* 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. * * Checks both A (IPv4) and AAAA (IPv6) records — an attacker can use AAAA-only DNS to * bypass IPv4-only checks.
(hostname: string)
| 82 | * (e.g. no AAAA records exist) is not treated as a rebinding risk. |
| 83 | */ |
| 84 | async function resolvesToBlockedIp(hostname: string): Promise<boolean> { |
| 85 | try { |
| 86 | const dns = await import('node:dns'); |
| 87 | const { resolve4, resolve6 } = dns.promises; |
| 88 | |
| 89 | // Check IPv4 A records |
| 90 | const v4Check = resolve4(hostname).then( |
| 91 | (addresses) => addresses.some(addr => BLOCKED_METADATA_HOSTS.has(addr)), |
| 92 | () => false, // ENODATA / ENOTFOUND — no A records, not a risk |
| 93 | ); |
| 94 | |
| 95 | // Check IPv6 AAAA records — the gap that issue #668 identified |
| 96 | const v6Check = resolve6(hostname).then( |
| 97 | (addresses) => addresses.some(addr => { |
| 98 | const normalized = addr.toLowerCase(); |
| 99 | return BLOCKED_METADATA_HOSTS.has(normalized) || isBlockedIpv6(normalized); |
| 100 | }), |
| 101 | () => false, // ENODATA / ENOTFOUND — no AAAA records, not a risk |
| 102 | ); |
| 103 | |
| 104 | const [v4Blocked, v6Blocked] = await Promise.all([v4Check, v6Check]); |
| 105 | return v4Blocked || v6Blocked; |
| 106 | } catch { |
| 107 | // Unexpected error — fail open (don't block navigation on DNS infrastructure failure) |
| 108 | return false; |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * Normalize non-standard file:// URLs into absolute form before the WHATWG URL parser |
no test coverage detected