2026-07-28 14:56:45 +08:00
|
|
|
/**
|
|
|
|
|
* 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 — without blocking non-browser clients
|
2026-07-28 15:40:02 +08:00
|
|
|
* (no browser markers → no deputy to confuse, and a native client forges Host
|
|
|
|
|
* freely anyway) or legitimately remote browsers (their authority is declared
|
|
|
|
|
* via `trustedHosts`, or derived by the composing app for IP-literal LAN
|
|
|
|
|
* serving). Network reachability and authentication stay out of scope: binding
|
|
|
|
|
* policy belongs to the webserver config, and this fence is not an auth layer.
|
2026-07-28 14:56:45 +08:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:40:02 +08:00
|
|
|
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
|
|
|
|
|
function parseAuthority(authority: string): URL | undefined {
|
2026-07-28 14:56:45 +08:00
|
|
|
try {
|
|
|
|
|
// http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
|
2026-07-28 15:40:02 +08:00
|
|
|
return new URL(`http://${authority}`)
|
2026-07-28 14:56:45 +08:00
|
|
|
} catch {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:57:02 +08:00
|
|
|
/**
|
|
|
|
|
* Assert one configured `trustedHosts` entry is a bare authority (`host` or
|
|
|
|
|
* `host:port`) and nothing else. WHATWG parsing would quietly read a hostname
|
|
|
|
|
* out of `harness.internal/path` or `user@harness.internal` — a typo must fail
|
|
|
|
|
* the load loudly instead of authorizing its hostname or being ignored until
|
2026-07-28 16:19:31 +08:00
|
|
|
* requests 403. The character test refuses every URL part beyond the authority
|
|
|
|
|
* (path, backslash path, query, fragment, userinfo) and all whitespace, which
|
|
|
|
|
* WHATWG trimming would otherwise strip silently; IPv6 brackets use none of
|
2026-07-28 15:57:02 +08:00
|
|
|
* them.
|
|
|
|
|
* @param entry - the configured value, verbatim.
|
|
|
|
|
*/
|
|
|
|
|
export function assertTrustedAuthority(entry: string): void {
|
2026-07-28 16:19:31 +08:00
|
|
|
if (parseAuthority(entry) !== undefined && !/[/\\?#@\s]/.test(entry)) return
|
2026-07-28 15:57:02 +08:00
|
|
|
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 16:19:31 +08:00
|
|
|
/**
|
|
|
|
|
* Whether the parsed authority carries an explicit port: 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 of stray whitespace would misread `host:port ` as port-less and
|
|
|
|
|
* broaden an exact-port grant to every port.
|
|
|
|
|
*/
|
|
|
|
|
function hasExplicitPort(entry: string, entryUrl: URL): boolean {
|
|
|
|
|
// An authority that parsed under http cannot fail under https.
|
|
|
|
|
return entryUrl.port !== '' || new URL(`https://${entry}`).port !== ''
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 15:40:02 +08:00
|
|
|
/**
|
|
|
|
|
* 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
|
2026-07-28 16:19:31 +08:00
|
|
|
return hasExplicitPort(entry, entryUrl)
|
2026-07-28 15:40:02 +08:00
|
|
|
? entryUrl.host === hostUrl.host
|
|
|
|
|
: entryUrl.hostname === hostUrl.hostname
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-28 14:56:45 +08:00
|
|
|
/**
|
|
|
|
|
* Decide whether one /api request may reach the RPC bridge.
|
|
|
|
|
* @param request - node HTTP request facts (headers).
|
2026-07-28 15:40:02 +08:00
|
|
|
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
|
|
|
|
* @returns true for requests without browser markers, and for browser requests whose Host is ours and whose markers are same-origin.
|
2026-07-28 14:56:45 +08:00
|
|
|
*/
|
|
|
|
|
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
|
2026-07-28 15:40:02 +08:00
|
|
|
// Marker gate: Origin and sec-fetch-site exist only when a browser is the
|
|
|
|
|
// sender's deputy. Absent both, the sender is the principal itself (curl,
|
|
|
|
|
// tests, native shells) and could forge every header below — fencing it
|
|
|
|
|
// would add nothing and would break non-browser LAN automation.
|
|
|
|
|
const origin = header(request.headers, 'origin')
|
|
|
|
|
const secFetchSite = header(request.headers, 'sec-fetch-site')
|
|
|
|
|
if (origin === undefined && secFetchSite === undefined) return true
|
2026-07-28 14:56:45 +08:00
|
|
|
// Host fence (DNS-rebinding defense): 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.
|
|
|
|
|
const host = header(request.headers, 'host')
|
|
|
|
|
if (host === undefined) return false
|
2026-07-28 15:40:02 +08:00
|
|
|
const hostUrl = parseAuthority(host)
|
|
|
|
|
if (hostUrl === undefined) return false
|
|
|
|
|
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
|
2026-07-28 14:56:45 +08:00
|
|
|
// Cross-site fence: modern browsers label the initiator relationship on
|
|
|
|
|
// every fetch; an explicit cross-site marker is refused regardless of Origin.
|
2026-07-28 15:40:02 +08:00
|
|
|
if (secFetchSite === 'cross-site') return false
|
2026-07-28 14:56:45 +08:00
|
|
|
// Origin fence: when a browser attaches an Origin it must be exactly this
|
2026-07-28 15:40:02 +08:00
|
|
|
// authority (compared through the same normalization as the Host). The
|
2026-07-28 14:56:45 +08:00
|
|
|
// literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
|
|
|
|
|
if (origin === undefined) return true
|
|
|
|
|
try {
|
2026-07-28 15:40:02 +08:00
|
|
|
return new URL(origin).host === hostUrl.host
|
2026-07-28 14:56:45 +08:00
|
|
|
} catch {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|