Skip to content

Commit 4877622

Browse files
committed
refactor(devframe): tighten origin-validation comments and tests
1 parent b055f1b commit 4877622

3 files changed

Lines changed: 66 additions & 138 deletions

File tree

packages/devframe/src/adapters/__tests__/initiate.test.ts

Lines changed: 49 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -352,127 +352,76 @@ describe('adapters/handler', () => {
352352
}
353353
})
354354

355-
it('a hostile first request never becomes the OTP-link origin; a later loopback one does', async () => {
356-
const wsPort = await getPort({ port: 18180, host: '127.0.0.1' })
355+
// The auth-link origin is derived from the served request's URL (the fetch
356+
// handler ignores the `Host` header — that path is `nodeMiddleware`'s), so
357+
// each case just points a request at the origin under test and inspects the
358+
// one-time banner (`console.log`).
359+
async function withBannerSpy(
360+
id: string,
361+
extra: Partial<Parameters<typeof initDevframe>[1]>,
362+
run: (devtools: ReturnType<typeof initDevframe>, spy: ReturnType<typeof vi.spyOn>) => Promise<void>,
363+
): Promise<void> {
364+
const wsPort = await getPort({ host: '127.0.0.1' })
357365
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
358-
const devtools = initDevframe(defineTestDef('handler-poison'), { base: '/__handler-poison/', host: '127.0.0.1', ws: { port: wsPort } })
359-
366+
const devtools = initDevframe(defineTestDef(id), { base: `/__${id}/`, host: '127.0.0.1', ws: { port: wsPort }, ...extra })
360367
try {
361368
await devtools.ready
362-
// A first request forging a non-loopback Host must not print, adopt, or
363-
// register that authority as the magic-link origin.
364-
await devtools.handler(new Request('http://evil.example.com/__handler-poison/__connection.json', {
365-
headers: { host: 'evil.example.com' },
366-
}))
367-
expect(spy).not.toHaveBeenCalled()
368-
369-
// A later loopback request is trusted, adopted, and prints exactly one
370-
// link pointing at that origin — the rejected candidate never locked it
371-
// out.
372-
await devtools.handler(new Request('http://localhost:4321/__handler-poison/__connection.json'))
373-
expect(spy).toHaveBeenCalledTimes(1)
374-
const link = String(spy.mock.calls[0])
375-
expect(link).toContain('http://localhost:4321/#')
376-
expect(link).not.toContain('evil.example.com')
377-
// The credential rides the fragment; assert only its presence.
378-
expect(link).toContain('#devframe_otp=')
379-
380-
// The first-valid origin is pinned: a second loopback request neither
381-
// re-prints nor moves it.
382-
await devtools.handler(new Request('http://127.0.0.1:9999/__handler-poison/__connection.json'))
383-
expect(spy).toHaveBeenCalledTimes(1)
369+
await run(devtools, spy)
384370
}
385371
finally {
386372
spy.mockRestore()
387373
await devtools.close()
388374
}
389-
})
390-
391-
it('adopts an exactly allow-listed non-loopback origin, but rejects a prefix/suffix near-match', async () => {
392-
const wsPort = await getPort({ port: 18181, host: '127.0.0.1' })
393-
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
394-
const devtools = initDevframe(defineTestDef('handler-allow'), {
395-
base: '/__handler-allow/',
396-
host: '127.0.0.1',
397-
ws: { port: wsPort },
398-
allowedOrigins: ['https://tools.example.com'],
399-
})
400-
401-
try {
402-
await devtools.ready
403-
// Only prefix/suffix-matches the allow-list entry — never adopted.
404-
await devtools.handler(new Request('https://tools.example.com.evil.com/__handler-allow/__connection.json', {
405-
headers: { host: 'tools.example.com.evil.com' },
406-
}))
407-
await devtools.handler(new Request('https://evil.tools.example.com/__handler-allow/__connection.json', {
408-
headers: { host: 'evil.tools.example.com' },
409-
}))
375+
}
376+
const hit = (devtools: ReturnType<typeof initDevframe>, origin: string): Promise<Response> =>
377+
devtools.handler(new Request(`${origin}/__connection.json`))
378+
379+
it('a hostile first request never becomes the OTP-link origin; a later loopback one does', () =>
380+
withBannerSpy('h-poison', {}, async (devtools, spy) => {
381+
// A forged non-loopback origin is not adopted and prints nothing.
382+
await hit(devtools, 'http://evil.example.com/__h-poison')
410383
expect(spy).not.toHaveBeenCalled()
384+
// A later loopback origin is adopted and prints exactly one OTP link
385+
// (the credential rides the fragment) — the reject never locked it out.
386+
await hit(devtools, 'http://localhost:4321/__h-poison')
387+
expect(spy).toHaveBeenCalledTimes(1)
388+
expect(String(spy.mock.calls[0])).toContain('http://localhost:4321/#devframe_otp=')
389+
expect(String(spy.mock.calls[0])).not.toContain('evil.example.com')
390+
// First-valid origin is pinned: a second loopback request doesn't move it.
391+
await hit(devtools, 'http://127.0.0.1:9999/__h-poison')
392+
expect(spy).toHaveBeenCalledTimes(1)
393+
}))
411394

412-
// The exact allow-listed origin is adopted.
413-
await devtools.handler(new Request('https://tools.example.com/__handler-allow/__connection.json', {
414-
headers: { host: 'tools.example.com' },
415-
}))
395+
it('adopts an exactly allow-listed non-loopback origin, but rejects a near-match', () =>
396+
withBannerSpy('h-allow', { allowedOrigins: ['https://tools.example.com'] }, async (devtools, spy) => {
397+
// Prefix/suffix near-matches of the allow-list entry are never adopted.
398+
await hit(devtools, 'https://tools.example.com.evil.com/__h-allow')
399+
await hit(devtools, 'https://evil.tools.example.com/__h-allow')
400+
expect(spy).not.toHaveBeenCalled()
401+
// The exact allow-listed origin is.
402+
await hit(devtools, 'https://tools.example.com/__h-allow')
416403
expect(spy).toHaveBeenCalledTimes(1)
417404
expect(String(spy.mock.calls[0])).toContain('https://tools.example.com/#')
418-
}
419-
finally {
420-
spy.mockRestore()
421-
await devtools.close()
422-
}
423-
})
405+
}))
424406

425-
it('an explicit origin wins regardless of the inbound Host', async () => {
426-
const wsPort = await getPort({ port: 18182, host: '127.0.0.1' })
427-
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
428-
const devtools = initDevframe(defineTestDef('handler-pinned'), {
429-
base: '/__handler-pinned/',
430-
host: '127.0.0.1',
431-
ws: { port: wsPort },
432-
origin: 'https://pinned.example.com',
433-
})
434-
435-
try {
436-
await devtools.ready
437-
// A pinned origin needs no request: the banner points at it from the
438-
// start, ignoring whatever Host a request forges.
407+
it('an explicit origin wins over any request', () =>
408+
withBannerSpy('h-pinned', { origin: 'https://pinned.example.com' }, async (devtools, spy) => {
409+
// Pinned: the banner points at it before any request, and a forged
410+
// request can't move it.
439411
expect(spy).toHaveBeenCalledTimes(1)
440412
expect(String(spy.mock.calls[0])).toContain('https://pinned.example.com/#')
441-
442-
await devtools.handler(new Request('http://evil.example.com/__handler-pinned/__connection.json', {
443-
headers: { host: 'evil.example.com' },
444-
}))
413+
await hit(devtools, 'http://evil.example.com/__h-pinned')
445414
expect(spy).toHaveBeenCalledTimes(1)
446-
expect(String(spy.mock.calls[0])).toContain('https://pinned.example.com/#')
447415
expect(String(spy.mock.calls[0])).not.toContain('evil.example.com')
448-
}
449-
finally {
450-
spy.mockRestore()
451-
await devtools.close()
452-
}
453-
})
454-
455-
it('canonicalizes the protocol and default port of an adopted origin', async () => {
456-
const wsPort = await getPort({ port: 18183, host: '127.0.0.1' })
457-
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
458-
const devtools = initDevframe(defineTestDef('handler-canon'), { base: '/__handler-canon/', host: '127.0.0.1', ws: { port: wsPort } })
416+
}))
459417

460-
try {
461-
await devtools.ready
462-
// An explicit :80 default port canonicalizes away in the advertised
463-
// origin, so the link carries no redundant port.
464-
await devtools.handler(new Request('http://localhost:80/__handler-canon/__connection.json', {
465-
headers: { host: 'localhost:80' },
466-
}))
418+
it('canonicalizes an adopted origin, dropping the default port', () =>
419+
withBannerSpy('h-canon', {}, async (devtools, spy) => {
420+
await hit(devtools, 'http://localhost:80/__h-canon')
467421
expect(spy).toHaveBeenCalledTimes(1)
468422
expect(String(spy.mock.calls[0])).toContain('http://localhost/#')
469423
expect(String(spy.mock.calls[0])).not.toContain('localhost:80')
470-
}
471-
finally {
472-
spy.mockRestore()
473-
await devtools.close()
474-
}
475-
})
424+
}))
476425

477426
it('bridge mode: without a distDir only meta + WS are served', async () => {
478427
const wsPort = await getPort({ port: 18160, host: '127.0.0.1' })

packages/devframe/src/node/instance-shell.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -607,18 +607,11 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
607607

608608
/**
609609
* Consider a request-derived origin candidate for the advertised public
610-
* origin (which backs the OTP magic link). Delegates the trust decision to
611-
* {@link validateOriginCandidate}: only a loopback host or an exact
612-
* `allowedOrigins` match is adopted, so a raw inbound `Host`/URL authority
613-
* never redirects the credential-bearing link. A dynamic `WsOriginRegistry`
614-
* or a disabled gate offers no static list, so it passes none and only
615-
* loopback candidates qualify.
616-
*
617-
* Keeps the first-valid-origin behavior: an invalid candidate is ignored
618-
* without setting `derivedOrigin`, so it neither prints a banner nor
619-
* registers a poisoned origin, and a later valid candidate can still be
620-
* adopted. Silent by design — a diagnostic here would let an unauthenticated
621-
* request amplify log noise.
610+
* origin (which backs the OTP magic link). {@link validateOriginCandidate}
611+
* adopts only a loopback host or an exact `allowedOrigins` match, so a raw
612+
* inbound `Host`/URL authority never redirects the credential-bearing link.
613+
* First-valid-origin wins: an invalid candidate leaves `derivedOrigin` unset
614+
* — printing/registering nothing — so a later valid one can still be adopted.
622615
*/
623616
function noteOrigin(candidate: string): void {
624617
if (derivedOrigin === undefined && !explicitOrigin()) {

packages/devframe/src/utils/origin.ts

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -64,23 +64,14 @@ export function isAllowedOrigin(origin: string | undefined, allowedOrigins: read
6464
}
6565

6666
/**
67-
* Canonicalize a request-derived origin candidate and decide whether it may
68-
* back a devframe's advertised public origin. That origin becomes the
69-
* destination of the OTP magic link, so a raw inbound authority is never
70-
* trusted: a candidate is adopted only when its parsed hostname is loopback,
71-
* or when its canonical origin exactly matches an `allowedOrigins` entry. A
72-
* caller with no static allow-list (a dynamic registry or a disabled gate)
73-
* passes none, so non-loopback adoption stays off — those deployments supply
74-
* an explicit origin instead.
75-
*
76-
* Unlike {@link isAllowedOrigin} — which accepts any origin-shaped string —
77-
* this rejects a candidate carrying credentials, a path, a query, a fragment,
78-
* a malformed port, or a non-HTTP(S) scheme, and returns the **canonical**
79-
* origin (default ports and casing normalized) rather than a boolean, so the
80-
* value that ends up in the magic link is always canonical. Forwarded headers
81-
* are never consulted.
82-
*
83-
* @returns the canonical origin to adopt, or `undefined` to reject.
67+
* Decide whether a request-derived origin candidate may back a devframe's
68+
* advertised public origin — the destination of the OTP magic link. Stricter
69+
* than {@link isAllowedOrigin}: it rejects credentials, a path, a query, a
70+
* fragment, a malformed port, and non-HTTP(S) schemes, and adopts a candidate
71+
* only when its hostname is loopback or its canonical origin exactly matches
72+
* an `allowedOrigins` entry (a caller with no static list passes none, so only
73+
* loopback qualifies). Returns the canonical origin to adopt, or `undefined`
74+
* to reject. Forwarded headers are never consulted.
8475
*/
8576
export function validateOriginCandidate(
8677
candidate: string,
@@ -95,19 +86,14 @@ export function validateOriginCandidate(
9586
}
9687
if (url.protocol !== 'http:' && url.protocol !== 'https:')
9788
return undefined
98-
// A canonical origin carries no credentials, path, query, or fragment; any
99-
// of these means the candidate was a full or poisoned URL, not a bare
100-
// authority safe to advertise.
101-
if (url.username || url.password || url.search || url.hash)
102-
return undefined
103-
if (url.pathname !== '/' && url.pathname !== '')
89+
// A canonical origin has no credentials, path, query, or fragment; any of
90+
// these means a full or poisoned URL, not a bare authority safe to advertise.
91+
if (url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== ''))
10492
return undefined
10593
const canonical = url.origin
10694
if (canonical === 'null')
10795
return undefined
108-
if (isLoopbackHostname(url.hostname))
109-
return canonical
110-
if (allowedOrigins?.includes(canonical))
96+
if (isLoopbackHostname(url.hostname) || allowedOrigins?.includes(canonical))
11197
return canonical
11298
return undefined
11399
}

0 commit comments

Comments
 (0)