api-request-trust.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  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 (EventSource, images, 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, the
  11. * CLI-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. import type { IncomingHttpHeaders } from 'node:http'
  16. /** The request facts the fence reads (structural subset of IncomingMessage). */
  17. interface ApiTrustRequest {
  18. headers: IncomingHttpHeaders
  19. }
  20. function header(headers: IncomingHttpHeaders, name: string): string | undefined {
  21. const value = headers[name]
  22. return typeof value === 'string' ? value : undefined
  23. }
  24. function isLoopbackHostname(hostname: string): boolean {
  25. if (hostname === 'localhost' || hostname === '[::1]') return true
  26. const parts = hostname.split('.')
  27. return parts.length === 4
  28. && parts[0] === '127'
  29. && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
  30. }
  31. /** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
  32. function parseAuthority(authority: string): URL | undefined {
  33. try {
  34. // http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
  35. return new URL(`http://${authority}`)
  36. } catch {
  37. return undefined
  38. }
  39. }
  40. /**
  41. * Assert one configured `trustedHosts` entry is a bare authority (`host` or
  42. * `host:port`) in canonical form: it must survive WHATWG parsing unchanged
  43. * (case aside). Anything parsing would silently rewrite is refused as a typo
  44. * that must fail the load loudly instead of being ignored until requests 403
  45. * or quietly changing the grant: URL parts beyond the authority
  46. * (`harness.internal/path`, `user@harness.internal` — which would authorize
  47. * the embedded hostname), stripped whitespace, a dangling colon or
  48. * zero-padded port (which would broaden an intended exact-port grant to every
  49. * port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
  50. * unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
  51. * carries).
  52. * @param entry - the configured value, verbatim.
  53. */
  54. export function assertTrustedAuthority(entry: string): void {
  55. const entryUrl = parseAuthority(entry)
  56. if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return
  57. throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
  58. }
  59. /**
  60. * Canonical form of a parsed authority: `hostname` when no port was written,
  61. * else `hostname:port`. The port is judged from URL parses under both special
  62. * schemes (their default ports differ, so `:80` and `:443` still count as
  63. * explicit), never from the raw string, where WHATWG trimming would misread
  64. * shapes like `host:port ` as port-less.
  65. */
  66. function canonicalAuthority(entry: string, entryUrl: URL): string {
  67. // An authority that parsed under http cannot fail under https.
  68. const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
  69. return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
  70. }
  71. /**
  72. * Whether the request authority matches a `trustedHosts` entry. An entry with
  73. * an explicit port matches that exact authority; a port-less entry matches the
  74. * hostname on any port (the shape the CLI derives for IP-literal LAN serving,
  75. * where the bound port may be OS-assigned). Both sides compare through WHATWG
  76. * normalization, so case and a redundant `:80` never decide trust.
  77. */
  78. function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
  79. return trustedHosts.some((entry) => {
  80. const entryUrl = parseAuthority(entry)
  81. if (entryUrl === undefined) return false
  82. return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
  83. ? entryUrl.hostname === hostUrl.hostname
  84. : entryUrl.host === hostUrl.host
  85. })
  86. }
  87. /**
  88. * Decide whether one /api request may reach the RPC bridge.
  89. * @param request - node HTTP request facts (headers).
  90. * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
  91. * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
  92. */
  93. export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
  94. // Host fence (DNS-rebinding defense), applied to every request: the browser
  95. // fills Host from the URL it believes it is talking to, so a rebound page
  96. // carries the attacker's domain here even though the socket lands on this
  97. // server. There is no marker shortcut — a browser read over plain HTTP
  98. // (EventSource, images, navigations) arrives with neither Origin nor
  99. // Fetch-Metadata, indistinguishable from curl, and its response is readable
  100. // by the rebound page.
  101. const host = header(request.headers, 'host')
  102. if (host === undefined) return false
  103. const hostUrl = parseAuthority(host)
  104. if (hostUrl === undefined) return false
  105. if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
  106. // Cross-site fence: modern browsers label the initiator relationship on
  107. // every fetch; an explicit cross-site marker is refused regardless of Origin.
  108. if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
  109. // Origin fence: when a browser attaches an Origin it must be exactly this
  110. // authority (compared through the same normalization as the Host). Absent
  111. // Origin is fine — the Host fence above already bound the request. The
  112. // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
  113. const origin = header(request.headers, 'origin')
  114. if (origin === undefined) return true
  115. try {
  116. return new URL(origin).host === hostUrl.host
  117. } catch {
  118. return false
  119. }
  120. }