Merge remote-tracking branch 'origin/doc/host-client-group-readmes' into feat/directory-picker

This commit is contained in:
creatixchu
2026-07-28 16:42:36 +08:00
20 changed files with 281 additions and 68 deletions
@@ -1,21 +1,26 @@
/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
import { describe, expect, it } from 'vitest'
import { isTrustedApiRequest } from '../src/api-request-trust.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts'
function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
return { headers }
}
describe('isTrustedApiRequest', () => {
it('accepts loopback Hosts in every spelling, with and without ports', () => {
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']) {
expect(isTrustedApiRequest(request({ host }), [])).toBe(true)
it('accepts every request without browser markers — curl, tests, native clients, on any Host', () => {
// No Origin and no sec-fetch-site → the sender is the principal itself
// (it forges Host freely anyway); this is the LAN-serving shape a Host
// fence must not break.
for (const host of ['127.0.0.1:3080', '192.168.1.5:3080', 'harness.example', undefined]) {
expect(isTrustedApiRequest(request(host === undefined ? {} : { host }), [])).toBe(true)
}
})
it('accepts non-browser requests (no Origin, no sec-fetch-site) — curl, tests, native clients', () => {
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true)
it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => {
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']) {
expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true)
}
})
it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => {
@@ -26,13 +31,22 @@ describe('isTrustedApiRequest', () => {
}), [])).toBe(false)
})
it('accepts a declared public authority only on exact host[:port] match', () => {
it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => {
const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }
expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true)
expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(false)
expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true)
expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false)
expect(isTrustedApiRequest(request(headers), [])).toBe(false)
})
it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => {
expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true)
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true)
// An unparsable entry never matches; it must not poison the rest of the list.
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true)
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false)
})
it('refuses cross-origin browser markers even on a loopback Host', () => {
// Origin present and different → cross-site request that survived preflight rules.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false)
@@ -42,19 +56,45 @@ describe('isTrustedApiRequest', () => {
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false)
})
it('accepts a same-origin browser request', () => {
it('accepts a same-origin browser request, with or without an Origin header', () => {
expect(isTrustedApiRequest(request({
host: 'localhost:3080',
origin: 'http://localhost:3080',
'sec-fetch-site': 'same-origin',
}), [])).toBe(true)
// Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site.
expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
})
it('refuses malformed authorities', () => {
expect(isTrustedApiRequest(request({}), [])).toBe(false)
expect(isTrustedApiRequest(request({ host: '' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ host: 'bad host' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ host: '127.0.0.999' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ host: '128.0.0.1' }), [])).toBe(false)
it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => {
for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) {
expect(() => { assertTrustedAuthority(entry) }).not.toThrow()
}
// WHATWG parsing would quietly read a hostname out of each of these; the
// config boundary must refuse them instead of authorizing the prefix.
for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) {
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
}
// WHATWG trimming would silently strip these; the entry must fail instead.
for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) {
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
}
})
it('never lets stray whitespace broaden an exact-port entry to every port', () => {
// Defense in depth below the load-time assert: the explicit-port judgment
// reads the parsed URL, so a trimmed `host:port ` entry stays exact.
const trusted = ['harness.internal:3080 ']
expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false)
expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true)
})
it('refuses malformed or untrusted authorities on browser requests', () => {
const markers = { 'sec-fetch-site': 'same-origin' }
expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false)
})
})
@@ -54,6 +54,30 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes:
}
describe('connection node half', () => {
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
const routes: WebRoute[] = []
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
// The apply throw also escapes cordis as a late rejection — the shape the
// boot's installFailLoud is contracted to catch. Capture it so the run
// stays clean, same pattern as the webserver bind-failure test.
const rejections: unknown[] = []
const onUnhandled = (err: unknown): void => { rejections.push(err) }
process.on('unhandledRejection', onUnhandled)
try {
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/)
expect(routes).toHaveLength(0)
for (let i = 0; i < 100 && rejections.length === 0; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority')
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('registers the /api prefix route and removes it with the fiber', async () => {
const { routes, dispose } = await mounted()
expect(routes).toHaveLength(1)
@@ -80,6 +104,11 @@ describe('connection node half', () => {
const loopback = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
expect(loopback.state.status).toBe(404)
// Undeclared LAN authority, no browser markers: the `--host 0.0.0.0` curl
// shape must reach the bridge even with an empty-by-default trust list.
const lan = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
expect(lan.state.status).toBe(404)
// Declared public authority, same-origin browser shape.
const declared = fakeResponse()
await routes[0]!.handler(fakeRequest({