fix(connection): keep LAN serving working under the /api browser-trust fence
Markerless requests pass on any Host (a non-browser sender is the principal and forges headers anyway); browser Host matching gains port-less entries and WHATWG normalization; dsh derives LAN IP-literal authorities for an all-interfaces bind and web grows --trusted-host for named ones.
This commit is contained in:
@@ -8,14 +8,19 @@ function request(headers: Record<string, string | undefined>): { headers: Record
|
||||
}
|
||||
|
||||
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,22 @@ 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('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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -80,6 +80,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({
|
||||
|
||||
Reference in New Issue
Block a user