mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-11 15:27:22 +02:00
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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})`;
|
||||
|
||||
Reference in New Issue
Block a user