fix(door): defeat DNS-rebind by connecting to a validated resolved IP (D12)
Deferred deeper-audit P1. validateDoorTarget only string-checked the host; a bare fetch did its own DNS resolution, so a hostname could resolve to 127.0.0.1/169.254.169.254 between check and connect. The executor now resolves once (dns.lookup all), rejects if ANY resolved address is blocked (isBlockedDoorIp: loopback/unspecified/link-local/ metadata incl IPv4-mapped IPv6; RFC1918/LAN/ULA allowed since door controllers live there), then connects to the validated IP literal so fetch performs no second resolution (TOCTOU closed). Original Host kept best-effort; redirect:'manual' retained. Dependency-free (no undici seam). 33 door-logic tests; suite 532.
This commit is contained in:
@@ -3,6 +3,7 @@ import {
|
|||||||
last4,
|
last4,
|
||||||
maskCardUid,
|
maskCardUid,
|
||||||
isAlwaysBlockedHost,
|
isAlwaysBlockedHost,
|
||||||
|
isBlockedDoorIp,
|
||||||
normalizeHost,
|
normalizeHost,
|
||||||
validateDoorTarget,
|
validateDoorTarget,
|
||||||
encodeHeatSyncGrant,
|
encodeHeatSyncGrant,
|
||||||
@@ -32,6 +33,29 @@ describe('maskCardUid', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('isBlockedDoorIp (resolved-IP DNS-rebind guard)', () => {
|
||||||
|
it('blocks loopback / unspecified / link-local / metadata (v4)', () => {
|
||||||
|
for (const ip of ['127.0.0.1', '127.1.2.3', '0.0.0.0', '0.1.2.3', '169.254.1.2', '169.254.169.254']) {
|
||||||
|
expect(isBlockedDoorIp(ip)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('blocks v6 loopback/unspecified/link-local/AWS-IMDS + IPv4-mapped', () => {
|
||||||
|
for (const ip of ['::1', '::', 'fe80::1', 'fd00:ec2::254', '::ffff:127.0.0.1', '::ffff:169.254.169.254', '[::1]']) {
|
||||||
|
expect(isBlockedDoorIp(ip)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('ALLOWS RFC1918 / LAN / ULA / public (door controllers live there)', () => {
|
||||||
|
for (const ip of ['10.0.0.5', '172.16.4.4', '192.168.1.50', '203.0.113.7', 'fd12:3456::1', '2001:db8::1', '::ffff:192.168.1.9']) {
|
||||||
|
expect(isBlockedDoorIp(ip)).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('fails closed on unrecognizable / malformed input', () => {
|
||||||
|
for (const ip of ['', 'not-an-ip', '999.1.1.1', '1.2.3', 'door.local']) {
|
||||||
|
expect(isBlockedDoorIp(ip)).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('isAlwaysBlockedHost', () => {
|
describe('isAlwaysBlockedHost', () => {
|
||||||
it('blocks cloud metadata and link-local', () => {
|
it('blocks cloud metadata and link-local', () => {
|
||||||
expect(isAlwaysBlockedHost('169.254.169.254')).toBe(true)
|
expect(isAlwaysBlockedHost('169.254.169.254')).toBe(true)
|
||||||
|
|||||||
@@ -59,6 +59,32 @@ export function controllerForAdapter(adapter: string): KnownController | undefin
|
|||||||
// allowed to be called at all.
|
// allowed to be called at all.
|
||||||
|
|
||||||
// Always-blocked, regardless of pin: IMDS / link-local and unspecified.
|
// Always-blocked, regardless of pin: IMDS / link-local and unspecified.
|
||||||
|
// Block policy applied to a RESOLVED numeric IP (defeats DNS-rebind: the
|
||||||
|
// executor resolves once, checks every address here, then connects to the
|
||||||
|
// validated IP literal so fetch performs no second resolution). RFC1918/LAN
|
||||||
|
// (10/8, 172.16/12, 192.168/16) and ULA/GUA IPv6 are intentionally ALLOWED
|
||||||
|
// (door controllers live there); only loopback, unspecified, link-local and
|
||||||
|
// cloud-metadata are blocked. Unrecognizable input fails closed.
|
||||||
|
export function isBlockedDoorIp(ip: string): boolean {
|
||||||
|
let h = ip.trim().toLowerCase().replace(/^\[|\]$/g, '')
|
||||||
|
const mapped = h.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
|
||||||
|
if (mapped) h = mapped[1]
|
||||||
|
if (h.includes(':')) {
|
||||||
|
if (h === '::1' || h === '::' || h === '0:0:0:0:0:0:0:0') return true
|
||||||
|
if (h === 'fd00:ec2::254') return true // AWS IPv6 IMDS
|
||||||
|
if (h.startsWith('fe80:') || h.startsWith('fe80::')) return true // link-local
|
||||||
|
return false // other IPv6 (ULA/GUA/LAN) allowed
|
||||||
|
}
|
||||||
|
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
||||||
|
if (!m) return true // not a clean IPv4 -> fail closed
|
||||||
|
const o = m.slice(1).map(Number)
|
||||||
|
if (o.some(n => n > 255)) return true
|
||||||
|
if (o[0] === 127) return true // loopback 127.0.0.0/8
|
||||||
|
if (o[0] === 0) return true // 0.0.0.0/8 (incl. unspecified)
|
||||||
|
if (o[0] === 169 && o[1] === 254) return true // link-local + 169.254.169.254 metadata
|
||||||
|
return false // RFC1918 / LAN / public allowed
|
||||||
|
}
|
||||||
|
|
||||||
export function isAlwaysBlockedHost(host: string): boolean {
|
export function isAlwaysBlockedHost(host: string): boolean {
|
||||||
const h = host.toLowerCase().replace(/^\[|\]$/g, '')
|
const h = host.toLowerCase().replace(/^\[|\]$/g, '')
|
||||||
if (h === '169.254.169.254' || h === 'metadata' || h === 'metadata.google.internal') return true
|
if (h === '169.254.169.254' || h === 'metadata' || h === 'metadata.google.internal') return true
|
||||||
|
|||||||
+38
-4
@@ -5,7 +5,8 @@
|
|||||||
// auth is a query param); it is never logged -- only a redacted snippet is
|
// auth is a query param); it is never logged -- only a redacted snippet is
|
||||||
// ever returned.
|
// ever returned.
|
||||||
|
|
||||||
import { validateDoorTarget, redactDoorSecrets } from '@/lib/door-logic'
|
import dns from 'node:dns/promises'
|
||||||
|
import { validateDoorTarget, redactDoorSecrets, isBlockedDoorIp } from '@/lib/door-logic'
|
||||||
|
|
||||||
export type DoorCallResult =
|
export type DoorCallResult =
|
||||||
| { ok: true; status: number; snippet: string }
|
| { ok: true; status: number; snippet: string }
|
||||||
@@ -24,15 +25,48 @@ export async function callDoor(opts: {
|
|||||||
const guard = validateDoorTarget(opts.url, opts.pinnedHost)
|
const guard = validateDoorTarget(opts.url, opts.pinnedHost)
|
||||||
if (!guard.ok) return { ok: false, reason: guard.reason }
|
if (!guard.ok) return { ok: false, reason: guard.reason }
|
||||||
|
|
||||||
|
// Defeat DNS-rebinding: validateDoorTarget only string-checked the host,
|
||||||
|
// and a bare fetch would do its OWN resolution (a hostname could resolve
|
||||||
|
// to 127.0.0.1 / 169.254.169.254 between check and connect). Resolve once
|
||||||
|
// here, reject if ANY resolved address is blocked, then connect to the
|
||||||
|
// validated IP literal so fetch performs no second resolution. Plaintext
|
||||||
|
// HTTP to LAN devices that don't vhost, so connecting by IP (with a
|
||||||
|
// best-effort original Host header) is correct; no TLS/SNI concern.
|
||||||
|
const target = new URL(guard.url)
|
||||||
|
let resolved: { address: string; family: number }[]
|
||||||
|
try {
|
||||||
|
resolved = await dns.lookup(target.hostname, { all: true, verbatim: true })
|
||||||
|
} catch {
|
||||||
|
return { ok: false, reason: 'Could not resolve the controller host.' }
|
||||||
|
}
|
||||||
|
if (resolved.length === 0) {
|
||||||
|
return { ok: false, reason: 'The controller host did not resolve.' }
|
||||||
|
}
|
||||||
|
for (const a of resolved) {
|
||||||
|
if (isBlockedDoorIp(a.address)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: 'The controller host resolves to a blocked address (loopback / link-local / metadata).',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const pinned = new URL(guard.url)
|
||||||
|
const isV6 = resolved[0].family === 6
|
||||||
|
pinned.hostname = isV6 ? `[${resolved[0].address}]` : resolved[0].address
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 6000)
|
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 6000)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(guard.url, {
|
const res = await fetch(pinned.toString(), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
// Never chase a redirect: a 3xx could bounce us off the pinned host.
|
// Never chase a redirect: a 3xx could bounce us off the pinned host
|
||||||
|
// (and would re-resolve, escaping the IP pin).
|
||||||
redirect: 'manual',
|
redirect: 'manual',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: { accept: 'text/plain, text/html' },
|
// Host preserved best-effort so a vhosting controller still routes;
|
||||||
|
// single-purpose door firmware ignores it. The UA may drop a forbidden
|
||||||
|
// Host header — harmless for these devices.
|
||||||
|
headers: { accept: 'text/plain, text/html', host: target.host },
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
|
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user