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

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
122 changes: 122 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,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 } })
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
31 changes: 27 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,28 @@ 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). 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.
*/
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
Loading