@@ -14,6 +14,7 @@ import type { DevframeInstanceRecord, DevframeInstanceRegistration } from './ins
1414import type { ContextRpcServer } from './rpc-core'
1515import { createServer } from 'node:http'
1616import process from 'node:process'
17+ import { validateOriginCandidate } from 'devframe/utils/origin'
1718import { defineHandler , H3 as H3App , toNodeHandler } from 'h3'
1819import { joinURL , withLeadingSlash , withoutLeadingSlash , withoutTrailingSlash } from 'ufo'
1920import { 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' )
0 commit comments