Files
deepseek-harness/packages/client/connection/src/api-request-trust.ts
T

177 lines
8.6 KiB
TypeScript

/**
* 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 (images and 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 listener's bind IP
* literal, or a declared `trustedHosts` authority. Binding policy belongs to
* the webserver config; this fence never establishes identity.
*/
import { isLoopbackHostname } from './loopback-hostname.ts'
import type { ConnectionTrustRequest } from './rpc.ts'
function header(headers: ConnectionTrustRequest['headers'], name: string): string | undefined {
if (headers instanceof Headers) return headers.get(name) ?? undefined
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
/**
* Normalized URL of a bare authority (hostname lowercased, default port
* stripped, IPv6 bracketed), or undefined when unparsable.
* @param authority - `host` or `host:port` text.
* @param scheme - special scheme to parse under; its default port is the one stripped.
*/
function parseAuthority(authority: string, scheme: 'http:' | 'https:' = 'http:'): URL | undefined {
try {
// http: and https: are WHATWG "special schemes": parsing yields a non-empty hostname or throws.
return new URL(`${scheme}//${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`, `[email protected]` — 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 {
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`${entryUrl.protocol === 'http:' ? 'https:' : 'http:'}//${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. Both sides compare through WHATWG normalization,
* so case and a redundant scheme-default port never decide trust.
*/
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[], protocol: 'http:' | 'https:'): boolean {
return trustedHosts.some((entry) => {
const entryUrl = parseAuthority(entry, protocol)
if (entryUrl === undefined) return false
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
? entryUrl.hostname === hostUrl.hostname
: entryUrl.host === hostUrl.host
})
}
/**
* Classify an authority already accepted by {@link assertTrustedAuthority}.
* @param entry - the configured authority, verbatim.
* @returns true unless the entry parses as a loopback hostname.
*/
export function isRemoteAuthority(entry: string): boolean {
const url = parseAuthority(entry)
return url === undefined || !isLoopbackHostname(url.hostname)
}
/**
* Whether a parsed authority names the deployment's own bind address. A browser
* dialing that literal sends it as Host, and accepting it grants nothing
* further: the literal is no rebinding target, the Origin and cross-site checks
* still apply, and it stays outside `trustedHosts`. Matching ignores the port,
* like a port-less entry, and any `%zone` id (an interface selector, not
* address text).
*/
function isBindAddressAuthority(hostUrl: URL, bindHost: string | undefined): boolean {
if (bindHost === undefined) return false
const zoneAt = bindHost.indexOf('%')
const address = zoneAt === -1 ? bindHost : bindHost.slice(0, zoneAt)
const bindUrl = parseAuthority(address.includes(':') ? `[${address}]` : address)
return bindUrl !== undefined && bindUrl.hostname === hostUrl.hostname
}
/**
* Decide whether one /api request may reach the RPC bridge.
* @param request - Node HTTP or Fetch request facts (headers).
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
* @param bindHost - the listener's own bind IP literal, accepted on any port independently of `trustedHosts`.
* @param protocol - listener protocol for default Host ports; HTTP when no Web carrier is mounted.
* @returns true when the Host is ours (loopback, the bind address, or trusted) and any attached browser markers are same-origin.
*/
export function isTrustedApiRequest(
request: ConnectionTrustRequest,
trustedHosts: readonly string[],
bindHost?: string,
protocol: 'http:' | 'https:' = 'http:',
): 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
// (images and 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, protocol)
if (hostUrl === undefined) return false
if (!isLoopbackHostname(hostUrl.hostname)
&& !isTrustedAuthority(hostUrl, trustedHosts, protocol)
&& !isBindAddressAuthority(hostUrl, bindHost)) 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
return isSameAuthorityOrigin(origin, host)
}
/**
* Compare Host under the Origin's HTTP(S) scheme so default ports normalize
* consistently. The transport may remain HTTP behind an HTTPS proxy.
* @param origin - verbatim Origin header value.
* @param host - verbatim Host header value already accepted by the Host fence.
* @returns true only when the Origin is this listener's own `http(s)` authority.
*/
function isSameAuthorityOrigin(origin: string, host: string): boolean {
let originUrl: URL
try {
originUrl = new URL(origin)
} catch {
return false
}
// Only http(s) can name this listener: the opaque `null` already threw above,
// and every other scheme (file:, ftp:) fails here rather than matching by
// hostname alone.
const scheme = originUrl.protocol === 'https:' ? 'https:'
: originUrl.protocol === 'http:' ? 'http:'
: undefined
if (scheme === undefined) return false
const hostUrl = parseAuthority(host, scheme)
return hostUrl !== undefined && originUrl.host === hostUrl.host
}