1
/**2
* Browser-trust fence for every /api request. Defends the two confused-deputy3
* paths a browser opens against a local HTTP API — DNS rebinding (Host names4
* the attacker's domain while the socket reaches this server) and cross-site5
* requests fired from a malicious page. The Host fence binds every request,6
* browser-looking or not: over plain HTTP a browser attaches neither Origin7
* nor Fetch-Metadata to reads (images and navigations — those8
* headers go only to trustworthy destinations), so an unmarked request may9
* still be a rebound browser read and Host is the one header rebinding cannot10
* forge. Non-browser and remote clients pass the same fence via loopback,11
* deployment-derived LAN IP literals, or a declared `trustedHosts` authority.12
* Network reachability and authentication stay out of scope: binding policy13
* belongs to the webserver config, and this fence is not an auth layer.14
*/16
import { isLoopbackHostname } from './loopback-hostname.ts'17
import type { ConnectionTrustRequest } from './rpc.ts'19
function header(headers: ConnectionTrustRequest['headers'], name: string): string | undefined {20
if (headers instanceof Headers) return headers.get(name) ?? undefined21
const value = headers[name]22
return typeof value === 'string' ? value : undefined23
}25
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */26
function parseAuthority(authority: string): URL | undefined {27
try {28
// http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.29
return new URL(`http://${authority}`)30
} catch {31
return undefined32
}33
}35
/**36
* Assert one configured `trustedHosts` entry is a bare authority (`host` or37
* `host:port`) in canonical form: it must survive WHATWG parsing unchanged38
* (case aside). Anything parsing would silently rewrite is refused as a typo39
* that must fail the load loudly instead of being ignored until requests 40340
* or quietly changing the grant: URL parts beyond the authority41
* (`harness.internal/path`, `[email protected]` — which would authorize42
* the embedded hostname), stripped whitespace, a dangling colon or43
* zero-padded port (which would broaden an intended exact-port grant to every44
* port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,45
* unbracketed IPv6; IDN hosts are declared in punycode, the form the wire46
* carries).47
* @param entry - the configured value, verbatim.48
*/49
export function assertTrustedAuthority(entry: string): void {50
const entryUrl = parseAuthority(entry)51
if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return52
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)53
}55
/**56
* Canonical form of a parsed authority: `hostname` when no port was written,57
* else `hostname:port`. The port is judged from URL parses under both special58
* schemes (their default ports differ, so `:80` and `:443` still count as59
* explicit), never from the raw string, where WHATWG trimming would misread60
* shapes like `host:port ` as port-less.61
*/62
function canonicalAuthority(entry: string, entryUrl: URL): string {63
// An authority that parsed under http cannot fail under https.64
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port65
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`66
}68
/**69
* Whether the request authority matches a `trustedHosts` entry. An entry with70
* an explicit port matches that exact authority; a port-less entry matches the71
* hostname on any port (the shape the CLI derives for IP-literal LAN serving,72
* where the bound port may be OS-assigned). Both sides compare through WHATWG73
* normalization, so case and a redundant `:80` never decide trust.74
*/75
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {76
return trustedHosts.some((entry) => {77
const entryUrl = parseAuthority(entry)78
if (entryUrl === undefined) return false79
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname80
? entryUrl.hostname === hostUrl.hostname81
: entryUrl.host === hostUrl.host82
})83
}85
/**86
* Decide whether one /api request may reach the RPC bridge.87
* @param request - Node HTTP or Fetch request facts (headers).88
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.89
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.90
*/91
export function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHosts: readonly string[]): boolean {92
// Host fence (DNS-rebinding defense), applied to every request: the browser93
// fills Host from the URL it believes it is talking to, so a rebound page94
// carries the attacker's domain here even though the socket lands on this95
// server. There is no marker shortcut — a browser read over plain HTTP96
// (images and navigations) arrives with neither Origin nor97
// Fetch-Metadata, indistinguishable from curl, and its response is readable98
// by the rebound page.99
const host = header(request.headers, 'host')100
if (host === undefined) return false101
const hostUrl = parseAuthority(host)102
if (hostUrl === undefined) return false103
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false104
// Cross-site fence: modern browsers label the initiator relationship on105
// every fetch; an explicit cross-site marker is refused regardless of Origin.106
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false107
// Origin fence: when a browser attaches an Origin it must be exactly this108
// authority (compared through the same normalization as the Host). Absent109
// Origin is fine — the Host fence above already bound the request. The110
// literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.111
const origin = header(request.headers, 'origin')112
if (origin === undefined) return true113
try {114
return new URL(origin).host === hostUrl.host115
} catch {116
return false117
}118
}