Skip to content

Commit 67c0d13

Browse files
committed
feat: add request-based access policy to server boundaries
1 parent 0e4a49c commit 67c0d13

22 files changed

Lines changed: 448 additions & 20 deletions

File tree

‎docs/content/2.adapters/1.initiate.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,34 @@ Fetch handlers only hand over `Request`s, so the host framework binds the RPC so
131131

132132
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.
133133

134+
## Request access policy
135+
136+
Use the server-only `access` option on `initDevframe` or `initHub` to restrict requests by connected peer address and an optional callback:
137+
138+
```ts
139+
const devtools = initDevframe(myDevframe, {
140+
base: '/__my-tool/',
141+
access: {
142+
internalIPs: ['127.0.0.1', '::1'],
143+
authorize: async ({ remoteAddress }) => await checkInternalAccess(remoteAddress),
144+
},
145+
})
146+
```
147+
148+
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.
149+
150+
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.
151+
152+
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.
153+
154+
::warning
155+
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.
156+
::
157+
158+
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.
159+
160+
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.
161+
134162
## Relation to the other adapters
135163

136164
`createDevServer`, `devframeViteBridge` (`@devframes/vite`), and `@devframes/next` are assembled from it internally. To host **many** devframes, use [`initHub`](/guide/hub-initiate).

‎docs/content/6.errors/DF0082.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
title: 'DF0082: Request Access Authorization Failed'
3+
description: 'Request access authorization failed on a WebSocket connection.'
4+
---
5+
6+
## Message
7+
8+
> Request access authorization failed on a WebSocket connection.
9+
10+
## Cause
11+
12+
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.
13+
14+
## Example
15+
16+
```ts
17+
initHub({
18+
access: {
19+
authorize: async () => await checkInternalAccess(),
20+
},
21+
})
22+
```
23+
24+
## Fix
25+
26+
Inspect the reported cause and repair the callback or its dependency. Return `false` for an expected denial.
27+
28+
## Source
29+
30+
- [`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.

‎packages/devframe/src/adapters/initiate.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { AgenticMcpModule } from '../node/agentic'
77
import type { DevframeAuthHandler } from '../node/auth/handler'
88
import type { DevframeInstanceRecord } from '../node/instance-registry'
99
import type { InstanceShellInternals, StartedServer } from '../node/instance-shell'
10+
import type { RequestAccessPolicy } from '../node/request-access'
1011
import type { DevframeDefinition, DevframeSetupInfo, DevframeSseOptions, DevframeWsOptions, McpSetting } from '../types/devframe'
1112
import type { StaticAssetsSource } from '../types/remote-assets'
1213
import type { ResolvedMcpConfig } from './_shared'
@@ -26,6 +27,7 @@ import { loadAutoMcpAdapter, normalizeBasePath, resolveMcpConfig } from './_shar
2627
import { resolveDevServerPort } from './dev'
2728

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

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

261264
const shell = createInstanceShell<DevframeNodeContext>({
262265
base,
266+
access: options.access,
263267
app,
264268
host,
265269
origin: options.origin,
@@ -332,6 +336,7 @@ export function initDevframe(
332336
base: shell.base,
333337
handler: shell.handler,
334338
nodeMiddleware: shell.nodeMiddleware,
339+
authorizeRequest: shell.authorizeRequest,
335340
attach: shell.attach,
336341
handleUpgrade: shell.handleUpgrade,
337342
ready: shell.ready,

‎packages/devframe/src/internal/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export type {
6767
InstanceWsTier,
6868
StartedServer,
6969
} from '../node/instance-shell'
70+
export type { RequestAccessContext, RequestAccessPolicy } from '../node/request-access'
7071
export { createContextRpcServer } from '../node/rpc-core'
7172
export type { ContextRpcServer, CreateContextRpcServerOptions } from '../node/rpc-core'
7273
export { normalizeHttpServerUrl } from '../node/utils'
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { createRequestAccess, normalizeRemoteAddress } from '../request-access'
3+
4+
describe('request access', () => {
5+
it('preserves defaults without requiring peer metadata', async () => {
6+
expect(await createRequestAccess(undefined)(undefined)).toBe(true)
7+
})
8+
9+
it.each([
10+
['127.0.0.1', '127.0.0.1'],
11+
['::ffff:127.0.0.1', '127.0.0.1'],
12+
['::FFFF:7f00:1', '127.0.0.1'],
13+
['0:0:0:0:0:0:0:1', '::1'],
14+
['2001:DB8::1', '2001:db8::1'],
15+
['fe80:0:0:0:0:0:0:1%eth0', 'fe80::1%eth0'],
16+
['localhost', undefined],
17+
[undefined, undefined],
18+
])('normalizes %s', (address, expected) => {
19+
expect(normalizeRemoteAddress(address)).toBe(expected)
20+
})
21+
22+
it('matches normalized addresses exactly', async () => {
23+
const access = createRequestAccess({ internalIPs: ['::ffff:127.0.0.1', '::1'] })
24+
expect(await access('127.0.0.1')).toBe(true)
25+
expect(await access('0:0:0:0:0:0:0:1')).toBe(true)
26+
expect(await access('127.0.0.2')).toBe(false)
27+
expect(await access('203.0.113.1')).toBe(false)
28+
})
29+
30+
it('denies empty lists and missing peers', async () => {
31+
expect(await createRequestAccess({ internalIPs: [] })('127.0.0.1')).toBe(false)
32+
expect(await createRequestAccess({})(undefined)).toBe(false)
33+
})
34+
35+
it.each([false, true])('supports sync and async callbacks returning %s', async (allowed) => {
36+
for (const authorize of [() => allowed, async () => allowed])
37+
expect(await createRequestAccess({ authorize })('127.0.0.1')).toBe(allowed)
38+
})
39+
40+
it('runs callbacks only on authorization with a resolved peer', async () => {
41+
const authorize = vi.fn(() => true)
42+
const access = createRequestAccess({ authorize })
43+
expect(authorize).not.toHaveBeenCalled()
44+
expect(await access(undefined)).toBe(false)
45+
expect(authorize).not.toHaveBeenCalled()
46+
expect(await access('::ffff:127.0.0.1')).toBe(true)
47+
expect(authorize).toHaveBeenCalledWith({ remoteAddress: '127.0.0.1' })
48+
})
49+
50+
it('requires both restrictions', async () => {
51+
const authorize = vi.fn(() => false)
52+
const access = createRequestAccess({ internalIPs: ['127.0.0.1'], authorize })
53+
expect(await access('::1')).toBe(false)
54+
expect(authorize).not.toHaveBeenCalled()
55+
expect(await access('127.0.0.1')).toBe(false)
56+
})
57+
58+
it('propagates callback errors', async () => {
59+
const error = new Error('policy failed')
60+
const access = createRequestAccess({ authorize: () => {
61+
throw error
62+
} })
63+
await expect(access('127.0.0.1')).rejects.toBe(error)
64+
})
65+
})

‎packages/devframe/src/node/diagnostics.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ export const diagnostics = defineDiagnostics({
220220
`The \`mcp\` option is enabled, but the optional peer "@devframes/agentic" could not be loaded: ${p.reason}`,
221221
fix: 'Install `@devframes/agentic` next to devframe (the MCP adapter and the MCP SDK live there), or remove the explicit `mcp` setting.',
222222
},
223+
DF0082: {
224+
why: 'Request access authorization failed on a WebSocket connection.',
225+
fix: 'Check the server-side access callback and its reported cause.',
226+
},
223227
DF0081: {
224228
why: (p: { name: string, reason: string }) =>
225229
`Tool "${p.name}" returned invalid MCP content blocks: ${p.reason}`,

‎packages/devframe/src/node/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ export * from './context'
1515
// `RpcFunctionsHost` type so consumers can type/cast `ctx.rpc` without
1616
// pulling in the implementation's `@internal` members.
1717
export type { RpcFunctionsHost } from './host-functions'
18+
export type { RequestAccessContext, RequestAccessPolicy } from './request-access'
1819
export * from './storage'

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

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,19 @@ import type { DevframeSseOptions, DevframeWsOptions } from '../types/devframe'
1111
import type { DevframeAuthHandler } from './auth'
1212
import type { RpcFunctionsHostImpl } from './host-functions'
1313
import type { DevframeInstanceRecord, DevframeInstanceRegistration } from './instance-registry'
14+
import type { RequestAccessPolicy } from './request-access'
1415
import type { ContextRpcServer } from './rpc-core'
1516
import { createServer } from 'node:http'
1617
import process from 'node:process'
1718
import { Server as TlsServer } from 'node:tls'
18-
import { validateOriginCandidate } from 'devframe/utils/origin'
19+
import { isAllowedOrigin, validateOriginCandidate } from 'devframe/utils/origin'
1920
import { joinURL, withLeadingSlash, withoutLeadingSlash, withoutTrailingSlash } from 'devframe/utils/url'
20-
import { defineHandler, H3 as H3App, toNodeHandler } from 'h3'
21+
import { defineHandler, getRequestIP, H3 as H3App, toNodeHandler } from 'h3'
2122
import { DEVFRAME_SSE_ROUTE, DEVFRAME_WS_ROUTE } from '../constants'
2223
import { createInteractiveAuth } from '../recipes/interactive-auth'
2324
import { diagnostics } from './diagnostics'
2425
import { getInternalContext } from './hub-internals/context'
26+
import { createRequestAccess } from './request-access'
2527
import { detectServerRuntime } from './runtime'
2628
import { formatHostForUrl, normalizeHttpServerUrl } from './utils'
2729

@@ -52,6 +54,7 @@ export interface StartedServer {
5254

5355
/** How {@link bindHttpAndWs} binds the socket: own a fresh server, or share one. */
5456
interface BindHttpAndWsOptions {
57+
authorizePeer?: (remoteAddress: string | undefined) => Promise<boolean>
5558
context: DevframeNodeContext
5659
/** The context's RPC core (birpc group + connection lifecycle hooks). */
5760
core: ContextRpcServer
@@ -108,6 +111,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
108111
path: options.path,
109112
destroyUnmatched: options.destroyUnmatched ?? ownsHttpServer,
110113
allowedOrigins: options.allowedOrigins,
114+
authorizePeer: options.authorizePeer,
111115
onConnected: core.onConnected,
112116
onDisconnected: core.onDisconnected,
113117
})
@@ -186,6 +190,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise<StartedServ
186190
*/
187191
interface BunServerLike {
188192
port: number
193+
requestIP: (request: Request) => { address: string } | null
189194
stop: (closeActiveConnections?: boolean) => void | Promise<void>
190195
}
191196
interface BunGlobal {
@@ -262,7 +267,7 @@ async function bindNativeHttpAndWs(
262267

263268
if (runtime === 'bun') {
264269
const { attachBunWsTransport } = await import('devframe/rpc/transports/ws-bun')
265-
const tier = await attachBunWsTransport(core, { allowedOrigins: options.allowedOrigins })
270+
const tier = await attachBunWsTransport(core, { allowedOrigins: options.allowedOrigins, authorizePeer: options.authorizePeer })
266271
closeWs = tier.close
267272
const Bun = (globalThis as typeof globalThis & { Bun: BunGlobal }).Bun
268273
let server: BunServerLike
@@ -273,7 +278,7 @@ async function bindNativeHttpAndWs(
273278
fetch: async (request, srv) =>
274279
isWsUpgradeRequest(request, wsPath)
275280
? await tier.handleUpgrade(request, srv)
276-
: await app.fetch(request),
281+
: await app.fetch(Object.assign(request, { context: { clientAddress: srv.requestIP(request)?.address } })),
277282
websocket: tier.websocket,
278283
})
279284
}
@@ -287,7 +292,7 @@ async function bindNativeHttpAndWs(
287292
}
288293
else {
289294
const { attachDenoWsTransport } = await import('devframe/rpc/transports/ws-deno')
290-
const tier = await attachDenoWsTransport(core, { allowedOrigins: options.allowedOrigins })
295+
const tier = await attachDenoWsTransport(core, { allowedOrigins: options.allowedOrigins, authorizePeer: options.authorizePeer })
291296
closeWs = tier.close
292297
const Deno = (globalThis as typeof globalThis & { Deno: DenoGlobal }).Deno
293298
let server: DenoServerLike
@@ -299,7 +304,7 @@ async function bindNativeHttpAndWs(
299304
async (request, info) =>
300305
isWsUpgradeRequest(request, wsPath)
301306
? await tier.handleUpgrade(request, info)
302-
: await app.fetch(request),
307+
: await app.fetch(Object.assign(request, { context: { clientAddress: (info as { remoteAddr?: { hostname?: string } }).remoteAddr?.hostname } })),
303308
)
304309
}
305310
catch (error) {
@@ -375,6 +380,7 @@ export interface InstanceShellInit<TContext extends DevframeNodeContext> {
375380
}
376381

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

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

500+
function createAccessBoundary(app: H3, options: { access?: RequestAccessPolicy, allowedOrigins?: readonly string[] | WsOriginRegistry | false }) {
501+
const authorizePeer = options.access ? createRequestAccess(options.access) : undefined
502+
const configuredOrigins = options.allowedOrigins
503+
const origins = Array.isArray(configuredOrigins) ? configuredOrigins : []
504+
const originAllowed = (origin: string | undefined): boolean => {
505+
if (configuredOrigins === false)
506+
return true
507+
if (configuredOrigins && !Array.isArray(configuredOrigins))
508+
return (configuredOrigins as WsOriginRegistry).isAllowed(origin)
509+
return isAllowedOrigin(origin, origins)
510+
}
511+
const hostAllowed = (origin: string): boolean => !!validateOriginCandidate(origin, origins)
512+
const authorizeRequest = async (request: IncomingMessage): Promise<boolean> => {
513+
if (!authorizePeer)
514+
return true
515+
const scheme = (request.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http'
516+
if (!hostAllowed(`${scheme}://${request.headers.host ?? ''}`) || !originAllowed(request.headers.origin))
517+
return false
518+
return authorizePeer(request.socket.remoteAddress)
519+
}
520+
if (authorizePeer) {
521+
app.use(defineHandler(async (event) => {
522+
if (!hostAllowed(new URL(event.req.url).origin) || !originAllowed(event.req.headers.get('origin') ?? undefined) || !await authorizePeer(getRequestIP(event))) {
523+
event.res.status = 403
524+
return 'Forbidden'
525+
}
526+
}))
527+
}
528+
return { authorizePeer, authorizeRequest, hostAllowed }
529+
}
530+
493531
/**
494532
* Copy a web `Response` from a fetch-style transport handler onto the h3
495533
* event's response and return its body, mirroring the MCP route's bridge.
@@ -526,6 +564,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
526564
const base = options.base
527565
const baseNoSlash = withoutTrailingSlash(base)
528566
const app = options.app ?? new H3App()
567+
const access = createAccessBoundary(app, options)
529568

530569
// Bun/Deno drive WebSockets through a native `fetch`-upgrade server;
531570
// crossws's Node adapter throws off Node. Owned-server tiers use the
@@ -721,6 +760,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
721760
port,
722761
path: withLeadingSlash(route),
723762
allowedOrigins: options.allowedOrigins,
763+
authorizePeer: access.authorizePeer,
724764
}
725765
// A side-car is devframe's own dedicated server, so on Bun/Deno it binds
726766
// the native adapter for a real WebSocket; on Node it takes crossws's
@@ -774,6 +814,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
774814
server: options.server,
775815
path: routePath,
776816
allowedOrigins: options.allowedOrigins,
817+
authorizePeer: access.authorizePeer,
777818
destroyUnmatched: options.destroyUnmatchedUpgrades,
778819
})
779820
return { websocketMeta: { path: advertisedPath }, wsAvailable: !wsDisabled }
@@ -871,6 +912,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
871912
unbound: true,
872913
path: routePath,
873914
allowedOrigins: options.allowedOrigins,
915+
authorizePeer: access.authorizePeer,
874916
onConnected: core.onConnected,
875917
onDisconnected: core.onDisconnected,
876918
})
@@ -910,8 +952,13 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
910952
void initPromise
911953
.then(async () => {
912954
const host = req.headers.host
955+
const encrypted = (req.socket as { encrypted?: boolean }).encrypted
956+
if (options.access && !access.hostAllowed(`${encrypted ? 'https' : 'http'}://${host ?? ''}`)) {
957+
res.statusCode = 403
958+
res.end('Forbidden')
959+
return
960+
}
913961
if (host) {
914-
const encrypted = (req.socket as { encrypted?: boolean }).encrypted
915962
noteOrigin(`${encrypted ? 'https' : 'http'}://${host}`)
916963
}
917964
if (!nodeHandler) {
@@ -994,6 +1041,7 @@ export function createInstanceShell<TContext extends DevframeNodeContext>(
9941041
return {
9951042
base,
9961043
handler: handleRequest,
1044+
authorizeRequest: access.authorizeRequest,
9971045
nodeMiddleware,
9981046
ready: initPromise,
9991047
context: contextPromise,

0 commit comments

Comments
 (0)