Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
2 changes: 2 additions & 0 deletions docs/content/1.guide/14.security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/content/2.adapters/1.initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/devframe/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
71 changes: 71 additions & 0 deletions packages/devframe/src/adapters/__tests__/initiate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,77 @@ describe('adapters/handler', () => {
}
})

// 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<Parameters<typeof initDevframe>[1]>,
run: (devtools: ReturnType<typeof initDevframe>, spy: ReturnType<typeof vi.spyOn>) => Promise<void>,
): Promise<void> {
const wsPort = await getPort({ host: '127.0.0.1' })
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
const devtools = initDevframe(defineTestDef(id), { base: `/__${id}/`, host: '127.0.0.1', ws: { port: wsPort }, ...extra })
try {
await devtools.ready
await run(devtools, spy)
}
finally {
spy.mockRestore()
await devtools.close()
}
}
const hit = (devtools: ReturnType<typeof initDevframe>, origin: string): Promise<Response> =>
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)
}))

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/#')
}))

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 hit(devtools, 'http://evil.example.com/__h-pinned')
expect(spy).toHaveBeenCalledTimes(1)
expect(String(spy.mock.calls[0])).not.toContain('evil.example.com')
}))

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')
}))

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 } })
Expand Down
10 changes: 6 additions & 4 deletions packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
/**
Expand Down
2 changes: 1 addition & 1 deletion packages/devframe/src/adapters/mcp/fetch.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
24 changes: 20 additions & 4 deletions packages/devframe/src/node/instance-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -552,9 +553,11 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
// 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
Expand Down Expand Up @@ -602,8 +605,21 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
}).catch(() => {})
}

function noteOrigin(origin: string): void {
derivedOrigin ??= origin
/**
* Consider a request-derived origin candidate for the advertised public
* 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()) {
const allowed = options.allowedOrigins
const accepted = validateOriginCandidate(candidate, Array.isArray(allowed) ? allowed : undefined)
if (accepted !== undefined)
derivedOrigin = accepted
}
maybePrintBanner()
maybeRegister()
}
Expand Down
2 changes: 1 addition & 1 deletion packages/devframe/src/rpc/transports/sse-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
64 changes: 8 additions & 56 deletions packages/devframe/src/rpc/transports/ws-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand Down
99 changes: 99 additions & 0 deletions packages/devframe/src/utils/origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* 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
}
}

/**
* 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,
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 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) || allowedOrigins?.includes(canonical))
return canonical
return undefined
}
1 change: 1 addition & 0 deletions packages/devframe/test/runtime-agnostic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/devframe/tsdown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'),
Expand Down
Loading
Loading