api-request-trust.host.spec.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. /** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
  2. import { describe, expect, it } from 'vitest'
  3. import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts'
  4. function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
  5. return { headers }
  6. }
  7. describe('isTrustedApiRequest', () => {
  8. it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => {
  9. // Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to
  10. // reads (EventSource, images, navigations), so a rebound-origin GET is
  11. // markerless and its response readable: no marker shortcut may exist.
  12. expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true)
  13. expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true)
  14. expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false)
  15. expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false)
  16. expect(isTrustedApiRequest(request({}), [])).toBe(false)
  17. })
  18. it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => {
  19. for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) {
  20. expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true)
  21. }
  22. })
  23. it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => {
  24. expect(isTrustedApiRequest(request({
  25. host: 'evil.example:3080',
  26. origin: 'http://evil.example:3080',
  27. 'sec-fetch-site': 'same-origin',
  28. }), [])).toBe(false)
  29. })
  30. it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => {
  31. const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }
  32. expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true)
  33. expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true)
  34. expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false)
  35. expect(isTrustedApiRequest(request(headers), [])).toBe(false)
  36. })
  37. it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => {
  38. expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true)
  39. expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true)
  40. // An unparsable entry never matches; it must not poison the rest of the list.
  41. expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true)
  42. expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false)
  43. })
  44. it('refuses cross-origin browser markers even on a loopback Host', () => {
  45. // Origin present and different → cross-site request that survived preflight rules.
  46. expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false)
  47. // Explicit cross-site label → refused regardless of Origin.
  48. expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false)
  49. // Opaque origin (sandboxed iframe, file: page) parses to no authority.
  50. expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false)
  51. })
  52. it('accepts a same-origin browser request, with or without an Origin header', () => {
  53. expect(isTrustedApiRequest(request({
  54. host: 'localhost:3080',
  55. origin: 'http://localhost:3080',
  56. 'sec-fetch-site': 'same-origin',
  57. }), [])).toBe(true)
  58. // Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site.
  59. expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
  60. })
  61. it('reads Fetch Headers while preserving absent browser markers', () => {
  62. expect(isTrustedApiRequest({ headers: new Headers({ host: '127.0.0.1:3080' }) }, [])).toBe(true)
  63. expect(isTrustedApiRequest({
  64. headers: new Headers({ host: '127.0.0.1:3080', origin: 'http://evil.example' }),
  65. }, [])).toBe(false)
  66. })
  67. it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => {
  68. for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) {
  69. expect(() => { assertTrustedAuthority(entry) }).not.toThrow()
  70. }
  71. // WHATWG parsing would quietly read a hostname out of each of these; the
  72. // config boundary must refuse them instead of authorizing the prefix.
  73. for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) {
  74. expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
  75. }
  76. // WHATWG trimming would silently strip these; the entry must fail instead.
  77. for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) {
  78. expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
  79. }
  80. // WHATWG parsing would silently rewrite these — a dangling colon or
  81. // zero-padded port would broaden an intended exact-port grant to every
  82. // port, and non-canonical host spellings would not read back as written.
  83. for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) {
  84. expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
  85. }
  86. })
  87. it('never lets stray whitespace broaden an exact-port entry to every port', () => {
  88. // Defense in depth below the load-time assert: the explicit-port judgment
  89. // reads the parsed URL, so a trimmed `host:port ` entry stays exact.
  90. const trusted = ['harness.internal:3080 ']
  91. expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false)
  92. expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true)
  93. })
  94. it('refuses malformed or untrusted authorities on browser requests', () => {
  95. const markers = { 'sec-fetch-site': 'same-origin' }
  96. expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false)
  97. expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false)
  98. expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false)
  99. expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false)
  100. expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false)
  101. })
  102. })