diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 31a1f9de..43ce4c96 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -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(); diff --git a/browse/src/meta-commands.ts b/browse/src/meta-commands.ts index f1ebdea8..16ed7f84 100644 --- a/browse/src/meta-commands.ts +++ b/browse/src/meta-commands.ts @@ -223,11 +223,11 @@ export async function handleMetaCommand( if (!url1 || !url2) throw new Error('Usage: browse diff '); 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); diff --git a/browse/src/url-validation.ts b/browse/src/url-validation.ts index 11163d82..8c23d7c4 100644 --- a/browse/src/url-validation.ts +++ b/browse/src/url-validation.ts @@ -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 { + 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 { 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.` + ); + } } diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 1bf37eb5..73b44ca7 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -23,7 +23,7 @@ export async function handleWriteCommand( case 'goto': { const url = args[0]; if (!url) throw new Error('Usage: browse goto '); - 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})`; diff --git a/browse/test/url-validation.test.ts b/browse/test/url-validation.test.ts index f1dd301e..9b09db2f 100644 --- a/browse/test/url-validation.test.ts +++ b/browse/test/url-validation.test.ts @@ -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,

hi

')).toThrow(/scheme.*not allowed/i); + it('blocks data: scheme', async () => { + await expect(validateNavigationUrl('data:text/html,

hi

')).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); }); });