fix(connection): fail the load on a trustedHosts entry that is not a bare authority
WHATWG parsing would quietly read a hostname out of harness.internal/path or user@harness.internal, authorizing the typo's hostname; other typos would sit silently ignored until requests 403. Refuse every URL part beyond host[:port] at plugin load.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
/** 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 }
|
||||
@@ -66,6 +66,17 @@ describe('isTrustedApiRequest', () => {
|
||||
expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
|
||||
})
|
||||
|
||||
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/)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses malformed or untrusted authorities on browser requests', () => {
|
||||
const markers = { 'sec-fetch-site': 'same-origin' }
|
||||
expect(isTrustedApiRequest(request({ ...markers }), [])).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)
|
||||
|
||||
Reference in New Issue
Block a user