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:
Moheeb Zara
2026-05-19 01:33:01 -07:00
parent 705f03a299
commit 42fd3859a4
3 changed files with 88 additions and 4 deletions
+38 -4
View File
@@ -5,7 +5,8 @@
// auth is a query param); it is never logged -- only a redacted snippet is
// 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 =
| { ok: true; status: number; snippet: string }
@@ -24,15 +25,48 @@ export async function callDoor(opts: {
const guard = validateDoorTarget(opts.url, opts.pinnedHost)
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 timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 6000)
try {
const res = await fetch(guard.url, {
const res = await fetch(pinned.toString(), {
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',
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',
})
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {