fix(browse): DNS rebinding protection for SSRF blocklist

validateNavigationUrl is now async — resolves hostname to IP and checks
against blocked metadata IPs. Prevents DNS rebinding where evil.com
initially resolves to a safe IP, then switches to 169.254.169.254.
All callers updated to await. Tests updated for async assertions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-22 12:23:21 -07:00
parent 1471b65b4a
commit 0c915f9705
5 changed files with 62 additions and 39 deletions
+1 -1
View File
@@ -122,7 +122,7 @@ export class BrowserManager {
// Validate URL before allocating page to avoid zombie tabs on rejection
if (url) {
validateNavigationUrl(url);
await validateNavigationUrl(url);
}
const page = await this.context.newPage();
+2 -2
View File
@@ -223,11 +223,11 @@ export async function handleMetaCommand(
if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
const page = bm.getPage();
validateNavigationUrl(url1);
await validateNavigationUrl(url1);
await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 });
const text1 = await getCleanText(page);
validateNavigationUrl(url2);
await validateNavigationUrl(url2);
await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 });
const text2 = await getCleanText(page);
+24 -1
View File
@@ -44,7 +44,23 @@ function isMetadataIp(hostname: string): boolean {
return false;
}
export function validateNavigationUrl(url: string): void {
/**
* Resolve a hostname to its IP addresses and check if any resolve to blocked metadata IPs.
* Mitigates DNS rebinding: even if the hostname looks safe, the resolved IP might not be.
*/
async function resolvesToBlockedIp(hostname: string): Promise<boolean> {
try {
const dns = await import('node:dns');
const { resolve4 } = dns.promises;
const addresses = await resolve4(hostname);
return addresses.some(addr => BLOCKED_METADATA_HOSTS.has(addr));
} catch {
// DNS resolution failed — not a rebinding risk
return false;
}
}
export async function validateNavigationUrl(url: string): Promise<void> {
let parsed: URL;
try {
parsed = new URL(url);
@@ -65,4 +81,11 @@ export function validateNavigationUrl(url: string): void {
`Blocked: ${parsed.hostname} is a cloud metadata endpoint. Access is denied for security.`
);
}
// DNS rebinding protection: resolve hostname and check if it points to metadata IPs
if (await resolvesToBlockedIp(hostname)) {
throw new Error(
`Blocked: ${parsed.hostname} resolves to a cloud metadata IP. Possible DNS rebinding attack.`
);
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ export async function handleWriteCommand(
case 'goto': {
const url = args[0];
if (!url) throw new Error('Usage: browse goto <url>');
validateNavigationUrl(url);
await validateNavigationUrl(url);
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
const status = response?.status() || 'unknown';
return `Navigated to ${url} (${status})`;
+34 -34
View File
@@ -2,71 +2,71 @@ import { describe, it, expect } from 'bun:test';
import { validateNavigationUrl } from '../src/url-validation';
describe('validateNavigationUrl', () => {
it('allows http URLs', () => {
expect(() => validateNavigationUrl('http://example.com')).not.toThrow();
it('allows http URLs', async () => {
await expect(validateNavigationUrl('http://example.com')).resolves.toBeUndefined();
});
it('allows https URLs', () => {
expect(() => validateNavigationUrl('https://example.com/path?q=1')).not.toThrow();
it('allows https URLs', async () => {
await expect(validateNavigationUrl('https://example.com/path?q=1')).resolves.toBeUndefined();
});
it('allows localhost', () => {
expect(() => validateNavigationUrl('http://localhost:3000')).not.toThrow();
it('allows localhost', async () => {
await expect(validateNavigationUrl('http://localhost:3000')).resolves.toBeUndefined();
});
it('allows 127.0.0.1', () => {
expect(() => validateNavigationUrl('http://127.0.0.1:8080')).not.toThrow();
it('allows 127.0.0.1', async () => {
await expect(validateNavigationUrl('http://127.0.0.1:8080')).resolves.toBeUndefined();
});
it('allows private IPs', () => {
expect(() => validateNavigationUrl('http://192.168.1.1')).not.toThrow();
it('allows private IPs', async () => {
await expect(validateNavigationUrl('http://192.168.1.1')).resolves.toBeUndefined();
});
it('blocks file:// scheme', () => {
expect(() => validateNavigationUrl('file:///etc/passwd')).toThrow(/scheme.*not allowed/i);
it('blocks file:// scheme', async () => {
await expect(validateNavigationUrl('file:///etc/passwd')).rejects.toThrow(/scheme.*not allowed/i);
});
it('blocks javascript: scheme', () => {
expect(() => validateNavigationUrl('javascript:alert(1)')).toThrow(/scheme.*not allowed/i);
it('blocks javascript: scheme', async () => {
await expect(validateNavigationUrl('javascript:alert(1)')).rejects.toThrow(/scheme.*not allowed/i);
});
it('blocks data: scheme', () => {
expect(() => validateNavigationUrl('data:text/html,<h1>hi</h1>')).toThrow(/scheme.*not allowed/i);
it('blocks data: scheme', async () => {
await expect(validateNavigationUrl('data:text/html,<h1>hi</h1>')).rejects.toThrow(/scheme.*not allowed/i);
});
it('blocks AWS/GCP metadata endpoint', () => {
expect(() => validateNavigationUrl('http://169.254.169.254/latest/meta-data/')).toThrow(/cloud metadata/i);
it('blocks AWS/GCP metadata endpoint', async () => {
await expect(validateNavigationUrl('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(/cloud metadata/i);
});
it('blocks GCP metadata hostname', () => {
expect(() => validateNavigationUrl('http://metadata.google.internal/computeMetadata/v1/')).toThrow(/cloud metadata/i);
it('blocks GCP metadata hostname', async () => {
await expect(validateNavigationUrl('http://metadata.google.internal/computeMetadata/v1/')).rejects.toThrow(/cloud metadata/i);
});
it('blocks Azure metadata hostname', () => {
expect(() => validateNavigationUrl('http://metadata.azure.internal/metadata/instance')).toThrow(/cloud metadata/i);
it('blocks Azure metadata hostname', async () => {
await expect(validateNavigationUrl('http://metadata.azure.internal/metadata/instance')).rejects.toThrow(/cloud metadata/i);
});
it('blocks metadata hostname with trailing dot', () => {
expect(() => validateNavigationUrl('http://metadata.google.internal./computeMetadata/v1/')).toThrow(/cloud metadata/i);
it('blocks metadata hostname with trailing dot', async () => {
await expect(validateNavigationUrl('http://metadata.google.internal./computeMetadata/v1/')).rejects.toThrow(/cloud metadata/i);
});
it('blocks metadata IP in hex form', () => {
expect(() => validateNavigationUrl('http://0xA9FEA9FE/')).toThrow(/cloud metadata/i);
it('blocks metadata IP in hex form', async () => {
await expect(validateNavigationUrl('http://0xA9FEA9FE/')).rejects.toThrow(/cloud metadata/i);
});
it('blocks metadata IP in decimal form', () => {
expect(() => validateNavigationUrl('http://2852039166/')).toThrow(/cloud metadata/i);
it('blocks metadata IP in decimal form', async () => {
await expect(validateNavigationUrl('http://2852039166/')).rejects.toThrow(/cloud metadata/i);
});
it('blocks metadata IP in octal form', () => {
expect(() => validateNavigationUrl('http://0251.0376.0251.0376/')).toThrow(/cloud metadata/i);
it('blocks metadata IP in octal form', async () => {
await expect(validateNavigationUrl('http://0251.0376.0251.0376/')).rejects.toThrow(/cloud metadata/i);
});
it('blocks IPv6 metadata with brackets', () => {
expect(() => validateNavigationUrl('http://[fd00::]/')).toThrow(/cloud metadata/i);
it('blocks IPv6 metadata with brackets', async () => {
await expect(validateNavigationUrl('http://[fd00::]/')).rejects.toThrow(/cloud metadata/i);
});
it('throws on malformed URLs', () => {
expect(() => validateNavigationUrl('not-a-url')).toThrow(/Invalid URL/i);
it('throws on malformed URLs', async () => {
await expect(validateNavigationUrl('not-a-url')).rejects.toThrow(/Invalid URL/i);
});
});