{"record":{"id":"1231a82c4ce72cd8","repo":"garrytan/gstack","slug":"blocked-parsed-hostname-resolves-to-a-cloud-me","errorCode":null,"errorMessage":"Blocked: ${parsed.hostname} resolves to a cloud metadata IP. Possible DNS rebinding attack.","messagePattern":"Blocked: (.+?) resolves to a cloud metadata IP\\. Possible DNS rebinding attack\\.","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"critical","filePath":"browse/src/url-validation.ts","lineNumber":292,"sourceCode":"      `Blocked: scheme \"${parsed.protocol}\" is not allowed. Only http:, https:, and file: URLs are permitted.`\n    );\n  }\n\n  const hostname = normalizeHostname(parsed.hostname.toLowerCase());\n\n  if (BLOCKED_METADATA_HOSTS.has(hostname) || isMetadataIp(hostname) || isBlockedIpv6(hostname)) {\n    throw new Error(\n      `Blocked: ${parsed.hostname} is a cloud metadata endpoint. Access is denied for security.`\n    );\n  }\n\n  // DNS rebinding protection: resolve hostname and check if it points to metadata IPs.\n  // Skip for loopback/private IPs — they can't be DNS-rebinded and the async DNS\n  // resolution adds latency that breaks concurrent E2E tests under load.\n  const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';\n  const isPrivateNet = /^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.)/.test(hostname);\n  if (!isLoopback && !isPrivateNet && await resolvesToBlockedIp(hostname)) {\n    throw new Error(\n      `Blocked: ${parsed.hostname} resolves to a cloud metadata IP. Possible DNS rebinding attack.`\n    );\n  }\n\n  return url;\n}\n","sourceCodeStart":274,"sourceCodeEnd":299,"githubUrl":"https://github.com/garrytan/gstack/blob/94993f74012782fd94416dd44b8314f6363a13a4/browse/src/url-validation.ts#L274-L299","documentation":"DNS-rebinding defense at url-validation.ts:287-293. After the literal-hostname block, the validator resolves the hostname (resolve4 + resolve6) and rejects it if any A/AAAA record points to a blocked metadata IP. Loopback and private-net literals are skipped to avoid latency in concurrent E2E runs.","triggerScenarios":"A hostname whose DNS A or AAAA records resolve to 169.254.169.254 or another blocked IP — the classic rebinding payload where the name looks benign but the address is the metadata service.","commonSituations":"Attacker-controlled domain referenced in scraped/templated URLs; stale or poisoned /etc/hosts or corporate DNS; a wildcard DNS service (e.g. nip.io style) pointed at the metadata IP.","solutions":["Do not navigate to untrusted or scraped hostnames without an allowlist","Resolve the hostname yourself (dig <host>) and confirm it is not a metadata IP","Route through a forward proxy that enforces destination allowlisting","If the target is legitimate, point it at a safe IP and re-test"],"exampleFix":null,"handlingStrategy":"validation","validationCode":"import { resolve4, resolve6 } from 'node:dns/promises'\nasync function resolvesToMetadata(host: string): Promise<boolean> {\n  const meta = new Set(['169.254.169.254'])\n  try {\n    const [v4, v6] = await Promise.all([resolve4(host), resolve6(host).catch(()=>[])])\n    return [...v4, ...v6].some(a => meta.has(a) || /^fd|^fe[89ab]/.test(a))\n  } catch { return false }\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Do not navigate to untrusted hostnames; use a destination allowlist","Resolve and inspect hostnames before handing them to goto","Route outbound traffic through an allowlisting proxy"],"tags":["ssrf","dns-rebinding","security","url-validation","navigation"],"backgroundTag":null,"analyzedSha":"94993f74012782fd94416dd44b8314f6363a13a4","analyzedAt":"2026-08-12T04:06:23.140Z","schemaVersion":2},"datasetVersion":"2026-08-12T13:17:24.610Z"}