diff --git a/docs/content/2.adapters/1.initiate.md b/docs/content/2.adapters/1.initiate.md index fd21331a..90a1d300 100644 --- a/docs/content/2.adapters/1.initiate.md +++ b/docs/content/2.adapters/1.initiate.md @@ -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`. 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). diff --git a/docs/content/6.errors/DF0082.md b/docs/content/6.errors/DF0082.md new file mode 100644 index 00000000..2cc26402 --- /dev/null +++ b/docs/content/6.errors/DF0082.md @@ -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. \ No newline at end of file diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts index 6fae86f3..178b2a57 100644 --- a/packages/devframe/src/adapters/initiate.ts +++ b/packages/devframe/src/adapters/initiate.ts @@ -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' @@ -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 @@ -157,6 +159,7 @@ export interface InitDevframeOptions { } export interface DevframeInstance { + authorizeRequest: (request: IncomingMessage) => Promise /** * The normalized mount base this instance answers under (leading and * trailing slash, e.g. `/__my-tool/`). Reference it when wiring the mount @@ -260,6 +263,7 @@ export function initDevframe( const shell = createInstanceShell({ base, + access: options.access, app, host, origin: options.origin, @@ -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, diff --git a/packages/devframe/src/internal/index.ts b/packages/devframe/src/internal/index.ts index b370be34..b906b209 100644 --- a/packages/devframe/src/internal/index.ts +++ b/packages/devframe/src/internal/index.ts @@ -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' diff --git a/packages/devframe/src/node/__tests__/request-access.test.ts b/packages/devframe/src/node/__tests__/request-access.test.ts new file mode 100644 index 00000000..8edc71a9 --- /dev/null +++ b/packages/devframe/src/node/__tests__/request-access.test.ts @@ -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) + }) +}) diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index b05c5825..27812d7f 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -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}`, diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index c5fb151a..e64ac76e 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -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' diff --git a/packages/devframe/src/node/instance-shell.ts b/packages/devframe/src/node/instance-shell.ts index 212a65da..3d296031 100644 --- a/packages/devframe/src/node/instance-shell.ts +++ b/packages/devframe/src/node/instance-shell.ts @@ -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' @@ -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 context: DevframeNodeContext /** The context's RPC core (birpc group + connection lifecycle hooks). */ core: ContextRpcServer @@ -108,6 +111,7 @@ async function bindHttpAndWs(options: BindHttpAndWsOptions): Promise { address: string } | null stop: (closeActiveConnections?: boolean) => void | Promise } interface BunGlobal { @@ -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 @@ -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, }) } @@ -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 @@ -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) { @@ -375,6 +380,7 @@ export interface InstanceShellInit { } export interface CreateInstanceShellOptions { + access?: RequestAccessPolicy /** Normalized mount base (leading and trailing slash). */ base: string /** h3 app to mount on. A fresh one is created when omitted. */ @@ -471,6 +477,7 @@ export interface InstanceShellInternals { } export interface InstanceShell { + authorizeRequest: (request: IncomingMessage) => Promise base: string handler: (request: Request) => Promise nodeMiddleware: (req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) => void @@ -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 => { + 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. @@ -526,6 +564,7 @@ export function createInstanceShell( 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 @@ -721,6 +760,7 @@ export function createInstanceShell( 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 @@ -774,6 +814,7 @@ export function createInstanceShell( server: options.server, path: routePath, allowedOrigins: options.allowedOrigins, + authorizePeer: access.authorizePeer, destroyUnmatched: options.destroyUnmatchedUpgrades, }) return { websocketMeta: { path: advertisedPath }, wsAvailable: !wsDisabled } @@ -871,6 +912,7 @@ export function createInstanceShell( unbound: true, path: routePath, allowedOrigins: options.allowedOrigins, + authorizePeer: access.authorizePeer, onConnected: core.onConnected, onDisconnected: core.onDisconnected, }) @@ -910,8 +952,13 @@ export function createInstanceShell( 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) { @@ -994,6 +1041,7 @@ export function createInstanceShell( return { base, handler: handleRequest, + authorizeRequest: access.authorizeRequest, nodeMiddleware, ready: initPromise, context: contextPromise, diff --git a/packages/devframe/src/node/request-access.ts b/packages/devframe/src/node/request-access.ts new file mode 100644 index 00000000..461d5333 --- /dev/null +++ b/packages/devframe/src/node/request-access.ts @@ -0,0 +1,42 @@ +import { isIP } from 'node:net' + +export interface RequestAccessContext { + remoteAddress: string +} + +export interface RequestAccessPolicy { + internalIPs?: readonly string[] + authorize?: (context: RequestAccessContext) => boolean | Promise +} + +export function normalizeRemoteAddress(address: string | undefined): string | undefined { + if (!address || !isIP(address)) + return undefined + if (isIP(address) === 4) + return address + const [literal, scope] = address.split('%') + const normalized = new URL(`http://[${literal}]/`).hostname.slice(1, -1) + const mapped = /^::ffff:([\da-f]+):([\da-f]+)$/.exec(normalized) + if (!mapped) + return scope ? `${normalized}%${scope}` : normalized + const high = Number.parseInt(mapped[1], 16) + const low = Number.parseInt(mapped[2], 16) + return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}` +} + +export function createRequestAccess(policy: RequestAccessPolicy | undefined): (remoteAddress: string | undefined) => Promise { + const addresses = policy?.internalIPs === undefined + ? undefined + : new Set(policy.internalIPs.map(normalizeRemoteAddress).filter(address => address !== undefined)) + const authorize = policy?.authorize + return async (remoteAddress) => { + if (!policy) + return true + const resolved = normalizeRemoteAddress(remoteAddress) + if (!resolved) + return false + if (addresses && !addresses.has(resolved)) + return false + return authorize ? await authorize({ remoteAddress: resolved }) === true : true + } +} diff --git a/packages/devframe/src/rpc/transports/ws-bun.ts b/packages/devframe/src/rpc/transports/ws-bun.ts index 0ef8939c..3447696d 100644 --- a/packages/devframe/src/rpc/transports/ws-bun.ts +++ b/packages/devframe/src/rpc/transports/ws-bun.ts @@ -3,6 +3,7 @@ import type { WsOriginRegistry } from './ws-server' import { createWsRpcPeerHooks, isAllowedOrigin } from './ws-server' export interface AttachBunWsTransportOptions { + authorizePeer?: (remoteAddress: string | undefined) => Promise /** Same contract as `WsRpcTransportOptions.allowedOrigins`. */ allowedOrigins?: readonly string[] | WsOriginRegistry | false } @@ -46,6 +47,7 @@ export async function attachBunWsTransport( ...createWsRpcPeerHooks(core.rpcGroup, { onConnected: core.onConnected, onDisconnected: core.onDisconnected, + authorizePeer: options.authorizePeer, }), /** * The same origin policy `routeUpgrades` applies for the Node @@ -64,8 +66,12 @@ export async function attachBunWsTransport( }) return { - handleUpgrade: (request, server) => - ws.handleUpgrade(request, server as Parameters[1]), + handleUpgrade: async (request, server) => { + const peer = (server as { requestIP?: (request: Request) => { address: string } | null }).requestIP?.(request) + if (options.authorizePeer && !await options.authorizePeer(peer?.address)) + return new Response('Forbidden', { status: 403 }) + return ws.handleUpgrade(request, server as Parameters[1]) + }, websocket: ws.websocket as BunWsTierWebSocket, close: () => ws.close(), } diff --git a/packages/devframe/src/rpc/transports/ws-deno.ts b/packages/devframe/src/rpc/transports/ws-deno.ts index 24a30114..dea34af1 100644 --- a/packages/devframe/src/rpc/transports/ws-deno.ts +++ b/packages/devframe/src/rpc/transports/ws-deno.ts @@ -3,6 +3,7 @@ import type { WsOriginRegistry } from './ws-server' import { createWsRpcPeerHooks, isAllowedOrigin } from './ws-server' export interface AttachDenoWsTransportOptions { + authorizePeer?: (remoteAddress: string | undefined) => Promise /** Same contract as `WsRpcTransportOptions.allowedOrigins`. */ allowedOrigins?: readonly string[] | WsOriginRegistry | false } @@ -36,6 +37,7 @@ export async function attachDenoWsTransport( ...createWsRpcPeerHooks(core.rpcGroup, { onConnected: core.onConnected, onDisconnected: core.onDisconnected, + authorizePeer: options.authorizePeer, }), /** * The same origin policy `routeUpgrades` applies for the Node @@ -54,8 +56,12 @@ export async function attachDenoWsTransport( }) return { - handleUpgrade: (request, info) => - ws.handleUpgrade(request, info as Parameters[1]), + handleUpgrade: async (request, info) => { + const peer = info as { remoteAddr?: { hostname?: string } } | undefined + if (options.authorizePeer && !await options.authorizePeer(peer?.remoteAddr?.hostname)) + return new Response('Forbidden', { status: 403 }) + return ws.handleUpgrade(request, info as Parameters[1]) + }, close: () => ws.close(), } } diff --git a/packages/devframe/src/rpc/transports/ws-server.ts b/packages/devframe/src/rpc/transports/ws-server.ts index 6ead63be..c246aa92 100644 --- a/packages/devframe/src/rpc/transports/ws-server.ts +++ b/packages/devframe/src/rpc/transports/ws-server.ts @@ -14,6 +14,7 @@ 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 { diagnostics } from '../../node/diagnostics' import { createRpcWireCodec } from '../wire-codec' import { createRpcSessionMeta } from './session' @@ -26,6 +27,7 @@ export type { } from './session' export interface WsRpcTransportOptions { + authorizePeer?: (remoteAddress: string | undefined) => Promise /** * Attach to an existing HTTP(S) server, sharing its port. Combine with * `path` to bind the WS endpoint to a single route so it coexists with @@ -253,6 +255,7 @@ function createUpgradeListener( path: string | undefined, destroyUnmatched: boolean, allowedOrigins: readonly string[] | WsOriginRegistry | false | undefined, + authorizePeer: WsRpcTransportOptions['authorizePeer'], ): (req: IncomingMessage, socket: Duplex, head: Buffer) => void { return (req, socket, head) => { socket.on('error', () => { @@ -282,7 +285,17 @@ function createUpgradeListener( socket.destroy() return } - void ws.handleUpgrade(req, socket, head) + const upgrade = async (): Promise => { + if (authorizePeer && !await authorizePeer(req.socket.remoteAddress)) { + socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n') + return + } + await ws.handleUpgrade(req, socket, head) + } + void upgrade().catch((cause: unknown) => { + socket.destroy() + diagnostics.DF0082({ cause }, { method: 'error' }) + }) } } @@ -299,7 +312,7 @@ export function createWsRpcPeerHooks< ServerFunctions extends object, >( rpcGroup: BirpcGroup, - options: Pick = {}, + options: Pick = {}, ): Partial { const { onConnected = NOOP, @@ -354,7 +367,20 @@ export function createWsRpcPeerHooks< }) onConnected(connection, meta) }, - message: (peer, message) => { + message: async (peer, message) => { + if (options.authorizePeer) { + try { + if (!await options.authorizePeer(peer.remoteAddress)) { + peer.close(1008, 'Forbidden') + return + } + } + catch (cause) { + peer.close(1011, 'Authorization failed') + diagnostics.DF0082({ cause }, { method: 'error' }) + return + } + } states.get(peer)?.onMessage?.(message.text()) }, close: (peer) => { @@ -406,8 +432,8 @@ export function attachWsRpcTransport< // One listener shape for every binding: the transport's own server, a // shared host server, and the caller-driven `handleUpgrade` / `attach`. - const sharedUpgradeListener = createUpgradeListener(ws, path, destroyUnmatched, allowedOrigins) - const ownedUpgradeListener = createUpgradeListener(ws, path, true, allowedOrigins) + const sharedUpgradeListener = createUpgradeListener(ws, path, destroyUnmatched, allowedOrigins, options.authorizePeer) + const ownedUpgradeListener = createUpgradeListener(ws, path, true, allowedOrigins, options.authorizePeer) /** Bind a server's `upgrade` events, tracked so `close()` detaches them. */ const attachments = new Set<() => void>() diff --git a/packages/hub/package.json b/packages/hub/package.json index f04435a9..1add0362 100644 --- a/packages/hub/package.json +++ b/packages/hub/package.json @@ -63,6 +63,7 @@ "devframe": "workspace:*", "get-port-please": "catalog:deps", "tsdown": "catalog:build", - "valibot": "catalog:deps" + "valibot": "catalog:deps", + "ws": "catalog:deps" } } diff --git a/packages/hub/src/node/__tests__/initiate.test.ts b/packages/hub/src/node/__tests__/initiate.test.ts index 3f8e54b2..272379da 100644 --- a/packages/hub/src/node/__tests__/initiate.test.ts +++ b/packages/hub/src/node/__tests__/initiate.test.ts @@ -1,12 +1,15 @@ import type { DevframeDefinition, DevframeNodeContext, DevframeRpcClientFunctions, DevframeRpcServerFunctions } from 'devframe/types' +import type { InitHubOptions } from '../initiate' +import { once } from 'node:events' import { mkdtempSync, writeFileSync } from 'node:fs' -import { createServer } from 'node:http' +import { createServer, request } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import WebSocket from 'ws' import { DOCK_RENDERERS_STATE_KEY } from '../../constants' import { DEVFRAMES_HUB_BASE, initHub } from '../initiate' @@ -44,6 +47,146 @@ function connectWsClient(url: string) { ) } +async function startAccessHub(options: Pick) { + const server = createServer() + const hub = initHub({ base: DEVFRAMES_HUB_BASE, server, auth: false, devframes: [makeFrame('access')], ...options }) + server.on('request', hub.nodeMiddleware) + onTestFinished(async () => { + await hub.close() + server.closeAllConnections() + await new Promise(resolve => server.close(() => resolve())) + }) + await hub.ready + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as { port: number } + return { hub, server, origin: `http://127.0.0.1:${address.port}` } +} + +async function upgradeStatus(url: string, origin?: string): Promise { + return new Promise((resolve, reject) => { + const client = new WebSocket(url, origin ? { origin } : undefined) + client.on('error', reject) + client.once('open', () => { + resolve(101) + client.close() + }) + client.once('unexpected-response', (_request, response) => { + resolve(response.statusCode!) + response.resume() + client.terminate() + }) + }) +} + +describe('request access policy', () => { + it('authorizes metadata routes mounted outside the hub namespace', async () => { + let allowed = true + const { hub, server, origin } = await startAccessHub({ access: { authorize: () => allowed } }) + server.removeListener('request', hub.nodeMiddleware) + server.on('request', (req, res) => { + void hub.authorizeRequest(req).then((permitted) => { + res.statusCode = permitted ? 200 : 403 + res.end(permitted ? 'connection capabilities' : 'Forbidden') + }) + }) + expect((await fetch(`${origin}/external/__connection.json`)).status).toBe(200) + allowed = false + const denied = await fetch(`${origin}/external/__connection.json`) + expect(denied.status).toBe(403) + expect(await denied.text()).not.toContain('capabilities') + }) + + it('admits allowed HTTP, MCP, SSE, and WebSocket requests', async () => { + const { origin } = await startAccessHub({ access: { internalIPs: ['127.0.0.1'] }, mcp: true }) + const mcp = await fetch(`${origin}${DEVFRAMES_HUB_BASE}__mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream', 'Origin': origin }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), + }) + expect(mcp.status).toBe(200) + expect(await mcp.text()).toContain('access-tool') + const sse = await fetch(`${origin}${DEVFRAMES_HUB_BASE}__sse`) + expect(sse.status).toBe(200) + await sse.body?.cancel() + expect(await upgradeStatus(`${origin.replace('http:', 'ws:')}${DEVFRAMES_HUB_BASE}__ws`)).toBe(101) + }) + + it('rejects hostile Host headers before serving metadata', async () => { + const { origin } = await startAccessHub({ access: { internalIPs: ['127.0.0.1'] } }) + const code = await new Promise((resolve, reject) => { + const req = request(`${origin}${DEVFRAMES_HUB_BASE}__connection.json`, { headers: { Host: 'attacker.example' } }, (response) => { + response.resume() + resolve(response.statusCode) + }) + req.on('error', reject) + req.end() + }) + expect(code).toBe(403) + }) + + it('protects sidecar WebSocket upgrades', async () => { + const { hub } = await startAccessHub({ ws: { sidecar: true }, access: { internalIPs: [] } }) + const endpoint = hub.connectionMeta().websocket as { port: number, path: string } + expect(await upgradeStatus(`ws://localhost:${endpoint.port}/${endpoint.path}`)).toBe(403) + }) + + it.each([false, true])('denies all protected HTTP endpoints independently of auth=%s', async (auth) => { + const { origin } = await startAccessHub({ auth, access: { internalIPs: [] }, mcp: true }) + for (const route of ['', '__connection.json', '__index.json', '__client-imports.js', 'embedded.js', 'access/__connection.json', '__sse', '__mcp']) { + const response = await fetch(`${origin}${DEVFRAMES_HUB_BASE}${route}`) + expect(response.status, route).toBe(403) + expect(await response.text()).not.toContain('websocket') + } + }) + + it('accepts normalized peers and ignores spoofed forwarding headers', async () => { + const { origin } = await startAccessHub({ access: { internalIPs: ['::ffff:127.0.0.1'] } }) + const response = await fetch(`${origin}${DEVFRAMES_HUB_BASE}__connection.json`, { headers: { 'X-Forwarded-For': '203.0.113.8' } }) + expect(response.status).toBe(200) + const denied = await startAccessHub({ access: { internalIPs: ['203.0.113.8'] } }) + expect((await fetch(`${denied.origin}${DEVFRAMES_HUB_BASE}__connection.json`, { + headers: { 'X-Forwarded-For': '203.0.113.8', 'Forwarded': 'for=203.0.113.8', 'X-Real-IP': '203.0.113.8' }, + })).status).toBe(403) + }) + + it('fails closed for fetch requests without peer metadata', async () => { + const { hub, origin } = await startAccessHub({ access: { internalIPs: ['127.0.0.1'] } }) + expect((await hub.handler(new Request(`${origin}${DEVFRAMES_HUB_BASE}__connection.json`))).status).toBe(403) + }) + + it.each([false, true])('evaluates a request-time callback (async=%s)', async (asynchronous) => { + const authorize = vi.fn(({ remoteAddress }: { remoteAddress: string }) => asynchronous ? Promise.resolve(remoteAddress === '127.0.0.1') : remoteAddress === '127.0.0.1') + const { origin } = await startAccessHub({ access: { authorize } }) + expect(authorize).not.toHaveBeenCalled() + expect((await fetch(`${origin}${DEVFRAMES_HUB_BASE}__connection.json`)).status).toBe(200) + expect(authorize).toHaveBeenCalledWith({ remoteAddress: '127.0.0.1' }) + }) + + it('denies hostile browser origins even for an allowed peer with auth disabled', async () => { + const { origin } = await startAccessHub({ access: { internalIPs: ['127.0.0.1'] } }) + expect((await fetch(`${origin}${DEVFRAMES_HUB_BASE}__connection.json`, { headers: { Origin: 'https://attacker.example' } })).status).toBe(403) + expect(await upgradeStatus(`${origin.replace('http:', 'ws:')}${DEVFRAMES_HUB_BASE}__ws`, 'https://attacker.example')).toBe(403) + }) + + it('requires both restrictions on HTTP and WebSocket upgrades', async () => { + const { origin } = await startAccessHub({ access: { internalIPs: ['127.0.0.1'], authorize: async () => false } }) + expect((await fetch(`${origin}${DEVFRAMES_HUB_BASE}__connection.json`)).status).toBe(403) + expect(await upgradeStatus(`${origin.replace('http:', 'ws:')}${DEVFRAMES_HUB_BASE}__ws`)).toBe(403) + }) + + it('rechecks policy on an already admitted connection', async () => { + let allowed = true + const { origin } = await startAccessHub({ access: { authorize: () => allowed } }) + const client = new WebSocket(`${origin.replace('http:', 'ws:')}${DEVFRAMES_HUB_BASE}__ws`) + await once(client, 'open') + onTestFinished(() => client.terminate()) + allowed = false + const closed = once(client, 'close') + client.send('{}') + expect((await closed)[0]).toBe(1008) + }) +}) + describe('initHub', () => { it('publishes an empty renderer manifest when no renderers are registered', async () => { expect.assertions(2) diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts index 2af9db7f..84c4026a 100644 --- a/packages/hub/src/node/initiate.ts +++ b/packages/hub/src/node/initiate.ts @@ -1,4 +1,4 @@ -import type { AgenticMcpModule, DevframeInstanceRecord, InstanceShellApi, ResolvedMcpConfig } from 'devframe/internal' +import type { AgenticMcpModule, DevframeInstanceRecord, InstanceShellApi, RequestAccessPolicy, ResolvedMcpConfig } from 'devframe/internal' import type { DevframeAuthHandler } from 'devframe/node/auth' import type { AuthBannerFunction } from 'devframe/recipes/interactive-auth' import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server' @@ -143,6 +143,7 @@ export type DevframesInput = Array< > export interface InitHubOptions { + access?: RequestAccessPolicy /** * Name for the hub instance, used in logs and diagnostics, and mcp server. */ @@ -299,6 +300,7 @@ export interface InitHubOptions { } export interface HubInstance { + authorizeRequest: (request: IncomingMessage) => Promise /** * The normalized mount base this hub answers under (leading and trailing * slash, e.g. `/__devframes/`). Reference it when wiring the mount (route @@ -407,6 +409,7 @@ export function initHub(options: InitHubOptions): HubInstance { const shell = createInstanceShell({ base, + access: options.access, app, host: options.host, origin: options.origin, @@ -612,6 +615,7 @@ export function initHub(options: InitHubOptions): HubInstance { base: shell.base, handler: shell.handler, nodeMiddleware: shell.nodeMiddleware, + authorizeRequest: shell.authorizeRequest, attach: shell.attach, handleUpgrade: shell.handleUpgrade, ready: shell.ready, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7feed286..7d9a9417 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1758,6 +1758,9 @@ importers: valibot: specifier: catalog:deps version: 1.5.0(typescript@6.0.3) + ws: + specifier: catalog:deps + version: 8.22.0 packages/hub-ui: devDependencies: @@ -4376,6 +4379,7 @@ packages: '@opentelemetry/sdk-trace-base@2.11.0': resolution: {integrity: sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts index 75b0a5ff..28fbfec4 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts @@ -23,6 +23,7 @@ export interface HubDevframeEntry { dock?: InstallDevframeOptions['dock']; } export interface HubInstance { + authorizeRequest: (_: IncomingMessage) => Promise; base: string; handler: (_: Request) => Promise; nodeMiddleware: (_: IncomingMessage, _: ServerResponse, _?: (_?: unknown) => void) => void; @@ -34,6 +35,7 @@ export interface HubInstance { close: () => Promise; } export interface InitHubOptions { + access?: RequestAccessPolicy; name?: string; version?: string; base: string; diff --git a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts index eddffc56..d198fc70 100644 --- a/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/initiate.snapshot.d.ts @@ -3,6 +3,7 @@ */ // #region Interfaces export interface DevframeInstance { + authorizeRequest: (_: IncomingMessage) => Promise; base: string; handler: (_: Request) => Promise; nodeMiddleware: (_: IncomingMessage, _: ServerResponse, _?: (_?: unknown) => void) => void; @@ -18,6 +19,7 @@ export interface DevframeInstanceInternals { readonly authHandler?: DevframeAuthHandler; } export interface InitDevframeOptions { + access?: RequestAccessPolicy; base: string; distDir?: StaticAssetsSource | false; server?: Server; diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts index eadd1d99..80b9d3d2 100644 --- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts @@ -371,6 +371,10 @@ export declare const diagnostics: import("nostics").Diagnostics<{ }) => string; readonly fix: "Install `@devframes/agentic` next to devframe (the MCP adapter and the MCP SDK live there), or remove the explicit `mcp` setting."; }; + readonly DF0082: { + readonly why: "Request access authorization failed on a WebSocket connection."; + readonly fix: "Check the server-side access callback and its reported cause."; + }; readonly DF0081: { readonly why: (p: { name: string; @@ -407,6 +411,8 @@ export { MountMcpHttpOptions } export { normalizeBasePath } export { probeDevframeOrigin } export { registerDevframeInstance } +export { RequestAccessContext } +export { RequestAccessPolicy } export { resolveBasePath } export { ResolvedMcpConfig } export { resolveInstanceRegister } diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index f2acbe76..a8ab9d2e 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -24,5 +24,7 @@ export declare function createStorage(_: CreateStorageOptions< // #endregion // #region Other +export { RequestAccessContext } +export { RequestAccessPolicy } export { RpcFunctionsHost } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-bun.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-bun.snapshot.d.ts index c45ac0b3..e337b7c2 100644 --- a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-bun.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-bun.snapshot.d.ts @@ -3,6 +3,7 @@ */ // #region Interfaces export interface AttachBunWsTransportOptions { + authorizePeer?: (_: string | undefined) => Promise; allowedOrigins?: readonly string[] | WsOriginRegistry | false; } export interface BunWsTier { diff --git a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-deno.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-deno.snapshot.d.ts index 28315ab8..45c61ed4 100644 --- a/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-deno.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/rpc/transports/ws-deno.snapshot.d.ts @@ -3,6 +3,7 @@ */ // #region Interfaces export interface AttachDenoWsTransportOptions { + authorizePeer?: (_: string | undefined) => Promise; allowedOrigins?: readonly string[] | WsOriginRegistry | false; } export interface DenoWsTier {