From 08d1b80c415ce8fc08f5ea7f29c4332b8cf3d831 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 1 Sep 2026 06:10:55 +0000 Subject: [PATCH 1/4] fix(devframe): validate authentication link origins --- docs/content/1.guide/14.security.md | 2 + docs/content/2.adapters/1.initiate.md | 2 +- .../src/adapters/__tests__/initiate.test.ts | 122 ++++++++++++++++++ packages/devframe/src/adapters/initiate.ts | 10 +- packages/devframe/src/node/instance-shell.ts | 84 +++++++++++- plans/README.md | 2 +- 6 files changed, 210 insertions(+), 12 deletions(-) diff --git a/docs/content/1.guide/14.security.md b/docs/content/1.guide/14.security.md index e953731da..df9a91ff4 100644 --- a/docs/content/1.guide/14.security.md +++ b/docs/content/1.guide/14.security.md @@ -67,6 +67,8 @@ Devtools ready — authenticate this browser: http://localhost:3000/#devframe_ot The code rides the URL **fragment** (`#devframe_otp=…`), which browsers never send to the server, keeping the single-use code out of access logs and `Referer` headers. `connectDevframe` reads it, exchanges it, and strips it from the URL. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal). +The link points at the **public origin**. A standalone dev server derives it from its own bound address; an owned listener uses that address regardless of any inbound `Host` header. A handler or middleware without an explicit `origin` derives one from a request only when the request's own origin is loopback or exactly matches an `allowedOrigins` entry — a raw inbound authority and forwarded headers are never trusted. Set `origin` explicitly for non-loopback handler deployments (behind a proxy, on a LAN, or on a public host) so the magic link always resolves to the address you intend. + For your own auth UI, disable built-in handling with `otpParam: false`, then call `authenticateWithUrlOtp(rpc)` or `consumeOtpFromUrl()` from `devframe/client`. ## Practices for tools built on devframe diff --git a/docs/content/2.adapters/1.initiate.md b/docs/content/2.adapters/1.initiate.md index b4f411967..145d45a63 100644 --- a/docs/content/2.adapters/1.initiate.md +++ b/docs/content/2.adapters/1.initiate.md @@ -129,7 +129,7 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so ## Auth -The running devframe **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner once the public origin is known (the first request, or the `origin` option). Pass `auth: false` for single-user localhost, or a `DevframeAuthHandler` for a custom scheme. +The running devframe **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner once the public origin is known — from the `origin` option, or derived from a request whose own origin is loopback or exactly matches an `allowedOrigins` entry. A non-loopback deployment (behind a proxy, on a LAN, on a public host) sets `origin` explicitly so the magic link resolves to the intended address; a raw inbound `Host` header and forwarded headers are never trusted. Pass `auth: false` for single-user localhost, or a `DevframeAuthHandler` for a custom scheme. ## Relation to the other adapters diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index b3c1075ee..9edb3bf25 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -352,6 +352,128 @@ describe('adapters/handler', () => { } }) + it('a hostile first request never becomes the OTP-link origin; a later loopback one does', async () => { + const wsPort = await getPort({ port: 18180, host: '127.0.0.1' }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const devtools = initDevframe(defineTestDef('handler-poison'), { base: '/__handler-poison/', host: '127.0.0.1', ws: { port: wsPort } }) + + try { + await devtools.ready + // A first request forging a non-loopback Host must not print, adopt, or + // register that authority as the magic-link origin. + await devtools.handler(new Request('http://evil.example.com/__handler-poison/__connection.json', { + headers: { host: 'evil.example.com' }, + })) + expect(spy).not.toHaveBeenCalled() + + // A later loopback request is trusted, adopted, and prints exactly one + // link pointing at that origin — the rejected candidate never locked it + // out. + await devtools.handler(new Request('http://localhost:4321/__handler-poison/__connection.json')) + expect(spy).toHaveBeenCalledTimes(1) + const link = String(spy.mock.calls[0]) + expect(link).toContain('http://localhost:4321/#') + expect(link).not.toContain('evil.example.com') + // The credential rides the fragment; assert only its presence. + expect(link).toContain('#devframe_otp=') + + // The first-valid origin is pinned: a second loopback request neither + // re-prints nor moves it. + await devtools.handler(new Request('http://127.0.0.1:9999/__handler-poison/__connection.json')) + expect(spy).toHaveBeenCalledTimes(1) + } + finally { + spy.mockRestore() + await devtools.close() + } + }) + + it('adopts an exactly allow-listed non-loopback origin, but rejects a prefix/suffix near-match', async () => { + const wsPort = await getPort({ port: 18181, host: '127.0.0.1' }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const devtools = initDevframe(defineTestDef('handler-allow'), { + base: '/__handler-allow/', + host: '127.0.0.1', + ws: { port: wsPort }, + allowedOrigins: ['https://tools.example.com'], + }) + + try { + await devtools.ready + // Only prefix/suffix-matches the allow-list entry — never adopted. + await devtools.handler(new Request('https://tools.example.com.evil.com/__handler-allow/__connection.json', { + headers: { host: 'tools.example.com.evil.com' }, + })) + await devtools.handler(new Request('https://evil.tools.example.com/__handler-allow/__connection.json', { + headers: { host: 'evil.tools.example.com' }, + })) + expect(spy).not.toHaveBeenCalled() + + // The exact allow-listed origin is adopted. + await devtools.handler(new Request('https://tools.example.com/__handler-allow/__connection.json', { + headers: { host: 'tools.example.com' }, + })) + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).toContain('https://tools.example.com/#') + } + finally { + spy.mockRestore() + await devtools.close() + } + }) + + it('an explicit origin wins regardless of the inbound Host', async () => { + const wsPort = await getPort({ port: 18182, host: '127.0.0.1' }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const devtools = initDevframe(defineTestDef('handler-pinned'), { + base: '/__handler-pinned/', + host: '127.0.0.1', + ws: { port: wsPort }, + origin: 'https://pinned.example.com', + }) + + try { + await devtools.ready + // A pinned origin needs no request: the banner points at it from the + // start, ignoring whatever Host a request forges. + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).toContain('https://pinned.example.com/#') + + await devtools.handler(new Request('http://evil.example.com/__handler-pinned/__connection.json', { + headers: { host: 'evil.example.com' }, + })) + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).toContain('https://pinned.example.com/#') + expect(String(spy.mock.calls[0])).not.toContain('evil.example.com') + } + finally { + spy.mockRestore() + await devtools.close() + } + }) + + it('canonicalizes the protocol and default port of an adopted origin', async () => { + const wsPort = await getPort({ port: 18183, host: '127.0.0.1' }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const devtools = initDevframe(defineTestDef('handler-canon'), { base: '/__handler-canon/', host: '127.0.0.1', ws: { port: wsPort } }) + + try { + await devtools.ready + // An explicit :80 default port canonicalizes away in the advertised + // origin, so the link carries no redundant port. + await devtools.handler(new Request('http://localhost:80/__handler-canon/__connection.json', { + headers: { host: 'localhost:80' }, + })) + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).toContain('http://localhost/#') + expect(String(spy.mock.calls[0])).not.toContain('localhost:80') + } + finally { + spy.mockRestore() + await devtools.close() + } + }) + it('bridge mode: without a distDir only meta + WS are served', async () => { const wsPort = await getPort({ port: 18160, host: '127.0.0.1' }) const devtools = initDevframe(defineTestDef('handler-bridge'), { base: '/__handler-bridge/', auth: false, ws: { port: wsPort } }) diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 10b40acff..47278f9e6 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -95,10 +95,12 @@ export interface InitDevframeOptions { mcp?: boolean | McpRouteOptions /** * Public origin the host app is reachable at (e.g. `http://localhost:3000`), - * or a getter for hosts that resolve it late. When omitted (or the getter - * returns a falsy value), it is derived lazily from the first request the - * handler serves — used for the auth banner's magic link and absolute dock - * URLs. + * or a getter for hosts that resolve it late. Backs the auth banner's magic + * link and absolute dock URLs. When omitted (or the getter returns a falsy + * value), it is derived from a served request — but only when that request's + * own origin is loopback or exactly matches an `allowedOrigins` entry; a raw + * inbound `Host`/URL authority and forwarded headers are never adopted. Set + * this explicitly for a non-loopback deployment (proxy, LAN, public host). */ origin?: string | (() => string) /** diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 2f930f41d..3135ee442 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -552,9 +552,11 @@ export function createInstanceShell( // listener) — derive it from the first request and let the auth banner // wait for it, unless the caller pinned one (as a string or a getter). let derivedOrigin: string | undefined + function explicitOrigin(): string | undefined { + return typeof options.origin === 'function' ? options.origin() : options.origin + } function currentOrigin(): string | undefined { - const explicit = typeof options.origin === 'function' ? options.origin() : options.origin - return explicit || derivedOrigin + return explicitOrigin() || derivedOrigin } let authHandler: DevframeAuthHandler | undefined let bannerPrinted = false @@ -602,8 +604,78 @@ export function createInstanceShell( }).catch(() => {}) } - function noteOrigin(origin: string): void { - derivedOrigin ??= origin + // `isLoopbackHostname` lives in the WS transport module (whose top-level + // `crossws` import instance-shell keeps out of its own static graph), so it + // is pulled in lazily and cached the first time a candidate needs checking. + // An explicit or already-derived origin short-circuits before this loads, so + // the common cases (a pinned dev-server origin, every request after the + // first valid one) never touch the transport module. + let loopbackCheck: ((hostname: string) => boolean) | undefined + async function ensureLoopbackCheck(): Promise<(hostname: string) => boolean> { + if (!loopbackCheck) { + const mod = await import('devframe/rpc/transports/ws-server') + loopbackCheck = mod.isLoopbackHostname + } + return loopbackCheck + } + + /** + * Canonicalize a request-derived origin candidate and decide whether it may + * back the advertised origin. That origin becomes the destination of the OTP + * magic link, so a raw inbound authority is never trusted: a candidate is + * adopted only when its parsed hostname is loopback, or when its canonical + * origin exactly matches a configured `allowedOrigins` entry. A dynamic + * `WsOriginRegistry` or a disabled gate (`false`) offers no static list to + * match, so non-loopback adoption stays off there — those deployments supply + * an explicit `origin`. Returns the canonical origin, or `undefined` to + * reject (credentials, a path, a query, a fragment, a malformed port, a + * non-HTTP(S) scheme, or an untrusted host). Forwarded headers are never + * consulted. + */ + function validateOriginCandidate( + candidate: string, + isLoopback: (hostname: string) => boolean, + ): string | undefined { + let url: URL + try { + url = new URL(candidate) + } + catch { + return undefined + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') + return undefined + // A canonical origin carries no credentials, path, query, or fragment; any + // of these means the candidate was a full or poisoned URL, not a bare + // authority safe to advertise. + if (url.username || url.password || url.search || url.hash) + return undefined + if (url.pathname !== '/' && url.pathname !== '') + return undefined + const canonical = url.origin + if (canonical === 'null') + return undefined + if (isLoopback(url.hostname)) + return canonical + const allowed = options.allowedOrigins + if (Array.isArray(allowed) && allowed.includes(canonical)) + return canonical + return undefined + } + + /** + * Consider a request-derived origin candidate. Keeps the first-valid-origin + * behavior: an invalid candidate is ignored without setting `derivedOrigin`, + * so it neither prints a banner nor registers a poisoned origin, and a later + * valid candidate can still be adopted. Silent by design — a diagnostic here + * would let an unauthenticated request amplify log noise. + */ + async function noteOrigin(candidate: string): Promise { + if (derivedOrigin === undefined && !explicitOrigin()) { + const accepted = validateOriginCandidate(candidate, await ensureLoopbackCheck()) + if (accepted !== undefined) + derivedOrigin = accepted + } maybePrintBanner() maybeRegister() } @@ -854,7 +926,7 @@ export function createInstanceShell( async function handleRequest(request: Request): Promise { await initPromise - noteOrigin(new URL(request.url).origin) + await noteOrigin(new URL(request.url).origin) const response = await app.fetch(request) // Normalize a miss to a bare 404: an unmounted path falls through to // h3's default JSON-error handler, but for an asset host a body-less @@ -885,7 +957,7 @@ export function createInstanceShell( const host = req.headers.host if (host) { const encrypted = (req.socket as { encrypted?: boolean }).encrypted - noteOrigin(`${encrypted ? 'https' : 'http'}://${host}`) + await noteOrigin(`${encrypted ? 'https' : 'http'}://${host}`) } if (!nodeHandler) { const { toNodeHandler } = await import('h3/node') diff --git a/plans/README.md b/plans/README.md index c263bd731..501db0b12 100644 --- a/plans/README.md +++ b/plans/README.md @@ -11,7 +11,7 @@ Generated by the improve skill on 2026-09-01 at commit `2d978f84`. Execute in th | 003 | Enforce shared-state exposure policy on direct MCP reads | P1 | S | 002 | TODO | | 004 | Contain remote asset materialization | P1 | S | - | TODO | | 005 | Block Data Inspector prototype-chain writes | P1 | S | - | TODO | -| 006 | Validate request-derived authentication-link origins | P1 | M | - | TODO | +| 006 | Validate request-derived authentication-link origins | P1 | M | - | DONE | | 007 | Reject pre-existing symlink escapes from filesystem roots | P2 | M | - | TODO | Status values: TODO | IN PROGRESS | DONE | BLOCKED (with reason) | REJECTED (with rationale) From f82f8cd5ea9e3237f2e74c00c09841ecfcfb83b1 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 1 Sep 2026 06:25:45 +0000 Subject: [PATCH 2/4] refactor(devframe): extract origin utils into devframe/utils/origin Address review: move isLoopbackHostname/isAllowedOrigin out of the crossws-carrying ws-server transport into a dependency-free devframe/utils/origin, and group the auth-link origin validation (validateOriginCandidate) alongside them. instance-shell now imports the validator statically instead of dynamically importing the whole transport module. ws-server re-exports both predicates to keep its public API path intact; sse-server and the MCP fetch gate import the check from the util directly. --- packages/devframe/package.json | 1 + packages/devframe/src/adapters/mcp/fetch.ts | 2 +- packages/devframe/src/node/instance-shell.ts | 87 +++----------- .../devframe/src/rpc/transports/sse-server.ts | 2 +- .../devframe/src/rpc/transports/ws-server.ts | 64 ++-------- packages/devframe/src/utils/origin.ts | 113 ++++++++++++++++++ .../devframe/test/runtime-agnostic.test.ts | 1 + packages/devframe/tsdown.config.ts | 2 + tsconfig.base.json | 3 + 9 files changed, 149 insertions(+), 126 deletions(-) create mode 100644 packages/devframe/src/utils/origin.ts diff --git a/packages/devframe/package.json b/packages/devframe/package.json index 95d9e729e..60eef7fc5 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -56,6 +56,7 @@ "./utils/nanoid": "./dist/utils/nanoid.mjs", "./utils/nostics": "./dist/utils/nostics.mjs", "./utils/open": "./dist/utils/open.mjs", + "./utils/origin": "./dist/utils/origin.mjs", "./utils/remote-assets": "./dist/utils/remote-assets.mjs", "./utils/simple-schema": "./dist/utils/simple-schema.mjs", "./utils/serve-static": "./dist/utils/serve-static.mjs", diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts index 384e6deff..76269b9f1 100644 --- a/packages/devframe/src/adapters/mcp/fetch.ts +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -1,6 +1,6 @@ import type { DevframeNodeContext } from 'devframe/types' import { createMcpHandler } from '@modelcontextprotocol/server' -import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' +import { isAllowedOrigin } from 'devframe/utils/origin' import { bridgeListChanged, buildMcpServerFromContext } from './build-server' export interface CreateMcpFetchHandlerOptions { diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 3135ee442..2f43d6eaf 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -14,6 +14,7 @@ import type { DevframeInstanceRecord, DevframeInstanceRegistration } from './ins import type { ContextRpcServer } from './rpc-core' import { createServer } from 'node:http' import process from 'node:process' +import { validateOriginCandidate } from 'devframe/utils/origin' import { defineHandler, H3 as H3App, toNodeHandler } from 'h3' import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'ufo' import { DEVFRAME_SSE_ROUTE, DEVFRAME_WS_ROUTE } from '../constants' @@ -604,75 +605,25 @@ export function createInstanceShell( }).catch(() => {}) } - // `isLoopbackHostname` lives in the WS transport module (whose top-level - // `crossws` import instance-shell keeps out of its own static graph), so it - // is pulled in lazily and cached the first time a candidate needs checking. - // An explicit or already-derived origin short-circuits before this loads, so - // the common cases (a pinned dev-server origin, every request after the - // first valid one) never touch the transport module. - let loopbackCheck: ((hostname: string) => boolean) | undefined - async function ensureLoopbackCheck(): Promise<(hostname: string) => boolean> { - if (!loopbackCheck) { - const mod = await import('devframe/rpc/transports/ws-server') - loopbackCheck = mod.isLoopbackHostname - } - return loopbackCheck - } - - /** - * Canonicalize a request-derived origin candidate and decide whether it may - * back the advertised origin. That origin becomes the destination of the OTP - * magic link, so a raw inbound authority is never trusted: a candidate is - * adopted only when its parsed hostname is loopback, or when its canonical - * origin exactly matches a configured `allowedOrigins` entry. A dynamic - * `WsOriginRegistry` or a disabled gate (`false`) offers no static list to - * match, so non-loopback adoption stays off there — those deployments supply - * an explicit `origin`. Returns the canonical origin, or `undefined` to - * reject (credentials, a path, a query, a fragment, a malformed port, a - * non-HTTP(S) scheme, or an untrusted host). Forwarded headers are never - * consulted. - */ - function validateOriginCandidate( - candidate: string, - isLoopback: (hostname: string) => boolean, - ): string | undefined { - let url: URL - try { - url = new URL(candidate) - } - catch { - return undefined - } - if (url.protocol !== 'http:' && url.protocol !== 'https:') - return undefined - // A canonical origin carries no credentials, path, query, or fragment; any - // of these means the candidate was a full or poisoned URL, not a bare - // authority safe to advertise. - if (url.username || url.password || url.search || url.hash) - return undefined - if (url.pathname !== '/' && url.pathname !== '') - return undefined - const canonical = url.origin - if (canonical === 'null') - return undefined - if (isLoopback(url.hostname)) - return canonical - const allowed = options.allowedOrigins - if (Array.isArray(allowed) && allowed.includes(canonical)) - return canonical - return undefined - } - /** - * Consider a request-derived origin candidate. Keeps the first-valid-origin - * behavior: an invalid candidate is ignored without setting `derivedOrigin`, - * so it neither prints a banner nor registers a poisoned origin, and a later - * valid candidate can still be adopted. Silent by design — a diagnostic here - * would let an unauthenticated request amplify log noise. + * Consider a request-derived origin candidate for the advertised public + * origin (which backs the OTP magic link). Delegates the trust decision to + * {@link validateOriginCandidate}: only a loopback host or an exact + * `allowedOrigins` match is adopted, so a raw inbound `Host`/URL authority + * never redirects the credential-bearing link. A dynamic `WsOriginRegistry` + * or a disabled gate offers no static list, so it passes none and only + * loopback candidates qualify. + * + * Keeps the first-valid-origin behavior: an invalid candidate is ignored + * without setting `derivedOrigin`, so it neither prints a banner nor + * registers a poisoned origin, and a later valid candidate can still be + * adopted. Silent by design — a diagnostic here would let an unauthenticated + * request amplify log noise. */ - async function noteOrigin(candidate: string): Promise { + function noteOrigin(candidate: string): void { if (derivedOrigin === undefined && !explicitOrigin()) { - const accepted = validateOriginCandidate(candidate, await ensureLoopbackCheck()) + const allowed = options.allowedOrigins + const accepted = validateOriginCandidate(candidate, Array.isArray(allowed) ? allowed : undefined) if (accepted !== undefined) derivedOrigin = accepted } @@ -926,7 +877,7 @@ export function createInstanceShell( async function handleRequest(request: Request): Promise { await initPromise - await noteOrigin(new URL(request.url).origin) + noteOrigin(new URL(request.url).origin) const response = await app.fetch(request) // Normalize a miss to a bare 404: an unmounted path falls through to // h3's default JSON-error handler, but for an asset host a body-less @@ -957,7 +908,7 @@ export function createInstanceShell( const host = req.headers.host if (host) { const encrypted = (req.socket as { encrypted?: boolean }).encrypted - await noteOrigin(`${encrypted ? 'https' : 'http'}://${host}`) + noteOrigin(`${encrypted ? 'https' : 'http'}://${host}`) } if (!nodeHandler) { const { toNodeHandler } = await import('h3/node') diff --git a/packages/devframe/src/rpc/transports/sse-server.ts b/packages/devframe/src/rpc/transports/sse-server.ts index eb941648d..52ae49d11 100644 --- a/packages/devframe/src/rpc/transports/sse-server.ts +++ b/packages/devframe/src/rpc/transports/sse-server.ts @@ -3,9 +3,9 @@ import type { RpcFunctionDefinitionAny } from '../types' import type { DevframeNodeRpcSessionMeta, DevframeRpcConnection } from './session' import type { WsOriginRegistry } from './ws-server' import { DEVFRAME_SSE_SESSION_HEADER } from 'devframe/constants' +import { isAllowedOrigin } from 'devframe/utils/origin' import { createRpcWireCodec, peekRpcWireFrame } from '../wire-codec' import { createRpcSessionMeta } from './session' -import { isAllowedOrigin } from './ws-server' export interface SseRpcTransportOptions { /** diff --git a/packages/devframe/src/rpc/transports/ws-server.ts b/packages/devframe/src/rpc/transports/ws-server.ts index 68a2257f3..5af64c060 100644 --- a/packages/devframe/src/rpc/transports/ws-server.ts +++ b/packages/devframe/src/rpc/transports/ws-server.ts @@ -13,6 +13,7 @@ import { createServer as createHttpsServer } from 'node:https' import crossws from 'crossws/adapters/node' import { DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM } from 'devframe/constants' import { randomToken, timingSafeEqual } from 'devframe/utils/crypto-token' +import { isAllowedOrigin } from 'devframe/utils/origin' import { createRpcWireCodec } from '../wire-codec' import { createRpcSessionMeta } from './session' @@ -226,62 +227,13 @@ function pathMatches(a: string, b: string): boolean { return strip(a) === strip(b) } -/** - * Whether `hostname` names a loopback host: `localhost` (or any `*.localhost` - * subdomain), the IPv6 loopback `::1`, or an IPv4 literal inside the - * `127.0.0.0/8` loopback block. - * - * The IPv4 case is matched **structurally** — the whole hostname must be a - * canonical dotted-decimal IPv4 literal whose first octet is `127`. A bare - * `startsWith('127.')` prefix check would also accept an attacker-controlled - * DNS name that merely *begins* with `127.` (`127.attacker.example`, - * `127.0.0.1.attacker.example`), letting a cross-origin browser page defeat - * the loopback origin gate that guards the RPC/MCP surface (a DNS-rebinding / - * cross-site WebSocket-hijacking bypass). Requiring a real IPv4 literal keeps - * genuine loopback addresses (`127.0.0.1`, `127.5.5.5`) allowed while rejecting - * those DNS names. - */ -export function isLoopbackHostname(hostname: string): boolean { - const h = hostname.replace(/^\[|\]$/g, '') // strip IPv6 brackets - if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') - return true - return isLoopbackIPv4(h) -} - -/** A canonical dotted-decimal IPv4 literal in `127.0.0.0/8`. */ -function isLoopbackIPv4(hostname: string): boolean { - const octets = hostname.split('.') - if (octets.length !== 4 || !octets.every(isDecimalOctet)) - return false - return Number(octets[0]) === 127 -} - -/** A single canonical IPv4 octet: 1–3 digits, no leading zero, value 0–255. */ -function isDecimalOctet(part: string): boolean { - if (!/^\d{1,3}$/.test(part) || (part.length > 1 && part[0] === '0')) - return false - return Number(part) <= 255 -} - -/** - * Default origin policy for a localhost dev tool: allow requests with no - * `Origin` header (native, non-browser clients), allow any loopback origin - * (so cross-port localhost dev setups keep working), and allow explicitly - * configured origins. Everything else — a real remote page in the dev's - * browser — is rejected. - */ -export function isAllowedOrigin(origin: string | undefined, allowedOrigins: readonly string[]): boolean { - if (!origin) - return true - if (allowedOrigins.includes(origin)) - return true - try { - return isLoopbackHostname(new URL(origin).hostname) - } - catch { - return false - } -} +// The loopback / origin predicates live in the dependency-free +// `devframe/utils/origin` module so consumers that only need one check (e.g. +// the instance shell's auth-link origin validation) don't import this whole +// `crossws`-carrying transport. Re-exported here to keep the historical +// `devframe/rpc/transports/ws-server` import path for `isAllowedOrigin` / +// `isLoopbackHostname` intact. +export { isAllowedOrigin, isLoopbackHostname } from 'devframe/utils/origin' function isWsOriginRegistry( value: readonly string[] | WsOriginRegistry | false | undefined, diff --git a/packages/devframe/src/utils/origin.ts b/packages/devframe/src/utils/origin.ts new file mode 100644 index 000000000..86ef28c32 --- /dev/null +++ b/packages/devframe/src/utils/origin.ts @@ -0,0 +1,113 @@ +/** + * Origin and hostname predicates shared by the RPC transports (the WS upgrade, + * SSE, and MCP origin gates) and the instance shell's authentication-link + * origin validation. Kept dependency-free and runtime-agnostic so any consumer + * can pull in a single check without dragging in a transport's `crossws` + * import. + */ + +/** + * Whether `hostname` names a loopback host: `localhost` (or any `*.localhost` + * subdomain), the IPv6 loopback `::1`, or an IPv4 literal inside the + * `127.0.0.0/8` loopback block. + * + * The IPv4 case is matched **structurally** — the whole hostname must be a + * canonical dotted-decimal IPv4 literal whose first octet is `127`. A bare + * `startsWith('127.')` prefix check would also accept an attacker-controlled + * DNS name that merely *begins* with `127.` (`127.attacker.example`, + * `127.0.0.1.attacker.example`), letting a cross-origin browser page defeat + * the loopback origin gate that guards the RPC/MCP surface (a DNS-rebinding / + * cross-site WebSocket-hijacking bypass). Requiring a real IPv4 literal keeps + * genuine loopback addresses (`127.0.0.1`, `127.5.5.5`) allowed while rejecting + * those DNS names. + */ +export function isLoopbackHostname(hostname: string): boolean { + const h = hostname.replace(/^\[|\]$/g, '') // strip IPv6 brackets + if (h === 'localhost' || h.endsWith('.localhost') || h === '::1') + return true + return isLoopbackIPv4(h) +} + +/** A canonical dotted-decimal IPv4 literal in `127.0.0.0/8`. */ +function isLoopbackIPv4(hostname: string): boolean { + const octets = hostname.split('.') + if (octets.length !== 4 || !octets.every(isDecimalOctet)) + return false + return Number(octets[0]) === 127 +} + +/** A single canonical IPv4 octet: 1–3 digits, no leading zero, value 0–255. */ +function isDecimalOctet(part: string): boolean { + if (!/^\d{1,3}$/.test(part) || (part.length > 1 && part[0] === '0')) + return false + return Number(part) <= 255 +} + +/** + * Default origin policy for a localhost dev tool: allow requests with no + * `Origin` header (native, non-browser clients), allow any loopback origin + * (so cross-port localhost dev setups keep working), and allow explicitly + * configured origins. Everything else — a real remote page in the dev's + * browser — is rejected. + */ +export function isAllowedOrigin(origin: string | undefined, allowedOrigins: readonly string[]): boolean { + if (!origin) + return true + if (allowedOrigins.includes(origin)) + return true + try { + return isLoopbackHostname(new URL(origin).hostname) + } + catch { + return false + } +} + +/** + * Canonicalize a request-derived origin candidate and decide whether it may + * back a devframe's advertised public origin. That origin becomes the + * destination of the OTP magic link, so a raw inbound authority is never + * trusted: a candidate is adopted only when its parsed hostname is loopback, + * or when its canonical origin exactly matches an `allowedOrigins` entry. A + * caller with no static allow-list (a dynamic registry or a disabled gate) + * passes none, so non-loopback adoption stays off — those deployments supply + * an explicit origin instead. + * + * Unlike {@link isAllowedOrigin} — which accepts any origin-shaped string — + * this rejects a candidate carrying credentials, a path, a query, a fragment, + * a malformed port, or a non-HTTP(S) scheme, and returns the **canonical** + * origin (default ports and casing normalized) rather than a boolean, so the + * value that ends up in the magic link is always canonical. Forwarded headers + * are never consulted. + * + * @returns the canonical origin to adopt, or `undefined` to reject. + */ +export function validateOriginCandidate( + candidate: string, + allowedOrigins?: readonly string[], +): string | undefined { + let url: URL + try { + url = new URL(candidate) + } + catch { + return undefined + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') + return undefined + // A canonical origin carries no credentials, path, query, or fragment; any + // of these means the candidate was a full or poisoned URL, not a bare + // authority safe to advertise. + if (url.username || url.password || url.search || url.hash) + return undefined + if (url.pathname !== '/' && url.pathname !== '') + return undefined + const canonical = url.origin + if (canonical === 'null') + return undefined + if (isLoopbackHostname(url.hostname)) + return canonical + if (allowedOrigins?.includes(canonical)) + return canonical + return undefined +} diff --git a/packages/devframe/test/runtime-agnostic.test.ts b/packages/devframe/test/runtime-agnostic.test.ts index c7eb141ac..46e42ea47 100644 --- a/packages/devframe/test/runtime-agnostic.test.ts +++ b/packages/devframe/test/runtime-agnostic.test.ts @@ -14,6 +14,7 @@ const AGNOSTIC_ENTRIES = [ 'utils/events.mjs', 'utils/hash.mjs', 'utils/nanoid.mjs', + 'utils/origin.mjs', 'utils/shared-state.mjs', 'utils/streaming-channel.mjs', 'utils/structured-clone.mjs', diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 5ee2389a6..8adba2877 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -78,6 +78,7 @@ const clientEntries = { 'utils/events': 'src/utils/events.ts', 'utils/hash': 'src/utils/hash.ts', 'utils/nanoid': 'src/utils/nanoid.ts', + 'utils/origin': 'src/utils/origin.ts', 'utils/simple-schema': 'src/utils/simple-schema.ts', 'utils/shared-state': 'src/utils/shared-state.ts', 'utils/streaming-channel': 'src/utils/streaming-channel.ts', @@ -159,6 +160,7 @@ export default defineConfig([ resolve(distDir, 'utils/events.mjs'), resolve(distDir, 'utils/hash.mjs'), resolve(distDir, 'utils/nanoid.mjs'), + resolve(distDir, 'utils/origin.mjs'), resolve(distDir, 'utils/simple-schema.mjs'), resolve(distDir, 'utils/shared-state.mjs'), resolve(distDir, 'utils/streaming-channel.mjs'), diff --git a/tsconfig.base.json b/tsconfig.base.json index 6e2a0908f..162245f5f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -85,6 +85,9 @@ "devframe/utils/open": [ "./packages/devframe/src/utils/open.ts" ], + "devframe/utils/origin": [ + "./packages/devframe/src/utils/origin.ts" + ], "devframe/utils/remote-assets": [ "./packages/devframe/src/utils/remote-assets.ts" ], From 63254735abfa18346c48750f611ab1014da14e30 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 1 Sep 2026 08:18:43 +0000 Subject: [PATCH 3/4] test(devframe): register devframe/utils/origin in tooling + refresh API snapshots - Add the devframe/utils/origin alias to alias.ts (the source of truth that regenerates tsconfig.base.json paths), so the new subpath resolves to source in vitest and typecheck. - Add its tsnapi API snapshots and refresh the ws-server snapshot, whose representation shifts now that isAllowedOrigin/isLoopbackHostname are re-exports rather than local definitions (same public names). --- alias.ts | 1 + .../tsnapi/devframe/rpc/transports/ws-server.snapshot.js | 9 ++++++--- .../tsnapi/devframe/utils/origin.snapshot.d.ts | 8 ++++++++ .../tsnapi/devframe/utils/origin.snapshot.js | 8 ++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.js diff --git a/alias.ts b/alias.ts index d916b2c47..08db230d3 100644 --- a/alias.ts +++ b/alias.ts @@ -34,6 +34,7 @@ export const alias = { 'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'), 'devframe/utils/nostics': r('devframe/src/utils/nostics.ts'), 'devframe/utils/open': r('devframe/src/utils/open.ts'), + 'devframe/utils/origin': r('devframe/src/utils/origin.ts'), 'devframe/utils/remote-assets': r('devframe/src/utils/remote-assets.ts'), 'devframe/utils/simple-schema': r('devframe/src/utils/simple-schema.ts'), 'devframe/utils/serve-static': r('devframe/src/utils/serve-static.ts'), diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js index 49c4c3243..f096c4598 100644 --- a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-server.snapshot.js @@ -1,10 +1,13 @@ /** * Generated by tsnapi — public API snapshot of `devframe/rpc/transports/ws-server` */ +// #region Functions +export function attachWsRpcTransport(_, _) {} +export function createWsOriginRegistry(_) {} +export function createWsRpcPeerHooks(_, _) {} +// #endregion + // #region Other -export { attachWsRpcTransport } -export { createWsOriginRegistry } -export { createWsRpcPeerHooks } export { isAllowedOrigin } export { isLoopbackHostname } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.d.ts new file mode 100644 index 000000000..24f8556d1 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.d.ts @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/origin` + */ +// #region Other +export { isAllowedOrigin } +export { isLoopbackHostname } +export { validateOriginCandidate } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.js new file mode 100644 index 000000000..aa340eb89 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.js @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/origin` + */ +// #region Functions +export function isAllowedOrigin(_, _) {} +export function isLoopbackHostname(_) {} +export function validateOriginCandidate(_, _) {} +// #endregion \ No newline at end of file From 487762289ebea246b280b8aee0d106d810ce39b7 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 2 Sep 2026 01:15:51 +0000 Subject: [PATCH 4/4] refactor(devframe): tighten origin-validation comments and tests --- .../src/adapters/__tests__/initiate.test.ts | 149 ++++++------------ packages/devframe/src/node/instance-shell.ts | 17 +- packages/devframe/src/utils/origin.ts | 38 ++--- 3 files changed, 66 insertions(+), 138 deletions(-) diff --git a/packages/devframe/src/adapters/__tests__/initiate.test.ts b/packages/devframe/src/adapters/__tests__/initiate.test.ts index 9edb3bf25..0da1e109e 100644 --- a/packages/devframe/src/adapters/__tests__/initiate.test.ts +++ b/packages/devframe/src/adapters/__tests__/initiate.test.ts @@ -352,127 +352,76 @@ describe('adapters/handler', () => { } }) - it('a hostile first request never becomes the OTP-link origin; a later loopback one does', async () => { - const wsPort = await getPort({ port: 18180, host: '127.0.0.1' }) + // The auth-link origin is derived from the served request's URL (the fetch + // handler ignores the `Host` header — that path is `nodeMiddleware`'s), so + // each case just points a request at the origin under test and inspects the + // one-time banner (`console.log`). + async function withBannerSpy( + id: string, + extra: Partial[1]>, + run: (devtools: ReturnType, spy: ReturnType) => Promise, + ): Promise { + const wsPort = await getPort({ host: '127.0.0.1' }) const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) - const devtools = initDevframe(defineTestDef('handler-poison'), { base: '/__handler-poison/', host: '127.0.0.1', ws: { port: wsPort } }) - + const devtools = initDevframe(defineTestDef(id), { base: `/__${id}/`, host: '127.0.0.1', ws: { port: wsPort }, ...extra }) try { await devtools.ready - // A first request forging a non-loopback Host must not print, adopt, or - // register that authority as the magic-link origin. - await devtools.handler(new Request('http://evil.example.com/__handler-poison/__connection.json', { - headers: { host: 'evil.example.com' }, - })) - expect(spy).not.toHaveBeenCalled() - - // A later loopback request is trusted, adopted, and prints exactly one - // link pointing at that origin — the rejected candidate never locked it - // out. - await devtools.handler(new Request('http://localhost:4321/__handler-poison/__connection.json')) - expect(spy).toHaveBeenCalledTimes(1) - const link = String(spy.mock.calls[0]) - expect(link).toContain('http://localhost:4321/#') - expect(link).not.toContain('evil.example.com') - // The credential rides the fragment; assert only its presence. - expect(link).toContain('#devframe_otp=') - - // The first-valid origin is pinned: a second loopback request neither - // re-prints nor moves it. - await devtools.handler(new Request('http://127.0.0.1:9999/__handler-poison/__connection.json')) - expect(spy).toHaveBeenCalledTimes(1) + await run(devtools, spy) } finally { spy.mockRestore() await devtools.close() } - }) - - it('adopts an exactly allow-listed non-loopback origin, but rejects a prefix/suffix near-match', async () => { - const wsPort = await getPort({ port: 18181, host: '127.0.0.1' }) - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) - const devtools = initDevframe(defineTestDef('handler-allow'), { - base: '/__handler-allow/', - host: '127.0.0.1', - ws: { port: wsPort }, - allowedOrigins: ['https://tools.example.com'], - }) - - try { - await devtools.ready - // Only prefix/suffix-matches the allow-list entry — never adopted. - await devtools.handler(new Request('https://tools.example.com.evil.com/__handler-allow/__connection.json', { - headers: { host: 'tools.example.com.evil.com' }, - })) - await devtools.handler(new Request('https://evil.tools.example.com/__handler-allow/__connection.json', { - headers: { host: 'evil.tools.example.com' }, - })) + } + const hit = (devtools: ReturnType, origin: string): Promise => + devtools.handler(new Request(`${origin}/__connection.json`)) + + it('a hostile first request never becomes the OTP-link origin; a later loopback one does', () => + withBannerSpy('h-poison', {}, async (devtools, spy) => { + // A forged non-loopback origin is not adopted and prints nothing. + await hit(devtools, 'http://evil.example.com/__h-poison') expect(spy).not.toHaveBeenCalled() + // A later loopback origin is adopted and prints exactly one OTP link + // (the credential rides the fragment) — the reject never locked it out. + await hit(devtools, 'http://localhost:4321/__h-poison') + expect(spy).toHaveBeenCalledTimes(1) + expect(String(spy.mock.calls[0])).toContain('http://localhost:4321/#devframe_otp=') + expect(String(spy.mock.calls[0])).not.toContain('evil.example.com') + // First-valid origin is pinned: a second loopback request doesn't move it. + await hit(devtools, 'http://127.0.0.1:9999/__h-poison') + expect(spy).toHaveBeenCalledTimes(1) + })) - // The exact allow-listed origin is adopted. - await devtools.handler(new Request('https://tools.example.com/__handler-allow/__connection.json', { - headers: { host: 'tools.example.com' }, - })) + it('adopts an exactly allow-listed non-loopback origin, but rejects a near-match', () => + withBannerSpy('h-allow', { allowedOrigins: ['https://tools.example.com'] }, async (devtools, spy) => { + // Prefix/suffix near-matches of the allow-list entry are never adopted. + await hit(devtools, 'https://tools.example.com.evil.com/__h-allow') + await hit(devtools, 'https://evil.tools.example.com/__h-allow') + expect(spy).not.toHaveBeenCalled() + // The exact allow-listed origin is. + await hit(devtools, 'https://tools.example.com/__h-allow') expect(spy).toHaveBeenCalledTimes(1) expect(String(spy.mock.calls[0])).toContain('https://tools.example.com/#') - } - finally { - spy.mockRestore() - await devtools.close() - } - }) + })) - it('an explicit origin wins regardless of the inbound Host', async () => { - const wsPort = await getPort({ port: 18182, host: '127.0.0.1' }) - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) - const devtools = initDevframe(defineTestDef('handler-pinned'), { - base: '/__handler-pinned/', - host: '127.0.0.1', - ws: { port: wsPort }, - origin: 'https://pinned.example.com', - }) - - try { - await devtools.ready - // A pinned origin needs no request: the banner points at it from the - // start, ignoring whatever Host a request forges. + it('an explicit origin wins over any request', () => + withBannerSpy('h-pinned', { origin: 'https://pinned.example.com' }, async (devtools, spy) => { + // Pinned: the banner points at it before any request, and a forged + // request can't move it. expect(spy).toHaveBeenCalledTimes(1) expect(String(spy.mock.calls[0])).toContain('https://pinned.example.com/#') - - await devtools.handler(new Request('http://evil.example.com/__handler-pinned/__connection.json', { - headers: { host: 'evil.example.com' }, - })) + await hit(devtools, 'http://evil.example.com/__h-pinned') expect(spy).toHaveBeenCalledTimes(1) - expect(String(spy.mock.calls[0])).toContain('https://pinned.example.com/#') expect(String(spy.mock.calls[0])).not.toContain('evil.example.com') - } - finally { - spy.mockRestore() - await devtools.close() - } - }) - - it('canonicalizes the protocol and default port of an adopted origin', async () => { - const wsPort = await getPort({ port: 18183, host: '127.0.0.1' }) - const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) - const devtools = initDevframe(defineTestDef('handler-canon'), { base: '/__handler-canon/', host: '127.0.0.1', ws: { port: wsPort } }) + })) - try { - await devtools.ready - // An explicit :80 default port canonicalizes away in the advertised - // origin, so the link carries no redundant port. - await devtools.handler(new Request('http://localhost:80/__handler-canon/__connection.json', { - headers: { host: 'localhost:80' }, - })) + it('canonicalizes an adopted origin, dropping the default port', () => + withBannerSpy('h-canon', {}, async (devtools, spy) => { + await hit(devtools, 'http://localhost:80/__h-canon') expect(spy).toHaveBeenCalledTimes(1) expect(String(spy.mock.calls[0])).toContain('http://localhost/#') expect(String(spy.mock.calls[0])).not.toContain('localhost:80') - } - finally { - spy.mockRestore() - await devtools.close() - } - }) + })) it('bridge mode: without a distDir only meta + WS are served', async () => { const wsPort = await getPort({ port: 18160, host: '127.0.0.1' }) diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 2f43d6eaf..5b3fad1c0 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -607,18 +607,11 @@ export function createInstanceShell( /** * Consider a request-derived origin candidate for the advertised public - * origin (which backs the OTP magic link). Delegates the trust decision to - * {@link validateOriginCandidate}: only a loopback host or an exact - * `allowedOrigins` match is adopted, so a raw inbound `Host`/URL authority - * never redirects the credential-bearing link. A dynamic `WsOriginRegistry` - * or a disabled gate offers no static list, so it passes none and only - * loopback candidates qualify. - * - * Keeps the first-valid-origin behavior: an invalid candidate is ignored - * without setting `derivedOrigin`, so it neither prints a banner nor - * registers a poisoned origin, and a later valid candidate can still be - * adopted. Silent by design — a diagnostic here would let an unauthenticated - * request amplify log noise. + * origin (which backs the OTP magic link). {@link validateOriginCandidate} + * adopts only a loopback host or an exact `allowedOrigins` match, so a raw + * inbound `Host`/URL authority never redirects the credential-bearing link. + * First-valid-origin wins: an invalid candidate leaves `derivedOrigin` unset + * — printing/registering nothing — so a later valid one can still be adopted. */ function noteOrigin(candidate: string): void { if (derivedOrigin === undefined && !explicitOrigin()) { diff --git a/packages/devframe/src/utils/origin.ts b/packages/devframe/src/utils/origin.ts index 86ef28c32..88c071c62 100644 --- a/packages/devframe/src/utils/origin.ts +++ b/packages/devframe/src/utils/origin.ts @@ -64,23 +64,14 @@ export function isAllowedOrigin(origin: string | undefined, allowedOrigins: read } /** - * Canonicalize a request-derived origin candidate and decide whether it may - * back a devframe's advertised public origin. That origin becomes the - * destination of the OTP magic link, so a raw inbound authority is never - * trusted: a candidate is adopted only when its parsed hostname is loopback, - * or when its canonical origin exactly matches an `allowedOrigins` entry. A - * caller with no static allow-list (a dynamic registry or a disabled gate) - * passes none, so non-loopback adoption stays off — those deployments supply - * an explicit origin instead. - * - * Unlike {@link isAllowedOrigin} — which accepts any origin-shaped string — - * this rejects a candidate carrying credentials, a path, a query, a fragment, - * a malformed port, or a non-HTTP(S) scheme, and returns the **canonical** - * origin (default ports and casing normalized) rather than a boolean, so the - * value that ends up in the magic link is always canonical. Forwarded headers - * are never consulted. - * - * @returns the canonical origin to adopt, or `undefined` to reject. + * Decide whether a request-derived origin candidate may back a devframe's + * advertised public origin — the destination of the OTP magic link. Stricter + * than {@link isAllowedOrigin}: it rejects credentials, a path, a query, a + * fragment, a malformed port, and non-HTTP(S) schemes, and adopts a candidate + * only when its hostname is loopback or its canonical origin exactly matches + * an `allowedOrigins` entry (a caller with no static list passes none, so only + * loopback qualifies). Returns the canonical origin to adopt, or `undefined` + * to reject. Forwarded headers are never consulted. */ export function validateOriginCandidate( candidate: string, @@ -95,19 +86,14 @@ export function validateOriginCandidate( } if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined - // A canonical origin carries no credentials, path, query, or fragment; any - // of these means the candidate was a full or poisoned URL, not a bare - // authority safe to advertise. - if (url.username || url.password || url.search || url.hash) - return undefined - if (url.pathname !== '/' && url.pathname !== '') + // A canonical origin has no credentials, path, query, or fragment; any of + // these means a full or poisoned URL, not a bare authority safe to advertise. + if (url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== '')) return undefined const canonical = url.origin if (canonical === 'null') return undefined - if (isLoopbackHostname(url.hostname)) - return canonical - if (allowedOrigins?.includes(canonical)) + if (isLoopbackHostname(url.hostname) || allowedOrigins?.includes(canonical)) return canonical return undefined }