Skip to content

Commit f82f8cd

Browse files
committed
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.
1 parent 08d1b80 commit f82f8cd

9 files changed

Lines changed: 149 additions & 126 deletions

File tree

packages/devframe/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"./utils/nanoid": "./dist/utils/nanoid.mjs",
5757
"./utils/nostics": "./dist/utils/nostics.mjs",
5858
"./utils/open": "./dist/utils/open.mjs",
59+
"./utils/origin": "./dist/utils/origin.mjs",
5960
"./utils/remote-assets": "./dist/utils/remote-assets.mjs",
6061
"./utils/simple-schema": "./dist/utils/simple-schema.mjs",
6162
"./utils/serve-static": "./dist/utils/serve-static.mjs",

packages/devframe/src/adapters/mcp/fetch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { DevframeNodeContext } from 'devframe/types'
22
import { createMcpHandler } from '@modelcontextprotocol/server'
3-
import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server'
3+
import { isAllowedOrigin } from 'devframe/utils/origin'
44
import { bridgeListChanged, buildMcpServerFromContext } from './build-server'
55

66
export interface CreateMcpFetchHandlerOptions {

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

Lines changed: 19 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { DevframeInstanceRecord, DevframeInstanceRegistration } from './ins
1414
import type { ContextRpcServer } from './rpc-core'
1515
import { createServer } from 'node:http'
1616
import process from 'node:process'
17+
import { validateOriginCandidate } from 'devframe/utils/origin'
1718
import { defineHandler, H3 as H3App, toNodeHandler } from 'h3'
1819
import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'ufo'
1920
import { DEVFRAME_SSE_ROUTE, DEVFRAME_WS_ROUTE } from '../constants'
@@ -604,75 +605,25 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
604605
}).catch(() => {})
605606
}
606607

607-
// `isLoopbackHostname` lives in the WS transport module (whose top-level
608-
// `crossws` import instance-shell keeps out of its own static graph), so it
609-
// is pulled in lazily and cached the first time a candidate needs checking.
610-
// An explicit or already-derived origin short-circuits before this loads, so
611-
// the common cases (a pinned dev-server origin, every request after the
612-
// first valid one) never touch the transport module.
613-
let loopbackCheck: ((hostname: string) => boolean) | undefined
614-
async function ensureLoopbackCheck(): Promise<(hostname: string) => boolean> {
615-
if (!loopbackCheck) {
616-
const mod = await import('devframe/rpc/transports/ws-server')
617-
loopbackCheck = mod.isLoopbackHostname
618-
}
619-
return loopbackCheck
620-
}
621-
622-
/**
623-
* Canonicalize a request-derived origin candidate and decide whether it may
624-
* back the advertised origin. That origin becomes the destination of the OTP
625-
* magic link, so a raw inbound authority is never trusted: a candidate is
626-
* adopted only when its parsed hostname is loopback, or when its canonical
627-
* origin exactly matches a configured `allowedOrigins` entry. A dynamic
628-
* `WsOriginRegistry` or a disabled gate (`false`) offers no static list to
629-
* match, so non-loopback adoption stays off there — those deployments supply
630-
* an explicit `origin`. Returns the canonical origin, or `undefined` to
631-
* reject (credentials, a path, a query, a fragment, a malformed port, a
632-
* non-HTTP(S) scheme, or an untrusted host). Forwarded headers are never
633-
* consulted.
634-
*/
635-
function validateOriginCandidate(
636-
candidate: string,
637-
isLoopback: (hostname: string) => boolean,
638-
): string | undefined {
639-
let url: URL
640-
try {
641-
url = new URL(candidate)
642-
}
643-
catch {
644-
return undefined
645-
}
646-
if (url.protocol !== 'http:' && url.protocol !== 'https:')
647-
return undefined
648-
// A canonical origin carries no credentials, path, query, or fragment; any
649-
// of these means the candidate was a full or poisoned URL, not a bare
650-
// authority safe to advertise.
651-
if (url.username || url.password || url.search || url.hash)
652-
return undefined
653-
if (url.pathname !== '/' && url.pathname !== '')
654-
return undefined
655-
const canonical = url.origin
656-
if (canonical === 'null')
657-
return undefined
658-
if (isLoopback(url.hostname))
659-
return canonical
660-
const allowed = options.allowedOrigins
661-
if (Array.isArray(allowed) && allowed.includes(canonical))
662-
return canonical
663-
return undefined
664-
}
665-
666608
/**
667-
* Consider a request-derived origin candidate. Keeps the first-valid-origin
668-
* behavior: an invalid candidate is ignored without setting `derivedOrigin`,
669-
* so it neither prints a banner nor registers a poisoned origin, and a later
670-
* valid candidate can still be adopted. Silent by design — a diagnostic here
671-
* would let an unauthenticated request amplify log noise.
609+
* 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.
672622
*/
673-
async function noteOrigin(candidate: string): Promise<void> {
623+
function noteOrigin(candidate: string): void {
674624
if (derivedOrigin === undefined && !explicitOrigin()) {
675-
const accepted = validateOriginCandidate(candidate, await ensureLoopbackCheck())
625+
const allowed = options.allowedOrigins
626+
const accepted = validateOriginCandidate(candidate, Array.isArray(allowed) ? allowed : undefined)
676627
if (accepted !== undefined)
677628
derivedOrigin = accepted
678629
}
@@ -926,7 +877,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
926877

927878
async function handleRequest(request: Request): Promise<Response> {
928879
await initPromise
929-
await noteOrigin(new URL(request.url).origin)
880+
noteOrigin(new URL(request.url).origin)
930881
const response = await app.fetch(request)
931882
// Normalize a miss to a bare 404: an unmounted path falls through to
932883
// h3's default JSON-error handler, but for an asset host a body-less
@@ -957,7 +908,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
957908
const host = req.headers.host
958909
if (host) {
959910
const encrypted = (req.socket as { encrypted?: boolean }).encrypted
960-
await noteOrigin(`${encrypted ? 'https' : 'http'}://${host}`)
911+
noteOrigin(`${encrypted ? 'https' : 'http'}://${host}`)
961912
}
962913
if (!nodeHandler) {
963914
const { toNodeHandler } = await import('h3/node')

packages/devframe/src/rpc/transports/sse-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ import type { RpcFunctionDefinitionAny } from '../types'
33
import type { DevframeNodeRpcSessionMeta, DevframeRpcConnection } from './session'
44
import type { WsOriginRegistry } from './ws-server'
55
import { DEVFRAME_SSE_SESSION_HEADER } from 'devframe/constants'
6+
import { isAllowedOrigin } from 'devframe/utils/origin'
67
import { createRpcWireCodec, peekRpcWireFrame } from '../wire-codec'
78
import { createRpcSessionMeta } from './session'
8-
import { isAllowedOrigin } from './ws-server'
99

1010
export interface SseRpcTransportOptions {
1111
/**

packages/devframe/src/rpc/transports/ws-server.ts

Lines changed: 8 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createServer as createHttpsServer } from 'node:https'
1313
import crossws from 'crossws/adapters/node'
1414
import { DEVFRAME_VIEWER_ORIGIN_QUERY_PARAM, DEVFRAME_VIEWER_ORIGIN_TOKEN_QUERY_PARAM } from 'devframe/constants'
1515
import { randomToken, timingSafeEqual } from 'devframe/utils/crypto-token'
16+
import { isAllowedOrigin } from 'devframe/utils/origin'
1617
import { createRpcWireCodec } from '../wire-codec'
1718
import { createRpcSessionMeta } from './session'
1819

@@ -226,62 +227,13 @@ function pathMatches(a: string, b: string): boolean {
226227
return strip(a) === strip(b)
227228
}
228229

229-
/**
230-
* Whether `hostname` names a loopback host: `localhost` (or any `*.localhost`
231-
* subdomain), the IPv6 loopback `::1`, or an IPv4 literal inside the
232-
* `127.0.0.0/8` loopback block.
233-
*
234-
* The IPv4 case is matched **structurally** — the whole hostname must be a
235-
* canonical dotted-decimal IPv4 literal whose first octet is `127`. A bare
236-
* `startsWith('127.')` prefix check would also accept an attacker-controlled
237-
* DNS name that merely *begins* with `127.` (`127.attacker.example`,
238-
* `127.0.0.1.attacker.example`), letting a cross-origin browser page defeat
239-
* the loopback origin gate that guards the RPC/MCP surface (a DNS-rebinding /
240-
* cross-site WebSocket-hijacking bypass). Requiring a real IPv4 literal keeps
241-
* genuine loopback addresses (`127.0.0.1`, `127.5.5.5`) allowed while rejecting
242-
* those DNS names.
243-
*/
244-
export function isLoopbackHostname(hostname: string): boolean {
245-
const h = hostname.replace(/^\[|\]$/g, '') // strip IPv6 brackets
246-
if (h === 'localhost' || h.endsWith('.localhost') || h === '::1')
247-
return true
248-
return isLoopbackIPv4(h)
249-
}
250-
251-
/** A canonical dotted-decimal IPv4 literal in `127.0.0.0/8`. */
252-
function isLoopbackIPv4(hostname: string): boolean {
253-
const octets = hostname.split('.')
254-
if (octets.length !== 4 || !octets.every(isDecimalOctet))
255-
return false
256-
return Number(octets[0]) === 127
257-
}
258-
259-
/** A single canonical IPv4 octet: 1–3 digits, no leading zero, value 0–255. */
260-
function isDecimalOctet(part: string): boolean {
261-
if (!/^\d{1,3}$/.test(part) || (part.length > 1 && part[0] === '0'))
262-
return false
263-
return Number(part) <= 255
264-
}
265-
266-
/**
267-
* Default origin policy for a localhost dev tool: allow requests with no
268-
* `Origin` header (native, non-browser clients), allow any loopback origin
269-
* (so cross-port localhost dev setups keep working), and allow explicitly
270-
* configured origins. Everything else — a real remote page in the dev's
271-
* browser — is rejected.
272-
*/
273-
export function isAllowedOrigin(origin: string | undefined, allowedOrigins: readonly string[]): boolean {
274-
if (!origin)
275-
return true
276-
if (allowedOrigins.includes(origin))
277-
return true
278-
try {
279-
return isLoopbackHostname(new URL(origin).hostname)
280-
}
281-
catch {
282-
return false
283-
}
284-
}
230+
// The loopback / origin predicates live in the dependency-free
231+
// `devframe/utils/origin` module so consumers that only need one check (e.g.
232+
// the instance shell's auth-link origin validation) don't import this whole
233+
// `crossws`-carrying transport. Re-exported here to keep the historical
234+
// `devframe/rpc/transports/ws-server` import path for `isAllowedOrigin` /
235+
// `isLoopbackHostname` intact.
236+
export { isAllowedOrigin, isLoopbackHostname } from 'devframe/utils/origin'
285237

286238
function isWsOriginRegistry(
287239
value: readonly string[] | WsOriginRegistry | false | undefined,
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Origin and hostname predicates shared by the RPC transports (the WS upgrade,
3+
* SSE, and MCP origin gates) and the instance shell's authentication-link
4+
* origin validation. Kept dependency-free and runtime-agnostic so any consumer
5+
* can pull in a single check without dragging in a transport's `crossws`
6+
* import.
7+
*/
8+
9+
/**
10+
* Whether `hostname` names a loopback host: `localhost` (or any `*.localhost`
11+
* subdomain), the IPv6 loopback `::1`, or an IPv4 literal inside the
12+
* `127.0.0.0/8` loopback block.
13+
*
14+
* The IPv4 case is matched **structurally** — the whole hostname must be a
15+
* canonical dotted-decimal IPv4 literal whose first octet is `127`. A bare
16+
* `startsWith('127.')` prefix check would also accept an attacker-controlled
17+
* DNS name that merely *begins* with `127.` (`127.attacker.example`,
18+
* `127.0.0.1.attacker.example`), letting a cross-origin browser page defeat
19+
* the loopback origin gate that guards the RPC/MCP surface (a DNS-rebinding /
20+
* cross-site WebSocket-hijacking bypass). Requiring a real IPv4 literal keeps
21+
* genuine loopback addresses (`127.0.0.1`, `127.5.5.5`) allowed while rejecting
22+
* those DNS names.
23+
*/
24+
export function isLoopbackHostname(hostname: string): boolean {
25+
const h = hostname.replace(/^\[|\]$/g, '') // strip IPv6 brackets
26+
if (h === 'localhost' || h.endsWith('.localhost') || h === '::1')
27+
return true
28+
return isLoopbackIPv4(h)
29+
}
30+
31+
/** A canonical dotted-decimal IPv4 literal in `127.0.0.0/8`. */
32+
function isLoopbackIPv4(hostname: string): boolean {
33+
const octets = hostname.split('.')
34+
if (octets.length !== 4 || !octets.every(isDecimalOctet))
35+
return false
36+
return Number(octets[0]) === 127
37+
}
38+
39+
/** A single canonical IPv4 octet: 1–3 digits, no leading zero, value 0–255. */
40+
function isDecimalOctet(part: string): boolean {
41+
if (!/^\d{1,3}$/.test(part) || (part.length > 1 && part[0] === '0'))
42+
return false
43+
return Number(part) <= 255
44+
}
45+
46+
/**
47+
* Default origin policy for a localhost dev tool: allow requests with no
48+
* `Origin` header (native, non-browser clients), allow any loopback origin
49+
* (so cross-port localhost dev setups keep working), and allow explicitly
50+
* configured origins. Everything else — a real remote page in the dev's
51+
* browser — is rejected.
52+
*/
53+
export function isAllowedOrigin(origin: string | undefined, allowedOrigins: readonly string[]): boolean {
54+
if (!origin)
55+
return true
56+
if (allowedOrigins.includes(origin))
57+
return true
58+
try {
59+
return isLoopbackHostname(new URL(origin).hostname)
60+
}
61+
catch {
62+
return false
63+
}
64+
}
65+
66+
/**
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.
84+
*/
85+
export function validateOriginCandidate(
86+
candidate: string,
87+
allowedOrigins?: readonly string[],
88+
): string | undefined {
89+
let url: URL
90+
try {
91+
url = new URL(candidate)
92+
}
93+
catch {
94+
return undefined
95+
}
96+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
97+
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 !== '')
104+
return undefined
105+
const canonical = url.origin
106+
if (canonical === 'null')
107+
return undefined
108+
if (isLoopbackHostname(url.hostname))
109+
return canonical
110+
if (allowedOrigins?.includes(canonical))
111+
return canonical
112+
return undefined
113+
}

packages/devframe/test/runtime-agnostic.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ const AGNOSTIC_ENTRIES = [
1414
'utils/events.mjs',
1515
'utils/hash.mjs',
1616
'utils/nanoid.mjs',
17+
'utils/origin.mjs',
1718
'utils/shared-state.mjs',
1819
'utils/streaming-channel.mjs',
1920
'utils/structured-clone.mjs',

packages/devframe/tsdown.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ const clientEntries = {
7878
'utils/events': 'src/utils/events.ts',
7979
'utils/hash': 'src/utils/hash.ts',
8080
'utils/nanoid': 'src/utils/nanoid.ts',
81+
'utils/origin': 'src/utils/origin.ts',
8182
'utils/simple-schema': 'src/utils/simple-schema.ts',
8283
'utils/shared-state': 'src/utils/shared-state.ts',
8384
'utils/streaming-channel': 'src/utils/streaming-channel.ts',
@@ -159,6 +160,7 @@ export default defineConfig([
159160
resolve(distDir, 'utils/events.mjs'),
160161
resolve(distDir, 'utils/hash.mjs'),
161162
resolve(distDir, 'utils/nanoid.mjs'),
163+
resolve(distDir, 'utils/origin.mjs'),
162164
resolve(distDir, 'utils/simple-schema.mjs'),
163165
resolve(distDir, 'utils/shared-state.mjs'),
164166
resolve(distDir, 'utils/streaming-channel.mjs'),

tsconfig.base.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@
8585
"devframe/utils/open": [
8686
"./packages/devframe/src/utils/open.ts"
8787
],
88+
"devframe/utils/origin": [
89+
"./packages/devframe/src/utils/origin.ts"
90+
],
8891
"devframe/utils/remote-assets": [
8992
"./packages/devframe/src/utils/remote-assets.ts"
9093
],

0 commit comments

Comments
 (0)