返回源码地图

packages/client/connection/src/api-request-trust.ts

main snapshot · da00f7f5358f · 正文引用章节 15;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Browser-trust fence for every /api request. Defends the two confused-deputy
3 * paths a browser opens against a local HTTP API — DNS rebinding (Host names
4 * the attacker's domain while the socket reaches this server) and cross-site
5 * requests fired from a malicious page. The Host fence binds every request,
6 * browser-looking or not: over plain HTTP a browser attaches neither Origin
7 * nor Fetch-Metadata to reads (images and navigations — those
8 * headers go only to trustworthy destinations), so an unmarked request may
9 * still be a rebound browser read and Host is the one header rebinding cannot
10 * 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 policy
13 * belongs to the webserver config, and this fence is not an auth layer.
14 */
15
16import { isLoopbackHostname } from './loopback-hostname.ts'
17import type { ConnectionTrustRequest } from './rpc.ts'
18
19function header(headers: ConnectionTrustRequest['headers'], name: string): string | undefined {
20 if (headers instanceof Headers) return headers.get(name) ?? undefined
21 const value = headers[name]
22 return typeof value === 'string' ? value : undefined
23}
24
25/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
26function 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 undefined
32 }
33}
34
35/**
36 * Assert one configured `trustedHosts` entry is a bare authority (`host` or
37 * `host:port`) in canonical form: it must survive WHATWG parsing unchanged
38 * (case aside). Anything parsing would silently rewrite is refused as a typo
39 * that must fail the load loudly instead of being ignored until requests 403
40 * or quietly changing the grant: URL parts beyond the authority
41 * (`harness.internal/path`, `user@harness.internal` — which would authorize
42 * the embedded hostname), stripped whitespace, a dangling colon or
43 * zero-padded port (which would broaden an intended exact-port grant to every
44 * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
45 * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
46 * carries).
47 * @param entry - the configured value, verbatim.
48 */
49export function assertTrustedAuthority(entry: string): void {
50 const entryUrl = parseAuthority(entry)
51 if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return
52 throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
53}
54
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 special
58 * schemes (their default ports differ, so `:80` and `:443` still count as
59 * explicit), never from the raw string, where WHATWG trimming would misread
60 * shapes like `host:port ` as port-less.
61 */
62function 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}`).port
65 return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
66}
67
68/**
69 * Whether the request authority matches a `trustedHosts` entry. An entry with
70 * an explicit port matches that exact authority; a port-less entry matches the
71 * 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 WHATWG
73 * normalization, so case and a redundant `:80` never decide trust.
74 */
75function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
76 return trustedHosts.some((entry) => {
77 const entryUrl = parseAuthority(entry)
78 if (entryUrl === undefined) return false
79 return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
80 ? entryUrl.hostname === hostUrl.hostname
81 : entryUrl.host === hostUrl.host
82 })
83}
84
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 */
91export function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHosts: readonly string[]): boolean {
92 // Host fence (DNS-rebinding defense), applied to every request: the browser
93 // fills Host from the URL it believes it is talking to, so a rebound page
94 // carries the attacker's domain here even though the socket lands on this
95 // server. There is no marker shortcut — a browser read over plain HTTP
96 // (images and navigations) arrives with neither Origin nor
97 // Fetch-Metadata, indistinguishable from curl, and its response is readable
98 // by the rebound page.
99 const host = header(request.headers, 'host')
100 if (host === undefined) return false
101 const hostUrl = parseAuthority(host)
102 if (hostUrl === undefined) return false
103 if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
104 // Cross-site fence: modern browsers label the initiator relationship on
105 // every fetch; an explicit cross-site marker is refused regardless of Origin.
106 if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
107 // Origin fence: when a browser attaches an Origin it must be exactly this
108 // authority (compared through the same normalization as the Host). Absent
109 // Origin is fine — the Host fence above already bound the request. The
110 // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
111 const origin = header(request.headers, 'origin')
112 if (origin === undefined) return true
113 try {
114 return new URL(origin).host === hostUrl.host
115 } catch {
116 return false
117 }
118}