Skip to content
Closed
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
28 changes: 28 additions & 0 deletions docs/content/2.adapters/1.initiate.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,34 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so

The running devframe **gates by default**. The interactive OTP handler wires automatically, printing its code/magic-link banner when an untrusted browser client asks for a code (`rpc.requestAuthCode()`); an already-authorized page triggers no print. The magic link's origin comes from the `origin` option, or is 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.

## Request access policy

Use the server-only `access` option on `initDevframe` or `initHub` to restrict requests by connected peer address and an optional callback:

```ts
const devtools = initDevframe(myDevframe, {
base: '/__my-tool/',
access: {
internalIPs: ['127.0.0.1', '::1'],
authorize: async ({ remoteAddress }) => await checkInternalAccess(remoteAddress),
},
})
```

Both restrictions must pass. Addresses use exact matching; IPv6 addresses are normalized and IPv4-mapped IPv6 addresses match their IPv4 form. An empty `internalIPs` list denies everyone. With `access` configured, a missing peer address denies the request before invoking the callback. `authorize` receives a typed `RequestAccessContext` and returns `boolean` or `Promise<boolean>`. It runs during authorization, after setup, rather than while resolving configuration. Callback failures produce HTTP errors or close the WebSocket with a diagnostic containing the cause.

The policy runs before HTTP routes, including connection metadata, UI bootstrap, SSE RPC, and enabled MCP endpoints. It also runs before WebSocket admission and every inbound message, including messages from authenticated connections. A changed policy takes effect on the next request or inbound message; it does not proactively cancel an idle connection or retract data already sent. Routes mounted outside the instance must call `await devtools.authorizeRequest(req)` before returning credentials or capabilities. Return HTTP 403 when that helper returns `false`; let callback errors reach the server error handler.

Authentication stays independent: `access` adds a restriction, while `auth: false` explicitly opts out of interactive authentication. Trusted tokens and authentication opt-outs cannot bypass the request policy. MCP keeps its own origin and authorization checks after this gate. With `access` omitted, existing behavior is preserved.

::warning
Use addresses from the actual connection. Forwarding headers such as `X-Forwarded-For`, `Forwarded`, and `X-Real-IP` are ignored. Behind a reverse proxy the peer is the proxy; allowing its address grants this gate to every caller it forwards. Keep authentication enabled unless another trusted boundary limits those callers. Host and Origin checks remain active; configure `allowedOrigins` for a non-loopback origin.
::

The Node middleware obtains peer metadata from the socket. Fetch hosts must preserve trusted server request metadata (`request.context.clientAddress` or the runtime's `request.ip`); a plain browser-style `Request` has neither and is denied. Owned Bun and Deno listeners obtain addresses from their runtime. Keep callbacks and policy values in server configuration; connection metadata contains neither.

Live standalone instances use the same policy. An external `ws.url` server owns its transport and needs its own policy. Static build files require access control in the web server serving them; runtime callbacks apply to live requests only.

## Relation to the other adapters

`createDevServer`, `devframeViteBridge` (`@devframes/vite`), and `@devframes/next` are assembled from it internally. To host **many** devframes, use [`initHub`](/guide/hub-initiate).
30 changes: 30 additions & 0 deletions docs/content/6.errors/DF0082.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
title: 'DF0082: Request Access Authorization Failed'
description: 'Request access authorization failed on a WebSocket connection.'
---

## Message

> Request access authorization failed on a WebSocket connection.

## Cause

The server-side request access callback threw an error or rejected its promise during an upgrade or an inbound message. The socket is rejected or closed before the request can reach RPC dispatch. The diagnostic retains the original error as its cause.

## Example

```ts
initHub({
access: {
authorize: async () => await checkInternalAccess(),
},
})
```

## Fix

Inspect the reported cause and repair the callback or its dependency. Return `false` for an expected denial.

## Source

- [`packages/devframe/src/rpc/transports/ws-server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/rpc/transports/ws-server.ts) reports callback failures during upgrades and inbound messages.
5 changes: 5 additions & 0 deletions packages/devframe/src/adapters/initiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { AgenticMcpModule } from '../node/agentic'
import type { DevframeAuthHandler } from '../node/auth/handler'
import type { DevframeInstanceRecord } from '../node/instance-registry'
import type { InstanceShellInternals, StartedServer } from '../node/instance-shell'
import type { RequestAccessPolicy } from '../node/request-access'
import type { DevframeDefinition, DevframeSetupInfo, DevframeSseOptions, DevframeWsOptions, McpSetting } from '../types/devframe'
import type { StaticAssetsSource } from '../types/remote-assets'
import type { ResolvedMcpConfig } from './_shared'
Expand All @@ -26,6 +27,7 @@ import { loadAutoMcpAdapter, normalizeBasePath, resolveMcpConfig } from './_shar
import { resolveDevServerPort } from './dev'

export interface InitDevframeOptions {
access?: RequestAccessPolicy
/**
* Mount base the handler answers under (e.g. `/__my-tool/`), required so
* the mount path is explicit at the call site. A handler is by definition
Expand Down Expand Up @@ -157,6 +159,7 @@ export interface InitDevframeOptions {
}

export interface DevframeInstance {
authorizeRequest: (request: IncomingMessage) => Promise<boolean>
/**
* The normalized mount base this instance answers under (leading and
* trailing slash, e.g. `/__my-tool/`). Reference it when wiring the mount
Expand Down Expand Up @@ -260,6 +263,7 @@ export function initDevframe(

const shell = createInstanceShell<DevframeNodeContext>({
base,
access: options.access,
app,
host,
origin: options.origin,
Expand Down Expand Up @@ -332,6 +336,7 @@ export function initDevframe(
base: shell.base,
handler: shell.handler,
nodeMiddleware: shell.nodeMiddleware,
authorizeRequest: shell.authorizeRequest,
attach: shell.attach,
handleUpgrade: shell.handleUpgrade,
ready: shell.ready,
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/src/internal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type {
InstanceWsTier,
StartedServer,
} from '../node/instance-shell'
export type { RequestAccessContext, RequestAccessPolicy } from '../node/request-access'
export { createContextRpcServer } from '../node/rpc-core'
export type { ContextRpcServer, CreateContextRpcServerOptions } from '../node/rpc-core'
export { normalizeHttpServerUrl } from '../node/utils'
Expand Down
65 changes: 65 additions & 0 deletions packages/devframe/src/node/__tests__/request-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from 'vitest'
import { createRequestAccess, normalizeRemoteAddress } from '../request-access'

describe('request access', () => {
it('preserves defaults without requiring peer metadata', async () => {
expect(await createRequestAccess(undefined)(undefined)).toBe(true)
})

it.each([
['127.0.0.1', '127.0.0.1'],
['::ffff:127.0.0.1', '127.0.0.1'],
['::FFFF:7f00:1', '127.0.0.1'],
['0:0:0:0:0:0:0:1', '::1'],
['2001:DB8::1', '2001:db8::1'],
['fe80:0:0:0:0:0:0:1%eth0', 'fe80::1%eth0'],
['localhost', undefined],
[undefined, undefined],
])('normalizes %s', (address, expected) => {
expect(normalizeRemoteAddress(address)).toBe(expected)
})

it('matches normalized addresses exactly', async () => {
const access = createRequestAccess({ internalIPs: ['::ffff:127.0.0.1', '::1'] })
expect(await access('127.0.0.1')).toBe(true)
expect(await access('0:0:0:0:0:0:0:1')).toBe(true)
expect(await access('127.0.0.2')).toBe(false)
expect(await access('203.0.113.1')).toBe(false)
})

it('denies empty lists and missing peers', async () => {
expect(await createRequestAccess({ internalIPs: [] })('127.0.0.1')).toBe(false)
expect(await createRequestAccess({})(undefined)).toBe(false)
})

it.each([false, true])('supports sync and async callbacks returning %s', async (allowed) => {
for (const authorize of [() => allowed, async () => allowed])
expect(await createRequestAccess({ authorize })('127.0.0.1')).toBe(allowed)
})

it('runs callbacks only on authorization with a resolved peer', async () => {
const authorize = vi.fn(() => true)
const access = createRequestAccess({ authorize })
expect(authorize).not.toHaveBeenCalled()
expect(await access(undefined)).toBe(false)
expect(authorize).not.toHaveBeenCalled()
expect(await access('::ffff:127.0.0.1')).toBe(true)
expect(authorize).toHaveBeenCalledWith({ remoteAddress: '127.0.0.1' })
})

it('requires both restrictions', async () => {
const authorize = vi.fn(() => false)
const access = createRequestAccess({ internalIPs: ['127.0.0.1'], authorize })
expect(await access('::1')).toBe(false)
expect(authorize).not.toHaveBeenCalled()
expect(await access('127.0.0.1')).toBe(false)
})

it('propagates callback errors', async () => {
const error = new Error('policy failed')
const access = createRequestAccess({ authorize: () => {
throw error
} })
await expect(access('127.0.0.1')).rejects.toBe(error)
})
})
4 changes: 4 additions & 0 deletions packages/devframe/src/node/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,10 @@ export const diagnostics = defineDiagnostics({
`The \`mcp\` option is enabled, but the optional peer "@devframes/agentic" could not be loaded: ${p.reason}`,
fix: 'Install `@devframes/agentic` next to devframe (the MCP adapter and the MCP SDK live there), or remove the explicit `mcp` setting.',
},
DF0082: {
why: 'Request access authorization failed on a WebSocket connection.',
fix: 'Check the server-side access callback and its reported cause.',
},
DF0081: {
why: (p: { name: string, reason: string }) =>
`Tool "${p.name}" returned invalid MCP content blocks: ${p.reason}`,
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export * from './context'
// `RpcFunctionsHost` type so consumers can type/cast `ctx.rpc` without
// pulling in the implementation's `@internal` members.
export type { RpcFunctionsHost } from './host-functions'
export type { RequestAccessContext, RequestAccessPolicy } from './request-access'
export * from './storage'
62 changes: 55 additions & 7 deletions packages/devframe/src/node/instance-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,19 @@ import type { DevframeSseOptions, DevframeWsOptions } from '../types/devframe'
import type { DevframeAuthHandler } from './auth'
import type { RpcFunctionsHostImpl } from './host-functions'
import type { DevframeInstanceRecord, DevframeInstanceRegistration } from './instance-registry'
import type { RequestAccessPolicy } from './request-access'
import type { ContextRpcServer } from './rpc-core'
import { createServer } from 'node:http'
import process from 'node:process'
import { Server as TlsServer } from 'node:tls'
import { validateOriginCandidate } from 'devframe/utils/origin'
import { isAllowedOrigin, validateOriginCandidate } from 'devframe/utils/origin'
import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'devframe/utils/url'
import { defineHandler, H3 as H3App, toNodeHandler } from 'h3'
import { defineHandler, getRequestIP, H3 as H3App, toNodeHandler } from 'h3'
import { DEVFRAME_SSE_ROUTE, DEVFRAME_WS_ROUTE } from '../constants'
import { createInteractiveAuth } from '../recipes/interactive-auth'
import { diagnostics } from './diagnostics'
import { getInternalContext } from './hub-internals/context'
import { createRequestAccess } from './request-access'
import { detectServerRuntime } from './runtime'
import { formatHostForUrl, normalizeHttpServerUrl } from './utils'

Expand Down Expand Up @@ -52,6 +54,7 @@ export interface StartedServer {

/** How {@link bindHttpAndWs} binds the socket: own a fresh server, or share one. */
interface BindHttpAndWsOptions {
authorizePeer?: (remoteAddress: string | undefined) => Promise<boolean>
context: DevframeNodeContext
/** The context's RPC core (birpc group + connection lifecycle hooks). */
core: ContextRpcServer
Expand Down Expand Up @@ -108,6 +111,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
path: options.path,
destroyUnmatched: options.destroyUnmatched ?? ownsHttpServer,
allowedOrigins: options.allowedOrigins,
authorizePeer: options.authorizePeer,
onConnected: core.onConnected,
onDisconnected: core.onDisconnected,
})
Expand Down Expand Up @@ -186,6 +190,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
*/
interface BunServerLike {
port: number
requestIP: (request: Request) => { address: string } | null
stop: (closeActiveConnections?: boolean) => void | Promise<void>
}
interface BunGlobal {
Expand Down Expand Up @@ -262,7 +267,7 @@ async function bindNativeHttpAndWs(

if (runtime === 'bun') {
const { attachBunWsTransport } = await import('devframe/rpc/transports/ws-bun')
const tier = await attachBunWsTransport(core, { allowedOrigins: options.allowedOrigins })
const tier = await attachBunWsTransport(core, { allowedOrigins: options.allowedOrigins, authorizePeer: options.authorizePeer })
closeWs = tier.close
const Bun = (globalThis as typeof globalThis & { Bun: BunGlobal }).Bun
let server: BunServerLike
Expand All @@ -273,7 +278,7 @@ async function bindNativeHttpAndWs(
fetch: async (request, srv) =>
isWsUpgradeRequest(request, wsPath)
? await tier.handleUpgrade(request, srv)
: await app.fetch(request),
: await app.fetch(Object.assign(request, { context: { clientAddress: srv.requestIP(request)?.address } })),
websocket: tier.websocket,
})
}
Expand All @@ -287,7 +292,7 @@ async function bindNativeHttpAndWs(
}
else {
const { attachDenoWsTransport } = await import('devframe/rpc/transports/ws-deno')
const tier = await attachDenoWsTransport(core, { allowedOrigins: options.allowedOrigins })
const tier = await attachDenoWsTransport(core, { allowedOrigins: options.allowedOrigins, authorizePeer: options.authorizePeer })
closeWs = tier.close
const Deno = (globalThis as typeof globalThis & { Deno: DenoGlobal }).Deno
let server: DenoServerLike
Expand All @@ -299,7 +304,7 @@ async function bindNativeHttpAndWs(
async (request, info) =>
isWsUpgradeRequest(request, wsPath)
? await tier.handleUpgrade(request, info)
: await app.fetch(request),
: await app.fetch(Object.assign(request, { context: { clientAddress: (info as { remoteAddr?: { hostname?: string } }).remoteAddr?.hostname } })),
)
}
catch (error) {
Expand Down Expand Up @@ -375,6 +380,7 @@ export interface InstanceShellInit<TContext extends DevframeNodeContext> {
}

export interface CreateInstanceShellOptions<TContext extends DevframeNodeContext> {
access?: RequestAccessPolicy
/** Normalized mount base (leading and trailing slash). */
base: string
/** h3 app to mount on. A fresh one is created when omitted. */
Expand Down Expand Up @@ -471,6 +477,7 @@ export interface InstanceShellInternals {
}

export interface InstanceShell<TContext extends DevframeNodeContext> {
authorizeRequest: (request: IncomingMessage) => Promise<boolean>
base: string
handler: (request: Request) => Promise<Response>
nodeMiddleware: (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void
Expand All @@ -490,6 +497,37 @@ export function samePath(a: string, b: string): boolean {
return withoutTrailingSlash(a) === withoutTrailingSlash(b)
}

function createAccessBoundary(app: H3, options: { access?: RequestAccessPolicy, allowedOrigins?: readonly string[] | WsOriginRegistry | false }) {
const authorizePeer = options.access ? createRequestAccess(options.access) : undefined
const configuredOrigins = options.allowedOrigins
const origins = Array.isArray(configuredOrigins) ? configuredOrigins : []
const originAllowed = (origin: string | undefined): boolean => {
if (configuredOrigins === false)
return true
if (configuredOrigins && !Array.isArray(configuredOrigins))
return (configuredOrigins as WsOriginRegistry).isAllowed(origin)
return isAllowedOrigin(origin, origins)
}
const hostAllowed = (origin: string): boolean => !!validateOriginCandidate(origin, origins)
const authorizeRequest = async (request: IncomingMessage): Promise<boolean> => {
if (!authorizePeer)
return true
const scheme = (request.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http'
if (!hostAllowed(`${scheme}://${request.headers.host ?? ''}`) || !originAllowed(request.headers.origin))
return false
return authorizePeer(request.socket.remoteAddress)
}
if (authorizePeer) {
app.use(defineHandler(async (event) => {
if (!hostAllowed(new URL(event.req.url).origin) || !originAllowed(event.req.headers.get('origin') ?? undefined) || !await authorizePeer(getRequestIP(event))) {
event.res.status = 403
return 'Forbidden'
}
}))
}
return { authorizePeer, authorizeRequest, hostAllowed }
}

/**
* Copy a web `Response` from a fetch-style transport handler onto the h3
* event's response and return its body, mirroring the MCP route's bridge.
Expand Down Expand Up @@ -526,6 +564,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
const base = options.base
const baseNoSlash = withoutTrailingSlash(base)
const app = options.app ?? new H3App()
const access = createAccessBoundary(app, options)

// Bun/Deno drive WebSockets through a native `fetch`-upgrade server;
// crossws's Node adapter throws off Node. Owned-server tiers use the
Expand Down Expand Up @@ -721,6 +760,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
port,
path: withLeadingSlash(route),
allowedOrigins: options.allowedOrigins,
authorizePeer: access.authorizePeer,
}
// A side-car is devframe's own dedicated server, so on Bun/Deno it binds
// the native adapter for a real WebSocket; on Node it takes crossws's
Expand Down Expand Up @@ -774,6 +814,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
server: options.server,
path: routePath,
allowedOrigins: options.allowedOrigins,
authorizePeer: access.authorizePeer,
destroyUnmatched: options.destroyUnmatchedUpgrades,
})
return { websocketMeta: { path: advertisedPath }, wsAvailable: !wsDisabled }
Expand Down Expand Up @@ -871,6 +912,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
unbound: true,
path: routePath,
allowedOrigins: options.allowedOrigins,
authorizePeer: access.authorizePeer,
onConnected: core.onConnected,
onDisconnected: core.onDisconnected,
})
Expand Down Expand Up @@ -910,8 +952,13 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
void initPromise
.then(async () => {
const host = req.headers.host
const encrypted = (req.socket as { encrypted?: boolean }).encrypted
if (options.access && !access.hostAllowed(`${encrypted ? 'https' : 'http'}://${host ?? ''}`)) {
res.statusCode = 403
res.end('Forbidden')
return
}
if (host) {
const encrypted = (req.socket as { encrypted?: boolean }).encrypted
noteOrigin(`${encrypted ? 'https' : 'http'}://${host}`)
}
if (!nodeHandler) {
Expand Down Expand Up @@ -994,6 +1041,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
return {
base,
handler: handleRequest,
authorizeRequest: access.authorizeRequest,
nodeMiddleware,
ready: initPromise,
context: contextPromise,
Expand Down
Loading
Loading