/** * Browser-trust fence for every /api request. Defends the two confused-deputy * paths a browser opens against a local HTTP API — DNS rebinding (Host names * the attacker's domain while the socket reaches this server) and cross-site * requests fired from a malicious page. The Host fence binds every request, * browser-looking or not: over plain HTTP a browser attaches neither Origin * nor Fetch-Metadata to reads (EventSource, images, navigations — those * headers go only to trustworthy destinations), so an unmarked request may * still be a rebound browser read and Host is the one header rebinding cannot * forge. Non-browser and remote clients pass the same fence via loopback, the * CLI-derived LAN IP literals, or a declared `trustedHosts` authority. * Network reachability and authentication stay out of scope: binding policy * belongs to the webserver config, and this fence is not an auth layer. */ import type { IncomingHttpHeaders } from 'node:http' /** The request facts the fence reads (structural subset of IncomingMessage). */ interface ApiTrustRequest { headers: IncomingHttpHeaders } function header(headers: IncomingHttpHeaders, name: string): string | undefined { const value = headers[name] return typeof value === 'string' ? value : undefined } function isLoopbackHostname(hostname: string): boolean { if (hostname === 'localhost' || hostname === '[::1]') return true const parts = hostname.split('.') return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255) } /** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */ function parseAuthority(authority: string): URL | undefined { try { // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws. return new URL(`http://${authority}`) } catch { return undefined } } /** * Assert one configured `trustedHosts` entry is a bare authority (`host` or * `host:port`) in canonical form: it must survive WHATWG parsing unchanged * (case aside). Anything parsing would silently rewrite is refused as a typo * that must fail the load loudly instead of being ignored until requests 403 * or quietly changing the grant: URL parts beyond the authority * (`harness.internal/path`, `user@harness.internal` — which would authorize * the embedded hostname), stripped whitespace, a dangling colon or * zero-padded port (which would broaden an intended exact-port grant to every * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding, * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire * carries). * @param entry - the configured value, verbatim. */ export function assertTrustedAuthority(entry: string): void { const entryUrl = parseAuthority(entry) if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`) } /** * Canonical form of a parsed authority: `hostname` when no port was written, * else `hostname:port`. The port is judged from URL parses under both special * schemes (their default ports differ, so `:80` and `:443` still count as * explicit), never from the raw string, where WHATWG trimming would misread * shapes like `host:port ` as port-less. */ function canonicalAuthority(entry: string, entryUrl: URL): string { // An authority that parsed under http cannot fail under https. const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}` } /** * Whether the request authority matches a `trustedHosts` entry. An entry with * an explicit port matches that exact authority; a port-less entry matches the * hostname on any port (the shape the CLI derives for IP-literal LAN serving, * where the bound port may be OS-assigned). Both sides compare through WHATWG * normalization, so case and a redundant `:80` never decide trust. */ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean { return trustedHosts.some((entry) => { const entryUrl = parseAuthority(entry) if (entryUrl === undefined) return false return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host }) } /** * Decide whether one /api request may reach the RPC bridge. * @param request - node HTTP request facts (headers). * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. */ export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean { // Host fence (DNS-rebinding defense), applied to every request: the browser // fills Host from the URL it believes it is talking to, so a rebound page // carries the attacker's domain here even though the socket lands on this // server. There is no marker shortcut — a browser read over plain HTTP // (EventSource, images, navigations) arrives with neither Origin nor // Fetch-Metadata, indistinguishable from curl, and its response is readable // by the rebound page. const host = header(request.headers, 'host') if (host === undefined) return false const hostUrl = parseAuthority(host) if (hostUrl === undefined) return false if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false // Cross-site fence: modern browsers label the initiator relationship on // every fetch; an explicit cross-site marker is refused regardless of Origin. if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false // Origin fence: when a browser attaches an Origin it must be exactly this // authority (compared through the same normalization as the Host). Absent // Origin is fine — the Host fence above already bound the request. The // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused. const origin = header(request.headers, 'origin') if (origin === undefined) return true try { return new URL(origin).host === hostUrl.host } catch { return false } }