From a5bdf3f1ab84372da5c3457df8690849e27700ae Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 4 Aug 2026 06:14:32 +0000 Subject: [PATCH 1/2] feat(security)!: enforce remote-dock tokens, gate MCP + hosted bridges, move OTP to URL fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the connect/MCP auth surfaces: - Wire `isRemoteTokenTrusted` into `createInteractiveAuth`'s connect-time gate so remote-UI dock tokens actually authenticate and `originLock` binds a token to its dock origin (previously dead code — minted, never verified). - Require an `Authorization: Bearer ` on the route-based MCP endpoint (`createMcpFetchHandler`), the real gate since the origin check only ever constrains browsers. `createDevServer`/`@devframes/next` mint a per-instance token, record it in the instance-registry file (now written mode 0600), and `devframe connect` presents it automatically. - Move the magic-link OTP from the query string (`?devframe_otp=`) to the URL fragment (`#devframe_otp=`), which the browser never sends to the server, so the one-click code stays out of access logs and Referer headers. BREAKING CHANGE: the hosted bridges (`viteDevBridge`, `createDevframeNextHandler`) now gate their side-car RPC/WS server by default instead of running with `auth: false`. Pass `auth: false` explicitly to keep a single-user localhost host ungated. The route-based MCP endpoint now requires a bearer token; obtain it from the instance registry (or `StartedServer.mcpAuthToken`). Created with the help of an agent. --- docs/adapters/mcp.md | 9 ++- docs/guide/client.md | 2 +- docs/guide/security.md | 9 ++- .../src/client/devframe/next-devframe-hub.ts | 20 ++++- .../tests/next-devframe-hub.test.ts | 30 +++++--- .../src/vite-devframe-hub.ts | 14 +++- .../src/adapters/__tests__/dev.test.ts | 2 +- packages/devframe/src/adapters/dev.ts | 15 +++- .../adapters/mcp/__tests__/mcp-http.test.ts | 46 ++++++++++- packages/devframe/src/adapters/mcp/fetch.ts | 42 +++++++++- packages/devframe/src/cli/connect.ts | 16 ++-- .../devframe/src/client/__tests__/otp.test.ts | 35 ++++++--- packages/devframe/src/client/otp.ts | 31 +++++--- packages/devframe/src/constants.ts | 13 ++-- .../src/helpers/__tests__/vite.test.ts | 76 ++++++++++++++++++- packages/devframe/src/helpers/vite.ts | 34 +++++---- packages/devframe/src/node/auth/state.ts | 17 +++-- .../devframe/src/node/instance-registry.ts | 17 +++-- packages/devframe/src/node/server.ts | 8 ++ .../__tests__/interactive-auth.test.ts | 60 +++++++++++++++ .../devframe/src/recipes/interactive-auth.ts | 22 +++++- packages/next/src/handler.ts | 23 +++++- packages/next/src/host.ts | 20 ++++- packages/next/test/handler.test.ts | 21 ++++- skills/devframe/SKILL.md | 5 +- .../@devframes/next/index.snapshot.d.ts | 3 + .../devframe/adapters/mcp.snapshot.d.ts | 1 + .../tsnapi/devframe/node.snapshot.d.ts | 1 + 28 files changed, 489 insertions(+), 103 deletions(-) diff --git a/docs/adapters/mcp.md b/docs/adapters/mcp.md index 8f146de5..9e99f33b 100644 --- a/docs/adapters/mcp.md +++ b/docs/adapters/mcp.md @@ -35,7 +35,7 @@ export default defineDevframe({ The endpoint speaks the MCP Streamable-HTTP transport at `/__mcp` (relative to the base path — `/__/__mcp` under a host), sharing the dev server's origin and port. The `--mcp` and `--no-mcp` flags override the definition per run. `__connection.json` advertises the route so in-browser tooling can discover it. -Each client session gets its own MCP server built from the live context, correlated by the `Mcp-Session-Id` header, so `tools/list_changed` and `resources/list_changed` notifications reach connected clients as the tool evolves. The endpoint binds to the same loopback host as the dev server and applies the shared loopback origin gate; widen it for a tunnel or LAN origin: +Each client session gets its own MCP server built from the live context, correlated by the `Mcp-Session-Id` header, so `tools/list_changed` and `resources/list_changed` notifications reach connected clients as the tool evolves. The dev server mints a per-instance bearer token and the endpoint requires it as `Authorization: Bearer ` — the real authentication, since the origin gate only ever constrains browsers (a non-browser client can omit or spoof the `Origin` header). The token is recorded in the instance registry (a user-private file, mode `0600`) so `devframe connect` presents it automatically; a client dialing the route directly reads it from `StartedServer.mcpAuthToken`. The endpoint binds to the same loopback host as the dev server and keeps the shared loopback origin gate as defense-in-depth; widen it for a tunnel or LAN origin: ```ts defineDevframe({ @@ -64,11 +64,16 @@ createDevframeNextHandler(devframe, { mcp: true }) ```ts import { createMcpFetchHandler } from 'devframe/adapters/mcp' +import { randomToken } from 'devframe/utils/crypto-token' +const authToken = randomToken() const mcp = createMcpFetchHandler(ctx, { serverName: 'my-tool (devframe)', serverVersion: '1.0.0', exposeSharedState: true, + // Required for every request as `Authorization: Bearer `. Hand it to + // trusted clients out-of-band — e.g. record it in the instance registry. + authToken, }) // route every method on /__mcp to mcp.fetch(request) ``` @@ -90,6 +95,6 @@ It exposes two gateway tools (the wire names of the `devframe:connect:*` ids — - **`devframe_connect_list-instances`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`. - **`devframe_connect_call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint. -Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/-.json` on boot and removes it on close; readers prune records whose liveness probe fails. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port ` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out. +Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/-.json` on boot and removes it on close; readers prune records whose liveness probe fails. The record carries the instance's MCP bearer token (in a file written mode `0600`), which the connector presents on every call — so `devframe connect` reaches a token-gated route without any configuration. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp`, which mints and returns the token to record, for serving MCP on a Next app's own origin. `--port ` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out. See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example. diff --git a/docs/guide/client.md b/docs/guide/client.md index d1bdaf29..2ad1c278 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -125,7 +125,7 @@ const ok = await rpc.requestTrustWithCode('047204') The code is single-use, expires after five minutes, and is rotated after repeated wrong attempts, so re-display the current code if an exchange fails. -To authenticate without typing, a host can print a link embedding the code (`buildOtpAuthUrl(origin)`); `connectDevframe` reads the `devframe_otp` query parameter, exchanges it, and strips it from the URL. Rename it with the `otpParam` option, or set `otpParam: false` and drive authentication yourself with the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` utilities. +To authenticate without typing, a host can print a link embedding the code (`buildOtpAuthUrl(origin)`); `connectDevframe` reads the `devframe_otp` fragment parameter (`#devframe_otp=…`, kept out of server logs and `Referer`), exchanges it, and strips it from the URL. Rename it with the `otpParam` option, or set `otpParam: false` and drive authentication yourself with the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` utilities. ### Re-using an existing token diff --git a/docs/guide/security.md b/docs/guide/security.md index 6fa44c37..d63d2ce6 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -84,18 +84,19 @@ Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a cod To skip typing, a host can print a link that embeds the code and open the browser straight into an authenticated session. The standalone CLI (`createCac` / `createDevServer`) does this automatically for `--open`: when the server is auth-gated, the browser it launches already carries the current code, so the tab lands authenticated with no prompt at all. Build the link yourself from the current code with `buildOtpAuthUrl(origin)` (devframe stays headless, so the host prints its own banner): ``` -Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456 +Devtools ready — authenticate this browser: http://localhost:3000/#devframe_otp=123456 ``` -`connectDevframe` reads the `devframe_otp` parameter, exchanges it, and removes it from the URL before anything else. Only the short-lived, single-use **code** ever rides the URL — the resulting bearer token is stored, never written back to it. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal), exactly as you would the bare code. +The code rides the URL **fragment** (`#devframe_otp=…`), which the browser never sends to the server — so the single-use code stays out of access logs and `Referer` headers. `connectDevframe` reads the `devframe_otp` fragment parameter, exchanges it, and removes it from the URL before anything else. Only the short-lived, single-use **code** ever rides the URL — the resulting bearer token is stored, never written back to it. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal), exactly as you would the bare code. Higher-level integrations can drive their own authentication UI instead: disable the built-in handling with the `otpParam: false` client option, then call the exposed `authenticateWithUrlOtp(rpc)` (consume the code from the URL and exchange it) or `consumeOtpFromUrl()` (read and strip the code) from `devframe/client`. ## Practices for tools built on devframe - **Stay on loopback.** The default bind host is `localhost`. Bind to a routable address only when you intend to, and require authentication when you do. -- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere. +- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere. The hosted bridges (`viteDevBridge`, `@devframes/next`'s handler) gate their side-car by default too — a host that owns the trust boundary another way opts out with `auth: false` explicitly. +- **The MCP route carries its own token.** The route-based MCP server requires a per-instance `Authorization: Bearer` token (recorded in the instance registry for `devframe connect`), because its origin gate only constrains browsers — see [MCP](/adapters/mcp). - **Treat tokens as secrets.** Never log the bearer token or the one-time code, and never bake either into build output. - **Authorize every handler.** A registered function is callable by any trusted client. Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. -- **Origin-lock remote docks.** When a hub embeds a remote-UI dock, enable `originLock` so a dock token is only honored from its expected origin. +- **Origin-lock remote docks.** When a hub embeds a remote-UI dock, keep `originLock` on (the default) so its session token is only honored on a connection whose `Origin` matches the dock's own — the connect-time gate verifies the token against the recorded origin before the connection is trusted. - **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`. diff --git a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts index 26b28755..3deb9dbc 100644 --- a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts +++ b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts @@ -10,6 +10,8 @@ import { createHubContext, mountDevframe } from '@devframes/hub/node' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import { createDevframeNextHost } from '@devframes/next' import { registerDevframeInstance, startHttpAndWs } from 'devframe/node' +import { createInteractiveAuth } from 'devframe/recipes/interactive-auth' +import { randomToken } from 'devframe/utils/crypto-token' import { getPort } from 'get-port-please' import { createDashboardView } from 'json-render/dashboard' import { dirname, join } from 'pathe' @@ -264,19 +266,27 @@ export async function nextDevframeHub( }, }) + // Gate the side-car RPC/WS server instead of leaving it open: the hub owns + // the trust boundary, so it mints a per-boot bearer token, accepts it as a + // pre-shared `clientAuthToken`, and hands it to its own SPA through the + // connection meta it serves (`authToken` below). `connectDevframe` presents + // it automatically — the host authenticates its own trusted origin with no + // code prompt, while an arbitrary connection is rejected. + const clientAuthToken = randomToken() const started = await startHttpAndWs({ context, host: hostName, port, - auth: false, + auth: createInteractiveAuth(context, { clientAuthTokens: [clientAuthToken] }), }) // Serve MCP in-process on the Next app's own origin (the `/_next/mcp` // shape): the hub's agent surface — agent-flagged commands, plugin tools // (git status/log/diff, terminals), `devframe:state:read` — over the same catch-all - // route as the SPAs, no side-car port involved. + // route as the SPAs, no side-car port involved. `mountMcp` mints the bearer + // token the route requires and returns it for the registry record below. const mcpPath = '/__hub/__mcp' - await nextHost.mountMcp(context, mcpPath, { + const mcp = await nextHost.mountMcp(context, mcpPath, { serverName: 'example:next-devframe-hub', }) @@ -284,6 +294,7 @@ export async function nextDevframeHub( backend: 'websocket' as const, websocket: started.port, mcp: { path: mcpPath }, + authToken: clientAuthToken, } // Publish the live meta to the bridge now the WS port is known, so every // registered `/__connection.json` (hub + mounted devframes) resolves. @@ -301,12 +312,13 @@ export async function nextDevframeHub( id: 'example:next-devframe-hub', name: 'Next Devframe Hub', rootDir: cwd, - mcp: { path: mcpPath }, + mcp: { path: mcpPath, token: mcp.authToken }, startedAt: Date.now(), }) const closeStarted = started.close started.close = async () => { registration.unregister() + await mcp.dispose() await closeStarted() } diff --git a/examples/next-devframe-hub/tests/next-devframe-hub.test.ts b/examples/next-devframe-hub/tests/next-devframe-hub.test.ts index 3975ccfe..65a416b3 100644 --- a/examples/next-devframe-hub/tests/next-devframe-hub.test.ts +++ b/examples/next-devframe-hub/tests/next-devframe-hub.test.ts @@ -6,8 +6,8 @@ import { nextDevframeHub } from '../src/client/devframe/next-devframe-hub' vi.stubGlobal('WebSocket', WebSocket) -function bootRpc(port: number) { - const channel = createWsRpcChannel({ url: `ws://127.0.0.1:${port}` }) +function bootRpc(port: number, authToken?: string) { + const channel = createWsRpcChannel({ url: `ws://127.0.0.1:${port}`, authToken }) return createRpcClient({}, { channel }) } @@ -19,14 +19,24 @@ describe('next-devframe-hub (example)', () => { server = undefined }) - it('returns connection meta pointing at the WS backend and in-process MCP', async () => { + it('returns connection meta pointing at the WS backend, in-process MCP, and a host-injected auth token', async () => { server = await nextDevframeHub({ host: '127.0.0.1' }) - expect(server.connectionMeta).toEqual({ - backend: 'websocket', - websocket: server.port, - mcp: { path: '/__hub/__mcp' }, - }) + expect(server.connectionMeta.backend).toBe('websocket') + expect(server.connectionMeta.websocket).toBe(server.port) + expect(server.connectionMeta.mcp).toEqual({ path: '/__hub/__mcp' }) + // The gated side-car hands its own SPA a pre-shared token via the meta. + expect(server.connectionMeta.authToken).toBeTypeOf('string') + expect(server.connectionMeta.authToken!.length).toBeGreaterThanOrEqual(32) + }) + + it('rejects a connection that does not present the host auth token', async () => { + server = await nextDevframeHub({ host: '127.0.0.1' }) + + const rpc = bootRpc(server.port) + await expect( + rpc.$call('example:next-devframe-hub:messages:list'), + ).rejects.toThrow() }) it('registers a hub-owned settings dock and the mounted plugin docks', async () => { @@ -49,7 +59,7 @@ describe('next-devframe-hub (example)', () => { it('lists startup and demo messages through the kit-local RPC', async () => { server = await nextDevframeHub({ host: '127.0.0.1' }) - const rpc = bootRpc(server.port) + const rpc = bootRpc(server.port, server.connectionMeta.authToken) const messages = await rpc.$call('example:next-devframe-hub:messages:list') as { message: string }[] expect(messages.map(m => m.message)).toContain('Next Devframe Hub started') expect(messages.map(m => m.message)).toContain('Next demo devframe loaded') @@ -58,7 +68,7 @@ describe('next-devframe-hub (example)', () => { it('executes the ping command through the hub command RPC', async () => { server = await nextDevframeHub({ host: '127.0.0.1' }) - const rpc = bootRpc(server.port) + const rpc = bootRpc(server.port, server.connectionMeta.authToken) await expect( rpc.$call('hub:commands:execute', 'example:next-devframe-hub:ping'), ).resolves.toBe('pong') diff --git a/examples/vite-devframe-hub/src/vite-devframe-hub.ts b/examples/vite-devframe-hub/src/vite-devframe-hub.ts index 5c1e8750..d6cbc627 100644 --- a/examples/vite-devframe-hub/src/vite-devframe-hub.ts +++ b/examples/vite-devframe-hub/src/vite-devframe-hub.ts @@ -9,6 +9,8 @@ import { defineHubRpcFunction } from '@devframes/hub' import { createHubContext, mountDevframe } from '@devframes/hub/node' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' import { registerDevframeInstance, startHttpAndWs } from 'devframe/node' +import { createInteractiveAuth } from 'devframe/recipes/interactive-auth' +import { randomToken } from 'devframe/utils/crypto-token' import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { join } from 'pathe' @@ -101,6 +103,14 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { // port. Clients discover whatever was chosen via `__connection.json`. const port = options.port ?? await getPort({ port: 9777, portRange: [9777, 9877] }) + // Gate the side-car RPC/WS server: the hub owns the trust boundary, so it + // mints a per-boot bearer token, accepts it as a pre-shared + // `clientAuthToken`, and hands it to its own SPA through the connection + // meta below. `connectDevframe` presents it automatically — the host + // trusts its own origin with no code prompt, while an arbitrary + // connection is rejected. + const clientAuthToken = randomToken() + // Serve the side-car's connection meta (`__connection.json`) at a URL // base so a browser loaded there can discover the WS endpoint via // `connectDevframe()`'s relative `./__connection.json` fetch. @@ -108,7 +118,7 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { const metaPath = `${metaBase}${DEVFRAME_CONNECTION_META_FILENAME}` server.middlewares.use(metaPath, (_req, res) => { res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ backend: 'websocket', websocket: port })) + res.end(JSON.stringify({ backend: 'websocket', websocket: port, authToken: clientAuthToken })) }) } @@ -175,7 +185,7 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { started = await startHttpAndWs({ context, port, - auth: false, + auth: createInteractiveAuth(context, { clientAuthTokens: [clientAuthToken] }), }) // Tell the hub UI (served at `base`) where to find the WS endpoint. diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index ac621278..cd189407 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -349,7 +349,7 @@ describe('adapters/dev', () => { try { expect(mockedOpen).toHaveBeenCalledTimes(1) const [target] = mockedOpen.mock.calls[0] - expect(target).toBe(`http://localhost:${port}/?devframe_otp=${code}`) + expect(target).toBe(`http://localhost:${port}/#devframe_otp=${code}`) } finally { spy.mockRestore() diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 2f55b369..61895606 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -3,6 +3,7 @@ import type { StartedServer } from '../node/server' import type { ConnectionMeta } from '../types/context' import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe' import process from 'node:process' +import { randomToken } from 'devframe/utils/crypto-token' import { open } from 'devframe/utils/open' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' @@ -182,7 +183,12 @@ export async function createDevServer( const mcpConfig = resolveMcpConfig(options.mcp ?? def.cli?.mcp) let mcpDispose: (() => Promise) | undefined let mcpMeta: ConnectionMeta['mcp'] + // Bearer token guarding the MCP route (see `createMcpFetchHandler`). Minted + // per-instance, handed to local discovery tools via the registry record — + // never advertised in `__connection.json`. + let mcpAuthToken: string | undefined if (mcpConfig) { + mcpAuthToken = randomToken() const mcpRoute = withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE) const mcpPath = joinURL(basePath, mcpRoute) let mountMcpHttp: typeof import('./mcp/http').mountMcpHttp @@ -198,6 +204,7 @@ export async function createDevServer( serverVersion: def.version ?? '0.0.0', exposeSharedState: true, allowedOrigins: mcpConfig.allowedOrigins, + authToken: mcpAuthToken, }) mcpDispose = mounted.dispose mcpMeta = { path: mcpRoute } @@ -275,10 +282,16 @@ export async function createDevServer( id: def.id, name: def.name, rootDir: process.cwd(), - mcp: mcpConfig ? { path: joinURL(basePath, withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE)) } : null, + mcp: mcpConfig + ? { path: joinURL(basePath, withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE)), token: mcpAuthToken } + : null, startedAt: Date.now(), }) + // Surface the MCP bearer token on the handle so a host that needs to present + // or forward it (tests, a custom launcher) can read it. + started.mcpAuthToken = mcpAuthToken + // Fold MCP session teardown and registry removal into the server's close so // callers get a single graceful-shutdown handle. const closeServer = started.close diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts index 1480a6af..f6567465 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts @@ -60,9 +60,21 @@ describe('mcp adapter (streamable http route)', () => { expect(meta.mcp).toBeUndefined() }) + function authTransport(started: StartedServer): StreamableHTTPClientTransport { + return new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`), { + requestInit: { headers: { Authorization: `Bearer ${started.mcpAuthToken}` } }, + }) + } + + it('mints a bearer token and exposes it on the server handle', async () => { + const started = await boot() + expect(started.mcpAuthToken).toBeTypeOf('string') + expect(started.mcpAuthToken!.length).toBeGreaterThanOrEqual(32) + }) + it('establishes a stateful session and lists agent tools', async () => { const started = await boot() - const transport = new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`)) + const transport = authTransport(started) const client = new Client({ name: 'test-client', version: '0.0.0' }) try { await client.connect(transport) @@ -88,11 +100,13 @@ describe('mcp adapter (streamable http route)', () => { // Initialize over raw HTTP to capture the issued session id from the // response header (the body is an SSE stream we can discard). + const authHeader = { Authorization: `Bearer ${started.mcpAuthToken}` } const init = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', + ...authHeader, }, body: JSON.stringify({ jsonrpc: '2.0', @@ -108,7 +122,7 @@ describe('mcp adapter (streamable http route)', () => { // DELETE ends the session. const del = await fetch(url, { method: 'DELETE', - headers: { 'mcp-session-id': sessionId! }, + headers: { 'mcp-session-id': sessionId!, ...authHeader }, }) await del.body?.cancel() expect(del.status).toBeLessThan(300) @@ -121,6 +135,7 @@ describe('mcp adapter (streamable http route)', () => { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', 'mcp-session-id': sessionId!, + ...authHeader, }, body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }), }) @@ -128,6 +143,33 @@ describe('mcp adapter (streamable http route)', () => { expect(stale.status).toBe(404) }) + it('rejects a request with a missing or invalid bearer token', async () => { + const started = await boot() + const url = `${started.origin}/__mcp` + const body = JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, + }) + const headers = { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream' } + + // No Authorization header — the origin gate passes (loopback / Origin-less) + // but the bearer gate rejects. + const missing = await fetch(url, { method: 'POST', headers, body }) + await missing.body?.cancel() + expect(missing.status).toBe(401) + + // Wrong token. + const wrong = await fetch(url, { + method: 'POST', + headers: { ...headers, Authorization: 'Bearer not-the-real-token' }, + body, + }) + await wrong.body?.cancel() + expect(wrong.status).toBe(401) + }) + it('rejects a disallowed cross-origin request', async () => { const started = await boot() const res = await fetch(`${started.origin}/__mcp`, { diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts index e192c185..62694661 100644 --- a/packages/devframe/src/adapters/mcp/fetch.ts +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -2,6 +2,7 @@ import type { DevframeNodeContext } from 'devframe/types' import { randomUUID } from 'node:crypto' import { isInitializeRequest, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server' import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' +import { timingSafeEqual } from 'devframe/utils/crypto-token' import { buildMcpServerFromContext } from './build-server' export interface CreateMcpFetchHandlerOptions { @@ -16,6 +17,17 @@ export interface CreateMcpFetchHandlerOptions { * origin gate entirely. Default: loopback-only (mirrors the WS transport). */ allowedOrigins?: readonly string[] | false + /** + * Bearer token every request must present as `Authorization: Bearer + * `. This is the endpoint's real authentication: the origin gate + * only ever constrains browsers (a non-browser client can omit or spoof the + * `Origin` header), so without a token any local process could reach every + * tool. Callers that expose the route mint a high-entropy token and hand it + * to trusted clients out-of-band (devframe records it in the instance + * registry so `devframe connect` can present it). Leave unset only for a + * transport that is already authenticated by other means. + */ + authToken?: string } export interface McpFetchHandler { @@ -44,9 +56,12 @@ interface McpSession { * and MCP server (built from the shared, live `ctx` via * `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header: an * `initialize` POST spins up a session; later requests route to it; a `DELETE` - * (or client disconnect) tears it down. The origin gate applies devframe's - * loopback-default DNS-rebinding protection (identical semantics to the WS - * upgrade's `isAllowedOrigin`). + * (or client disconnect) tears it down. Two gates guard every request: the + * origin gate applies devframe's loopback-default DNS-rebinding protection + * (identical semantics to the WS upgrade's `isAllowedOrigin`, and only ever + * constrains browsers), and — when {@link CreateMcpFetchHandlerOptions.authToken} + * is set — a constant-time `Authorization: Bearer` check that is the endpoint's + * real authentication for non-browser clients. * * @experimental */ @@ -56,6 +71,22 @@ export function createMcpFetchHandler( ): McpFetchHandler { const sessions = new Map() const allowedOrigins = options.allowedOrigins + const authToken = options.authToken + + /** + * Constant-time check of the request's `Authorization: Bearer ` + * against the expected token. Returns `true` when no token is configured + * (the caller opted out of endpoint auth). + */ + function isAuthorized(req: Request): boolean { + if (!authToken) + return true + const header = req.headers.get('authorization') ?? '' + const prefix = 'bearer ' + if (header.slice(0, prefix.length).toLowerCase() !== prefix) + return false + return timingSafeEqual(header.slice(prefix.length).trim(), authToken) + } function drop(sessionId: string): void { const session = sessions.get(sessionId) @@ -112,6 +143,11 @@ export function createMcpFetchHandler( if (allowedOrigins !== false && !isAllowedOrigin(origin, allowedOrigins ?? [])) return new Response('Forbidden: origin not allowed', { status: 403 }) + // Bearer-token gate — the actual authentication (see `authToken` above). + // The origin gate above is defense-in-depth against browsers only. + if (!isAuthorized(req)) + return new Response('Unauthorized: missing or invalid bearer token', { status: 401 }) + const sessionId = req.headers.get('mcp-session-id') ?? undefined let session = sessionId ? sessions.get(sessionId) : undefined diff --git a/packages/devframe/src/cli/connect.ts b/packages/devframe/src/cli/connect.ts index 0bd96a18..12e90bbd 100644 --- a/packages/devframe/src/cli/connect.ts +++ b/packages/devframe/src/cli/connect.ts @@ -166,7 +166,7 @@ async function index(sdk: ConnectSdk, options: ConnectServerOptions): Promise { - return withInstanceClient(sdk, url, async (client) => { +async function listInstanceTools(sdk: ConnectSdk, url: string, token?: string): Promise<{ name: string, description?: string }[]> { + return withInstanceClient(sdk, url, token, async (client) => { const listed = await client.listTools() return listed.tools.map((tool: { name: string, description?: string }) => ({ name: tool.name, @@ -233,7 +233,7 @@ async function call( throw diagnostics.DF0051({ port: args.port }) const url = `${record.origin}${record.mcp.path}` - return withInstanceClient(sdk, url, async (client) => { + return withInstanceClient(sdk, url, record.mcp.token, async (client) => { const result = await client.callTool({ name: args.tool!, arguments: args.args ?? {} }) return { instance: { id: record.id, port: record.port }, @@ -248,9 +248,15 @@ async function call( async function withInstanceClient( sdk: ConnectSdk, url: string, + token: string | undefined, fn: (client: InstanceType) => Promise, ): Promise { - const transport = new sdk.StreamableHTTPClientTransport(new URL(url)) + // Present the instance's MCP bearer token (recorded in the registry) so the + // route's `Authorization: Bearer` gate accepts this Origin-less client. + const transport = new sdk.StreamableHTTPClientTransport( + new URL(url), + token ? { requestInit: { headers: { Authorization: `Bearer ${token}` } } } : undefined, + ) const client = new sdk.Client({ name: 'devframe-connect', version: '0.0.0' }) await client.connect(transport) try { diff --git a/packages/devframe/src/client/__tests__/otp.test.ts b/packages/devframe/src/client/__tests__/otp.test.ts index f2eba6ad..fcd01e82 100644 --- a/packages/devframe/src/client/__tests__/otp.test.ts +++ b/packages/devframe/src/client/__tests__/otp.test.ts @@ -6,39 +6,54 @@ afterEach(() => { }) describe('otp url helpers', () => { - it('reads the OTP from the page URL query string (default param)', () => { - vi.stubGlobal('location', { search: '?devframe_otp=123456&x=1', href: 'http://localhost:3000/?devframe_otp=123456&x=1' }) + it('reads the OTP from the page URL fragment (default param)', () => { + vi.stubGlobal('location', { hash: '#devframe_otp=123456&x=1', href: 'http://localhost:3000/#devframe_otp=123456&x=1' }) expect(readOtpFromUrl()).toBe('123456') }) it('supports a custom param name', () => { - vi.stubGlobal('location', { search: '?code=999', href: 'http://localhost:3000/?code=999' }) + vi.stubGlobal('location', { hash: '#code=999', href: 'http://localhost:3000/#code=999' }) expect(readOtpFromUrl('code')).toBe('999') }) it('returns undefined when the param is absent and is safe without location', () => { - vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' }) + vi.stubGlobal('location', { hash: '#x=1', href: 'http://localhost:3000/#x=1' }) expect(readOtpFromUrl()).toBeUndefined() vi.stubGlobal('location', undefined) expect(readOtpFromUrl()).toBeUndefined() expect(() => consumeOtpFromUrl()).not.toThrow() }) + it('ignores an OTP left in the query string (fragment-only)', () => { + vi.stubGlobal('location', { hash: '', search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' }) + expect(readOtpFromUrl()).toBeUndefined() + }) + it('consume reads then strips the OTP via history.replaceState, keeping other params', () => { const replaceState = vi.fn() - vi.stubGlobal('location', { search: '?devframe_otp=123456&x=1', href: 'http://localhost:3000/?devframe_otp=123456&x=1' }) + vi.stubGlobal('location', { hash: '#devframe_otp=123456&x=1', href: 'http://localhost:3000/#devframe_otp=123456&x=1' }) vi.stubGlobal('history', { state: { a: 1 }, replaceState }) expect(consumeOtpFromUrl()).toBe('123456') expect(replaceState).toHaveBeenCalledTimes(1) const [state, , href] = replaceState.mock.calls[0] expect(state).toEqual({ a: 1 }) - expect(href).toBe('http://localhost:3000/?x=1') + expect(href).toBe('http://localhost:3000/#x=1') + }) + + it('clears the fragment entirely when the OTP was its only param', () => { + const replaceState = vi.fn() + vi.stubGlobal('location', { hash: '#devframe_otp=123456', href: 'http://localhost:3000/#devframe_otp=123456' }) + vi.stubGlobal('history', { state: null, replaceState }) + + expect(consumeOtpFromUrl()).toBe('123456') + const [, , href] = replaceState.mock.calls[0] + expect(href).toBe('http://localhost:3000/') }) it('does not touch the URL when no OTP is present', () => { const replaceState = vi.fn() - vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' }) + vi.stubGlobal('location', { hash: '#x=1', href: 'http://localhost:3000/#x=1' }) vi.stubGlobal('history', { state: null, replaceState }) expect(consumeOtpFromUrl()).toBeUndefined() @@ -48,7 +63,7 @@ describe('otp url helpers', () => { describe('authenticateWithUrlOtp', () => { it('exchanges the OTP via the client and resolves true on success', async () => { - vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' }) + vi.stubGlobal('location', { hash: '#devframe_otp=123456', href: 'http://localhost:3000/#devframe_otp=123456' }) vi.stubGlobal('history', { state: null, replaceState: vi.fn() }) const requestTrustWithCode = vi.fn().mockResolvedValue(true) @@ -59,7 +74,7 @@ describe('authenticateWithUrlOtp', () => { }) it('returns false (and does not exchange) when no OTP is present', async () => { - vi.stubGlobal('location', { search: '', href: 'http://localhost:3000/' }) + vi.stubGlobal('location', { hash: '', href: 'http://localhost:3000/' }) const requestTrustWithCode = vi.fn() const ok = await authenticateWithUrlOtp({ isTrusted: false, requestTrustWithCode }) @@ -70,7 +85,7 @@ describe('authenticateWithUrlOtp', () => { it('skips the exchange but still consumes the OTP when already trusted', async () => { const replaceState = vi.fn() - vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' }) + vi.stubGlobal('location', { hash: '#devframe_otp=123456', href: 'http://localhost:3000/#devframe_otp=123456' }) vi.stubGlobal('history', { state: null, replaceState }) const requestTrustWithCode = vi.fn() diff --git a/packages/devframe/src/client/otp.ts b/packages/devframe/src/client/otp.ts index ee7092fa..a300cac2 100644 --- a/packages/devframe/src/client/otp.ts +++ b/packages/devframe/src/client/otp.ts @@ -2,17 +2,21 @@ import type { DevframeRpcClient } from './rpc' import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants' // Browser-only helpers for "magic link" authentication: a host prints a URL -// carrying a one-time authentication code (OTP), and the client reads it, -// exchanges it for a token, and removes it from the address bar. Only the -// short-lived, single-use OTP ever rides the URL — never the resulting token. +// carrying a one-time authentication code (OTP) in its fragment, and the client +// reads it, exchanges it for a token, and removes it from the address bar. Only +// the short-lived, single-use OTP ever rides the URL — never the resulting +// token — and it rides the fragment (`#devframe_otp=…`), which the browser +// never sends to the server, so it can't leak into an access log or `Referer`. /** - * Read a one-time authentication code (OTP) from the current page URL's query - * string, without side effects. Returns `undefined` when the parameter is absent. + * Read a one-time authentication code (OTP) from the current page URL's + * fragment, without side effects. Returns `undefined` when the parameter is + * absent. */ export function readOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined { try { - return new URLSearchParams(globalThis.location?.search).get(param) || undefined + const hash = globalThis.location?.hash?.replace(/^#/, '') ?? '' + return new URLSearchParams(hash).get(param) || undefined } catch { return undefined @@ -22,19 +26,22 @@ export function readOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | function stripParamFromUrl(param: string): void { try { const url = new URL(globalThis.location!.href) - if (!url.searchParams.has(param)) + const fragment = new URLSearchParams(url.hash.replace(/^#/, '')) + if (!fragment.has(param)) return - url.searchParams.delete(param) + fragment.delete(param) + // An empty fragment clears the `#` entirely rather than leaving a bare one. + url.hash = fragment.toString() globalThis.history?.replaceState(globalThis.history.state, '', url.href) } catch {} } /** - * Read the one-time code from the page URL and remove it from the address bar - * (and the current history entry), so the single-use code isn't left in the - * URL, browser history, or a `Referer`. Returns the code, or `undefined` when - * absent. + * Read the one-time code from the page URL fragment and remove it from the + * address bar (and the current history entry), so the single-use code isn't + * left in the URL, browser history, or a `Referer`. Returns the code, or + * `undefined` when absent. */ export function consumeOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined { const code = readOtpFromUrl(param) diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index 90ef82e0..9fc90d74 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -41,11 +41,14 @@ export const DEVFRAME_RPC_DUMP_DIRNAME = '__rpc-dump' export const REMOTE_CONNECTION_KEY = 'devframe-remote-connection' /** - * Page-URL query parameter carrying a one-time authentication code (OTP) for - * "magic link" auth. A host can print a link like `/?devframe_otp=`; - * the client reads the code, exchanges it for a token, and strips the parameter - * from the URL. See `buildOtpAuthUrl` (node) and the `authenticateWithUrlOtp` / - * `consumeOtpFromUrl` client utilities (or `connectDevframe`'s `otpParam`). + * Page-URL **fragment** parameter carrying a one-time authentication code (OTP) + * for "magic link" auth. A host can print a link like + * `/#devframe_otp=`; the client reads the code, exchanges it for a + * token, and strips the parameter from the URL. The code rides the fragment + * (never the query string) so the browser never transmits it to the server, + * keeping it out of access logs and `Referer` headers. See `buildOtpAuthUrl` + * (node) and the `authenticateWithUrlOtp` / `consumeOtpFromUrl` client utilities + * (or `connectDevframe`'s `otpParam`). */ export const DEVFRAME_OTP_URL_PARAM = 'devframe_otp' diff --git a/packages/devframe/src/helpers/__tests__/vite.test.ts b/packages/devframe/src/helpers/__tests__/vite.test.ts index 42267ab6..d2da703a 100644 --- a/packages/devframe/src/helpers/__tests__/vite.test.ts +++ b/packages/devframe/src/helpers/__tests__/vite.test.ts @@ -1,7 +1,13 @@ import type { DevframeDefinition } from '../../types/devframe' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' +import { createRpcClient } from 'devframe/rpc/client' +import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { readDevframeInstances } from '../../node/instance-registry' import { viteDevBridge } from '../vite' function defineTestDef(): DevframeDefinition { @@ -54,13 +60,26 @@ describe('viteDevBridge (bridge mode mcp)', () => { afterEach(async () => { await bridge?.closeBundle?.() bridge = undefined + vi.unstubAllEnvs() }) it('forwards the mcp option and advertises the side-car endpoint in the meta', async () => { + // Point the instance registry at a temp dir so we can read back the + // per-instance MCP bearer token the side-car minted (never advertised in + // the meta) and present it, exactly as `devframe connect` does. + const registryDir = mkdtempSync(join(tmpdir(), 'df-vite-bridge-registry-')) + vi.stubEnv('DEVFRAME_INSTANCES_DIR', registryDir) + // The test harness disables the registry globally; re-enable it here so the + // side-car writes the record carrying the MCP token. + vi.stubEnv('DEVFRAME_DISABLE_INSTANCE_REGISTRY', '0') + const port = await getPort({ port: 19710, host: '127.0.0.1' }) bridge = viteDevBridge(defineTestDef(), { devMiddleware: { port, host: '127.0.0.1' }, mcp: true, + // The bridge now gates by default; opt out here so this test can dial + // the WS/MCP side-car directly. (MCP still requires its bearer token.) + auth: false, }) const server = fakeViteServer() @@ -72,10 +91,17 @@ describe('viteDevBridge (bridge mode mcp)', () => { expect(meta.backend).toBe('websocket') expect(meta.websocket).toEqual({ port, path: '/__devframe_ws' }) expect(meta.mcp).toEqual({ port, path: '/__mcp' }) + // The token is never advertised in the meta. + expect(meta.mcp.token).toBeUndefined() + + const token = readDevframeInstances({ instancesDir: registryDir }).find(r => r.port === port)?.mcp?.token + expect(token).toBeTypeOf('string') - // The advertised endpoint is live: a real MCP client can connect and - // list the agent tools on the side-car origin. - const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/__mcp`)) + // The advertised endpoint is live: a real MCP client presenting the + // registry-recorded bearer token can connect and list the agent tools. + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/__mcp`), { + requestInit: { headers: { Authorization: `Bearer ${token}` } }, + }) const client = new Client({ name: 'test-client', version: '0.0.0' }) try { await client.connect(transport) @@ -91,6 +117,7 @@ describe('viteDevBridge (bridge mode mcp)', () => { const port = await getPort({ port: 19720, host: '127.0.0.1' }) bridge = viteDevBridge(defineTestDef(), { devMiddleware: { port, host: '127.0.0.1' }, + auth: false, }) const server = fakeViteServer() @@ -100,3 +127,44 @@ describe('viteDevBridge (bridge mode mcp)', () => { expect(meta.mcp).toBeUndefined() }) }) + +describe('viteDevBridge (auth default)', () => { + let bridge: ReturnType | undefined + + afterEach(async () => { + await bridge?.closeBundle?.() + bridge = undefined + }) + + /** Handshake result on a fresh, unauthenticated WS connection. */ + async function handshakeIsTrusted(port: number): Promise { + const rpc = createRpcClient({}, { + channel: createWsRpcChannel({ url: `ws://127.0.0.1:${port}/__devframe_ws` }), + }) + try { + const res = await rpc.$call('anonymous:devframe:auth', { authToken: '', ua: 'test', origin: 'http://localhost' }) as { isTrusted: boolean } + return res.isTrusted + } + finally { + rpc.$close?.() + } + } + + it('gates the side-car by default (unset auth → untrusted handshake)', async () => { + const port = await getPort({ port: 19730, host: '127.0.0.1' }) + bridge = viteDevBridge(defineTestDef(), { devMiddleware: { port, host: '127.0.0.1' } }) + await bridge.configureServer(fakeViteServer()) + + // A gated server answers the handshake with `isTrusted: false` until a + // code is exchanged; an ungated (`auth: false`) server auto-trusts. + expect(await handshakeIsTrusted(port)).toBe(false) + }) + + it('opts out when auth: false is passed explicitly (auto-trust handshake)', async () => { + const port = await getPort({ port: 19740, host: '127.0.0.1' }) + bridge = viteDevBridge(defineTestDef(), { devMiddleware: { port, host: '127.0.0.1' }, auth: false }) + await bridge.configureServer(fakeViteServer()) + + expect(await handshakeIsTrusted(port)).toBe(true) + }) +}) diff --git a/packages/devframe/src/helpers/vite.ts b/packages/devframe/src/helpers/vite.ts index 7c59ed38..68f64f4a 100644 --- a/packages/devframe/src/helpers/vite.ts +++ b/packages/devframe/src/helpers/vite.ts @@ -38,16 +38,17 @@ export interface ViteDevBridgeOptions { flags?: Record } /** - * Whether the bridged devframe runs its own auth gate. This is a **hosted** - * adapter — the devframe shares the host app's origin and the host owns - * authentication — so it defaults to `false`: the plugin's own gate never - * fires and its `cli.auth` default is ignored (matching devframe's - * hosted-deployment contract). Pass `true` to force devframe's interactive - * OTP gate on, or a {@link DevframeAuthHandler} to install a custom scheme. - * Only applies in bridge mode (`devMiddleware`); the static-mount mode + * Whether the bridged devframe runs its own auth gate. The side-car RPC + * server is reachable by anything that can open its socket, so it **gates by + * default**: when unset, authentication resolves through `createDevServer` + * (devframe's interactive OTP gate unless the definition's `cli.auth` opts + * out), and the side-car prints its code/link banner to stdout. Pass a + * {@link DevframeAuthHandler} to install a custom scheme, or `false` to opt + * out for a single-user localhost host that owns the trust boundary another + * way. Only applies in bridge mode (`devMiddleware`); the static-mount mode * starts no RPC server. * - * @default false + * @default gated (devframe's interactive OTP, unless `cli.auth` opts out) */ auth?: boolean | DevframeAuthHandler /** @@ -89,11 +90,11 @@ export interface DevframeVitePlugin { * host-served SPA can discover the WS endpoint via * {@link connectDevframe}. * - * As a hosted adapter the bridge defers authentication to the host: its - * side-car RPC server runs with the plugin's own auth gate **off** by - * default (ignoring `def.cli?.auth`), so a plugin mounted this way never - * triggers its standalone OTP prompt. Opt back in per-mount with - * `options.auth` (`true` for devframe's interactive gate, or a handler). + * The side-car RPC server **gates by default** (devframe's interactive OTP + * unless the definition's `cli.auth` opts out), printing its code/link banner + * to stdout, so a bridged devframe isn't silently reachable by anything that + * can open its socket. Pass `options.auth: false` to opt out for a single-user + * localhost host, or a {@link DevframeAuthHandler} for a custom scheme. * * Use bridge mode when integrating with frameworks that own the SPA * (Nuxt, Astro, SolidStart, plain Vite apps). For the all-in-one @@ -136,9 +137,10 @@ export function viteDevBridge(d: DevframeDefinition, options: ViteDevBridgeOptio port, flags: mw.flags, openBrowser: false, - // Hosted adapter: the host owns auth, so the bridged devframe's own - // gate stays off unless the caller explicitly opts back in. - auth: options.auth ?? false, + // Gate by default: an unset `auth` defers to `createDevServer` + // (devframe's interactive OTP unless `cli.auth` opts out) rather than + // leaving the side-car socket ungated. `false` opts out explicitly. + auth: options.auth, mcp: options.mcp, }) } diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts index 00a8e099..823a7ed8 100644 --- a/packages/devframe/src/node/auth/state.ts +++ b/packages/devframe/src/node/auth/state.ts @@ -45,14 +45,21 @@ export function refreshTempAuthCode(): string { } /** - * Build a "magic link" authentication URL that embeds a one-time code (OTP) as - * a query parameter. Opening it authenticates the client without typing — print - * it on startup (devframe stays headless, so the host prints its own banner). - * Defaults to the current code; the link is subject to the same TTL. + * Build a "magic link" authentication URL that embeds a one-time code (OTP) in + * the URL **fragment**. Opening it authenticates the client without typing — + * print it on startup (devframe stays headless, so the host prints its own + * banner). Defaults to the current code; the link is subject to the same TTL. + * + * The code rides the fragment (`#devframe_otp=…`), not the query string, so it + * is never sent to the server, written to an access log, or leaked in a + * `Referer` header — the browser client reads it locally (see + * `consumeOtpFromUrl`). Any existing fragment parameters are preserved. */ export function buildOtpAuthUrl(baseUrl: string, code: string = tempAuthCode): string { const url = new URL(baseUrl) - url.searchParams.set(DEVFRAME_OTP_URL_PARAM, code) + const fragment = new URLSearchParams(url.hash.replace(/^#/, '')) + fragment.set(DEVFRAME_OTP_URL_PARAM, code) + url.hash = fragment.toString() return url.href } diff --git a/packages/devframe/src/node/instance-registry.ts b/packages/devframe/src/node/instance-registry.ts index 3e793337..cd632cb8 100644 --- a/packages/devframe/src/node/instance-registry.ts +++ b/packages/devframe/src/node/instance-registry.ts @@ -27,10 +27,13 @@ export interface DevframeInstanceRecord { /** Working directory the instance was started from. */ rootDir: string /** - * Absolute URL path of the MCP Streamable-HTTP endpoint on `origin`, or - * `null` when the instance runs without an MCP route. + * The MCP Streamable-HTTP endpoint on `origin`, or `null` when the instance + * runs without an MCP route. `token` is the bearer credential the endpoint + * requires (`Authorization: Bearer `); it lives only in this + * user-private registry file (written mode `0600`) so local discovery tools + * like `devframe connect` can present it, and is never advertised over HTTP. */ - mcp: { path: string } | null + mcp: { path: string, token?: string } | null /** Epoch-ms timestamp of registration. */ startedAt: number } @@ -101,12 +104,16 @@ export function registerDevframeInstance( if (!isRegistryDisabled()) { try { - mkdirSync(dir, { recursive: true }) + // The record can carry the MCP bearer token, so keep the directory and + // file readable only by the owner (`0700`/`0600`) — the token is a + // secret shared out-of-band with local discovery tools, never a + // world-readable value. + mkdirSync(dir, { recursive: true, mode: 0o700 }) // Atomic publish: write a temp file *in the same directory* (a rename // is only atomic — and only possible — within one filesystem), then // rename into place. const tmp = join(dir, `.${record.pid}-${record.port}.${Date.now()}.tmp`) - writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`) + writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }) renameSync(tmp, file) } catch (error) { diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index bd60745a..633d2998 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -127,6 +127,14 @@ export interface StartedServer { * registered on `context.rpc`. */ connectionMeta: () => ConnectionMeta + /** + * Bearer token required on the MCP Streamable-HTTP route, when one is + * mounted (set by {@link createDevServer} once it enables the route). + * `undefined` when the instance runs without MCP. A host that needs to + * present or forward the token (tests, a custom launcher) reads it here; + * it is never advertised over HTTP. + */ + mcpAuthToken?: string close: () => Promise } diff --git a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts index 29ffe0a8..84f6994b 100644 --- a/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts +++ b/packages/devframe/src/recipes/__tests__/interactive-auth.test.ts @@ -8,6 +8,7 @@ import { getPort } from 'get-port-please' import { describe, expect, it } from 'vitest' import { getTempAuthCode } from '../../node/auth/state' import { createHostContext } from '../../node/context' +import { getInternalContext } from '../../node/hub-internals/context' import { startHttpAndWs } from '../../node/server' import { createInteractiveAuth } from '../interactive-auth' @@ -150,6 +151,65 @@ describe('recipes/interactive-auth', () => { } }) + describe('remote-dock tokens (onConnect)', () => { + // Minimal stand-in for the crossws `Peer` — `onConnect` only reads + // `request.url` and `request.headers.get('origin')`. + function fakePeer(token: string, origin?: string): any { + return { + request: { + url: `/?devframe_auth_token=${encodeURIComponent(token)}`, + headers: { get: (name: string) => (name.toLowerCase() === 'origin' ? origin ?? null : null) }, + }, + } + } + + it('trusts a valid remote-dock token when originLock is off, regardless of origin', async () => { + const context = await createTestContext() + const auth = createInteractiveAuth(context) + const token = getInternalContext(context).allocateRemoteToken('dock-1', 'http://localhost:5173', false) + + const session = { meta: {} } as any + auth.onConnect(fakePeer(token, 'http://anywhere.example'), session) + expect(session.meta.isTrusted).toBe(true) + expect(session.meta.clientAuthToken).toBe(token) + }) + + it('honors originLock: trusts only when the request Origin matches the dock origin', async () => { + const context = await createTestContext() + const auth = createInteractiveAuth(context) + const dockOrigin = 'http://localhost:5173' + const token = getInternalContext(context).allocateRemoteToken('dock-2', dockOrigin, true) + + const matching = { meta: {} } as any + auth.onConnect(fakePeer(token, dockOrigin), matching) + expect(matching.meta.isTrusted).toBe(true) + + const mismatched = { meta: {} } as any + auth.onConnect(fakePeer(token, 'http://evil.example'), mismatched) + expect(mismatched.meta.isTrusted).toBeUndefined() + + const noOrigin = { meta: {} } as any + auth.onConnect(fakePeer(token), noOrigin) + expect(noOrigin.meta.isTrusted).toBeUndefined() + }) + + it('does not trust an unknown or revoked remote token', async () => { + const context = await createTestContext() + const auth = createInteractiveAuth(context) + const internal = getInternalContext(context) + const token = internal.allocateRemoteToken('dock-3', 'http://localhost:5173', false) + internal.revokeRemoteTokensForDock('dock-3') + + const session = { meta: {} } as any + auth.onConnect(fakePeer(token, 'http://localhost:5173'), session) + expect(session.meta.isTrusted).toBeUndefined() + + const unknown = { meta: {} } as any + auth.onConnect(fakePeer('deadbeef', 'http://localhost:5173'), unknown) + expect(unknown.meta.isTrusted).toBeUndefined() + }) + }) + it('self-revoke: devframe:auth:revoke drops the caller to untrusted and invalidates the token', async () => { const { server, host, port } = await startAuthenticatedServer() diff --git a/packages/devframe/src/recipes/interactive-auth.ts b/packages/devframe/src/recipes/interactive-auth.ts index 4af42d98..213555f2 100644 --- a/packages/devframe/src/recipes/interactive-auth.ts +++ b/packages/devframe/src/recipes/interactive-auth.ts @@ -149,13 +149,21 @@ export function createInteractiveAuth( return !!session.meta.isTrusted } - function onConnect(peer: { request?: { url: string } }, session: DevframeNodeRpcSession): void { + function onConnect( + peer: { request?: { url?: string, headers?: { get?: (name: string) => string | null } } }, + session: DevframeNodeRpcSession, + ): void { let token: string | undefined + let requestOrigin: string | undefined try { const url = new URL(peer.request?.url ?? '', 'http://localhost') token = url.searchParams.get(DEVFRAME_AUTH_TOKEN_QUERY_PARAM) ?? undefined } catch {} + try { + requestOrigin = peer.request?.headers?.get?.('origin') ?? undefined + } + catch {} if (!token) return if (isStaticToken(token)) { @@ -163,7 +171,17 @@ export function createInteractiveAuth( session.meta.isTrusted = true return } - verifyAuthToken(token, session, storage) + // A persisted bearer minted by the code exchange (returning browser). + if (verifyAuthToken(token, session, storage)) + return + // A session-only remote-UI dock token (see `allocateRemoteToken`). These + // never enter the persisted store, so `verifyAuthToken` can't see them — + // check them here so a remote dock's iframe actually authenticates, and so + // `originLock` binds the token to the dock's recorded origin. + if (internal.isRemoteTokenTrusted(token, requestOrigin)) { + session.meta.clientAuthToken = token + session.meta.isTrusted = true + } } function buildOpenUrl(url: string): string { diff --git a/packages/next/src/handler.ts b/packages/next/src/handler.ts index 7856f4d4..bf2bc317 100644 --- a/packages/next/src/handler.ts +++ b/packages/next/src/handler.ts @@ -22,9 +22,11 @@ export interface CreateDevframeNextHandlerOptions { /** Flag bag forwarded to `def.setup(ctx, { flags })`. */ flags?: Record /** - * Whether the side-car runs its own auth gate. Defaults to `false`: this is a - * hosted adapter and the Next app owns authentication. Pass `true` for - * devframe's interactive gate or a handler for a custom scheme. + * Whether the side-car runs its own auth gate. **Gates by default** (defers + * to `createDevServer` — devframe's interactive OTP unless the definition's + * `cli.auth` opts out), so the side-car socket isn't silently reachable by + * anything that can open it. Pass `false` to opt out for a single-user + * localhost host, or a handler for a custom scheme. */ auth?: CreateDevServerOptions['auth'] /** Origin the Next app is reachable at, for docks needing an absolute URL. */ @@ -53,6 +55,14 @@ export interface DevframeNextHandler { fetch: (request: Request) => Promise /** Resolves once the side-car RPC/WS server is listening. */ ready: Promise + /** + * Bearer token the side-car's MCP route requires (`Authorization: Bearer + * `), or `undefined` when no MCP route is mounted. Available after + * {@link DevframeNextHandler.ready} resolves; recorded in the instance + * registry for `devframe connect`, and read here by a caller that dials the + * endpoint directly. + */ + readonly mcpAuthToken?: string /** Shut the side-car server down (call from an app-lifecycle hook / test). */ close: () => Promise } @@ -127,7 +137,9 @@ export function createDevframeNextHandler( port, flags: options.flags, openBrowser: false, - auth: options.auth ?? false, + // Gate by default: an unset `auth` defers to `createDevServer` rather + // than leaving the side-car socket ungated. `false` opts out explicitly. + auth: options.auth, mcp: options.mcp, }) const mcpMeta = resolveMcpConnectionMeta(def, options.mcp, port) @@ -144,6 +156,9 @@ export function createDevframeNextHandler( return nextHost.fetch(request) }, ready, + get mcpAuthToken() { + return started?.mcpAuthToken + }, async close() { await ready.catch(() => {}) await started?.close() diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index ff771ebf..f6f0d3de 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -1,5 +1,6 @@ import type { ConnectionMeta, DevframeHost, DevframeNodeContext, DevframeStorageScope } from 'devframe/types' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' +import { randomToken } from 'devframe/utils/crypto-token' import { serveStaticHandler } from 'devframe/utils/serve-static' import { H3 } from 'h3' @@ -36,6 +37,14 @@ export interface DevframeNextHostMcpOptions { * origin gate entirely. */ allowedOrigins?: readonly string[] | false + /** + * Bearer token the endpoint requires as `Authorization: Bearer ` — + * the route's real authentication (the origin gate only ever constrains + * browsers). When omitted a high-entropy token is minted and returned by + * {@link DevframeNextHost.mountMcp}; record it in the instance registry (see + * `registerDevframeInstance`) so `devframe connect` can present it. + */ + authToken?: string } export interface DevframeNextHost { @@ -72,7 +81,8 @@ export interface DevframeNextHost { * `devframe/adapters/mcp` (imported lazily: `@modelcontextprotocol/server` * stays an optional peer). Advertise the path in the connection meta * (`mcp: { path }` — same origin, no port) and register the instance via - * `registerDevframeInstance` so `devframe connect` can discover it. + * `registerDevframeInstance` (with the returned `authToken` in its `mcp` + * record) so `devframe connect` can discover it and present the token. * * @experimental */ @@ -80,7 +90,7 @@ export interface DevframeNextHost { ctx: DevframeNodeContext, path: string, options?: DevframeNextHostMcpOptions, - ) => Promise<{ dispose: () => Promise }> + ) => Promise<{ dispose: () => Promise, authToken: string }> } const META_SUFFIX = `/${DEVFRAME_CONNECTION_META_FILENAME}` @@ -165,11 +175,16 @@ export function createDevframeNextHost( }, async mountMcp(ctx, path, mcpOptions = {}) { const { createMcpFetchHandler } = await import('devframe/adapters/mcp') + // The route requires a bearer token (the origin gate only constrains + // browsers). Mint one when the caller doesn't supply it, and return it + // so the host can record it in the instance registry for `devframe connect`. + const authToken = mcpOptions.authToken ?? randomToken() const handler = createMcpFetchHandler(ctx, { serverName: mcpOptions.serverName ?? 'devframe (next)', serverVersion: mcpOptions.serverVersion ?? '0.0.0', exposeSharedState: mcpOptions.exposeSharedState ?? true, allowedOrigins: mcpOptions.allowedOrigins, + authToken, }) const key = stripTrailingSlash(path) mcpMounts.set(key, handler) @@ -178,6 +193,7 @@ export function createDevframeNextHost( mcpMounts.delete(key) await handler.dispose() }, + authToken, } }, } diff --git a/packages/next/test/handler.test.ts b/packages/next/test/handler.test.ts index 00147755..a7df1712 100644 --- a/packages/next/test/handler.test.ts +++ b/packages/next/test/handler.test.ts @@ -78,12 +78,14 @@ describe('createDevframeNextHandler', () => { } expect(body.mcp).toEqual({ port: body.websocket.port, path: '/__mcp' }) - // The advertised endpoint answers MCP initialize on the side-car origin. + // The advertised endpoint answers MCP initialize on the side-car origin + // once the required bearer token is presented. const init = await fetch(`http://127.0.0.1:${body.mcp!.port}${body.mcp!.path}`, { method: 'POST', headers: { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', + 'Authorization': `Bearer ${handler.mcpAuthToken}`, }, body: JSON.stringify({ jsonrpc: '2.0', @@ -95,5 +97,22 @@ describe('createDevframeNextHandler', () => { expect(init.status).toBe(200) expect(init.headers.get('mcp-session-id')).toBeTruthy() await init.body?.cancel() + + // Without the token the same request is rejected. + const unauthed = await fetch(`http://127.0.0.1:${body.mcp!.port}${body.mcp!.path}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, + }), + }) + await unauthed.body?.cancel() + expect(unauthed.status).toBe(401) }) }) diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 5bbc5c80..386c8efb 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -569,10 +569,11 @@ RPC handlers run with the full privileges of the host process, so the boundary t - **`auth` defaults to `true`** — dev-mode connections must authenticate before calls are accepted. Devframe ships the node primitives (`exchangeTempAuthCode`, `verifyAuthToken` in `devframe/node/auth`); the host adapter (e.g. Vite DevTools) provides the interactive `devframe:anonymous:auth` + `devframe:auth:exchange` handlers and auth UI. - **`auth: false` trusts every reachable connection.** Use it only for single-user `localhost` tools. Never combine it with a non-loopback bind host, a tunnel, or a shared/CI environment. The default bind host is already `localhost`. - **Authentication** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network. -- **Magic-link (optional):** print `buildOtpAuthUrl(origin)` — `/?devframe_otp=`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. The standalone CLI's `--open` does this automatically via `DevframeAuthHandler.buildOpenUrl` — the launched tab already carries the OTP, no prompt needed. +- **Magic-link (optional):** print `buildOtpAuthUrl(origin)` — `/#devframe_otp=`. The code rides the URL **fragment**, which the browser never sends to the server, so it stays out of access logs and `Referer`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. The standalone CLI's `--open` does this automatically via `DevframeAuthHandler.buildOpenUrl` — the launched tab already carries the OTP, no prompt needed. - **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`. - **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first. -- **Origin-lock remote docks** (`originLock`) so a dock token is honored only from its expected origin. +- **Origin-lock remote docks** (`originLock`, on by default) so a dock's session token is honored only on a connection whose `Origin` matches the dock — the connect-time gate enforces it. +- **The MCP route needs a token.** The route-based MCP server (`cli.mcp`, `viteDevBridge`/Next handler `mcp`, `createMcpFetchHandler`'s `authToken`) requires `Authorization: Bearer ` — the origin gate only constrains browsers. The dev server mints one per instance and records it (registry file, mode `0600`) so `devframe connect` presents it automatically. See [Security](https://devfra.me/security) for the full reference. diff --git a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts index a691f0cb..d71d5225 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts @@ -24,6 +24,7 @@ export interface DevframeNextConfig { export interface DevframeNextHandler { fetch: (_: Request) => Promise; ready: Promise; + readonly mcpAuthToken?: string; close: () => Promise; } export interface DevframeNextHost { @@ -32,6 +33,7 @@ export interface DevframeNextHost { setConnectionMeta: (_: ConnectionMeta) => void; mountMcp: (_: DevframeNodeContext, _: string, _?: DevframeNextHostMcpOptions) => Promise<{ dispose: () => Promise; + authToken: string; }>; } export interface DevframeNextHostMcpOptions { @@ -39,6 +41,7 @@ export interface DevframeNextHostMcpOptions { serverVersion?: string; exposeSharedState?: boolean | ((_: string) => boolean); allowedOrigins?: readonly string[] | false; + authToken?: string; } // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts index 9c610d2b..1fce3e6a 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts @@ -7,6 +7,7 @@ export interface CreateMcpFetchHandlerOptions { serverVersion: string; exposeSharedState: boolean | ((_: string) => boolean); allowedOrigins?: readonly string[] | false; + authToken?: string; } export interface CreateMcpServerOptions { transport?: 'stdio'; diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 406a5da9..9c9836a6 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -32,6 +32,7 @@ export interface DevframeInstanceRecord { rootDir: string; mcp: { path: string; + token?: string; } | null; startedAt: number; } From 86e7b57a51cc56c1e7f297fb065a198e0c2260d8 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 4 Aug 2026 06:52:10 +0000 Subject: [PATCH 2/2] refactor(security): gate MCP route by rejecting Origin-less requests; drop bearer token + meta-token Replace the MCP bearer-token requirement with an origin-only gate, and stop delivering any token through the connection meta: - The route-based MCP endpoint no longer requires `Authorization: Bearer`; it instead rejects `Origin`-less requests (a request must carry a loopback or allow-listed `Origin`), so a route-based endpoint isn't reachable by an arbitrary local process while native clients send their loopback origin. `devframe connect` now sends each instance's own origin. - Revert all token plumbing: `createMcpFetchHandler.authToken`, `StartedServer.mcpAuthToken`, the instance-registry `mcp.token` field and its 0600 file mode, `@devframes/next` `mountMcp`/handler token surfaces. - The hub examples no longer inject an auth token into `__connection.json`; they run their loopback side-car with an explicit `auth: false` (a documented single-user-localhost opt-out). The library default flip stays: viteDevBridge and createDevframeNextHandler still gate by default. Created with the help of an agent. --- docs/adapters/mcp.md | 9 +-- docs/guide/security.md | 2 +- .../src/client/devframe/next-devframe-hub.ts | 23 +++---- .../tests/next-devframe-hub.test.ts | 30 +++------ .../src/vite-devframe-hub.ts | 18 ++---- packages/devframe/src/adapters/dev.ts | 15 +---- .../adapters/mcp/__tests__/mcp-http.test.ts | 61 ++++++++---------- packages/devframe/src/adapters/mcp/fetch.ts | 62 +++++-------------- packages/devframe/src/cli/connect.ts | 16 ++--- .../src/helpers/__tests__/vite.test.ts | 29 ++------- .../devframe/src/node/instance-registry.ts | 17 ++--- packages/devframe/src/node/server.ts | 8 --- packages/next/src/handler.ts | 11 ---- packages/next/src/host.ts | 23 ++----- packages/next/test/handler.test.ts | 13 ++-- skills/devframe/SKILL.md | 2 +- .../@devframes/next/index.snapshot.d.ts | 3 - .../devframe/adapters/mcp.snapshot.d.ts | 1 - .../tsnapi/devframe/node.snapshot.d.ts | 1 - 19 files changed, 100 insertions(+), 244 deletions(-) diff --git a/docs/adapters/mcp.md b/docs/adapters/mcp.md index 9e99f33b..1ff6e65f 100644 --- a/docs/adapters/mcp.md +++ b/docs/adapters/mcp.md @@ -35,7 +35,7 @@ export default defineDevframe({ The endpoint speaks the MCP Streamable-HTTP transport at `/__mcp` (relative to the base path — `/__/__mcp` under a host), sharing the dev server's origin and port. The `--mcp` and `--no-mcp` flags override the definition per run. `__connection.json` advertises the route so in-browser tooling can discover it. -Each client session gets its own MCP server built from the live context, correlated by the `Mcp-Session-Id` header, so `tools/list_changed` and `resources/list_changed` notifications reach connected clients as the tool evolves. The dev server mints a per-instance bearer token and the endpoint requires it as `Authorization: Bearer ` — the real authentication, since the origin gate only ever constrains browsers (a non-browser client can omit or spoof the `Origin` header). The token is recorded in the instance registry (a user-private file, mode `0600`) so `devframe connect` presents it automatically; a client dialing the route directly reads it from `StartedServer.mcpAuthToken`. The endpoint binds to the same loopback host as the dev server and keeps the shared loopback origin gate as defense-in-depth; widen it for a tunnel or LAN origin: +Each client session gets its own MCP server built from the live context, correlated by the `Mcp-Session-Id` header, so `tools/list_changed` and `resources/list_changed` notifications reach connected clients as the tool evolves. The endpoint binds to the same loopback host as the dev server and applies an origin gate: a request must carry an `Origin` that is loopback (or on the configured allow-list). Unlike the WS transport it rejects `Origin`-less requests, so a route-based endpoint isn't reachable by an arbitrary local process — native clients (like `devframe connect`) send their loopback origin explicitly. Widen the gate for a tunnel or LAN origin: ```ts defineDevframe({ @@ -64,16 +64,11 @@ createDevframeNextHandler(devframe, { mcp: true }) ```ts import { createMcpFetchHandler } from 'devframe/adapters/mcp' -import { randomToken } from 'devframe/utils/crypto-token' -const authToken = randomToken() const mcp = createMcpFetchHandler(ctx, { serverName: 'my-tool (devframe)', serverVersion: '1.0.0', exposeSharedState: true, - // Required for every request as `Authorization: Bearer `. Hand it to - // trusted clients out-of-band — e.g. record it in the instance registry. - authToken, }) // route every method on /__mcp to mcp.fetch(request) ``` @@ -95,6 +90,6 @@ It exposes two gateway tools (the wire names of the `devframe:connect:*` ids — - **`devframe_connect_list-instances`** — discover running devframe dev servers and list each one's MCP tools. Instances running without an MCP route are listed with a hint to restart with `--mcp`. - **`devframe_connect_call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint. -Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/-.json` on boot and removes it on close; readers prune records whose liveness probe fails. The record carries the instance's MCP bearer token (in a file written mode `0600`), which the connector presents on every call — so `devframe connect` reaches a token-gated route without any configuration. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp`, which mints and returns the token to record, for serving MCP on a Next app's own origin. `--port ` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out. +Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/-.json` on boot and removes it on close; readers prune records whose liveness probe fails. The connector dials each instance's endpoint with the instance's own loopback origin, so it clears the route's origin gate without any configuration. In-process hosts register explicitly with `registerDevframeInstance` from `devframe/node` — see `createDevframeNextHost().mountMcp` for serving MCP on a Next app's own origin. `--port ` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out. See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example. diff --git a/docs/guide/security.md b/docs/guide/security.md index d63d2ce6..689dd38d 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -95,7 +95,7 @@ Higher-level integrations can drive their own authentication UI instead: disable - **Stay on loopback.** The default bind host is `localhost`. Bind to a routable address only when you intend to, and require authentication when you do. - **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere. The hosted bridges (`viteDevBridge`, `@devframes/next`'s handler) gate their side-car by default too — a host that owns the trust boundary another way opts out with `auth: false` explicitly. -- **The MCP route carries its own token.** The route-based MCP server requires a per-instance `Authorization: Bearer` token (recorded in the instance registry for `devframe connect`), because its origin gate only constrains browsers — see [MCP](/adapters/mcp). +- **The MCP route requires an origin.** Unlike the WS transport, the route-based MCP server rejects `Origin`-less requests (a request must carry a loopback or allow-listed `Origin`), so a route-based endpoint isn't reachable by an arbitrary local process — see [MCP](/adapters/mcp). - **Treat tokens as secrets.** Never log the bearer token or the one-time code, and never bake either into build output. - **Authorize every handler.** A registered function is callable by any trusted client. Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. - **Origin-lock remote docks.** When a hub embeds a remote-UI dock, keep `originLock` on (the default) so its session token is only honored on a connection whose `Origin` matches the dock's own — the connect-time gate verifies the token against the recorded origin before the connection is trusted. diff --git a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts index 3deb9dbc..563a8778 100644 --- a/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts +++ b/examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts @@ -10,8 +10,6 @@ import { createHubContext, mountDevframe } from '@devframes/hub/node' import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import { createDevframeNextHost } from '@devframes/next' import { registerDevframeInstance, startHttpAndWs } from 'devframe/node' -import { createInteractiveAuth } from 'devframe/recipes/interactive-auth' -import { randomToken } from 'devframe/utils/crypto-token' import { getPort } from 'get-port-please' import { createDashboardView } from 'json-render/dashboard' import { dirname, join } from 'pathe' @@ -266,27 +264,22 @@ export async function nextDevframeHub( }, }) - // Gate the side-car RPC/WS server instead of leaving it open: the hub owns - // the trust boundary, so it mints a per-boot bearer token, accepts it as a - // pre-shared `clientAuthToken`, and hands it to its own SPA through the - // connection meta it serves (`authToken` below). `connectDevframe` presents - // it automatically — the host authenticates its own trusted origin with no - // code prompt, while an arbitrary connection is rejected. - const clientAuthToken = randomToken() + // Single-user localhost demo: the side-car is reachable only on loopback, so + // it opts out of the gate for a no-friction dev experience. A hub reachable + // beyond localhost should gate (see `docs/guide/security.md`). const started = await startHttpAndWs({ context, host: hostName, port, - auth: createInteractiveAuth(context, { clientAuthTokens: [clientAuthToken] }), + auth: false, }) // Serve MCP in-process on the Next app's own origin (the `/_next/mcp` // shape): the hub's agent surface — agent-flagged commands, plugin tools // (git status/log/diff, terminals), `devframe:state:read` — over the same catch-all - // route as the SPAs, no side-car port involved. `mountMcp` mints the bearer - // token the route requires and returns it for the registry record below. + // route as the SPAs, no side-car port involved. const mcpPath = '/__hub/__mcp' - const mcp = await nextHost.mountMcp(context, mcpPath, { + await nextHost.mountMcp(context, mcpPath, { serverName: 'example:next-devframe-hub', }) @@ -294,7 +287,6 @@ export async function nextDevframeHub( backend: 'websocket' as const, websocket: started.port, mcp: { path: mcpPath }, - authToken: clientAuthToken, } // Publish the live meta to the bridge now the WS port is known, so every // registered `/__connection.json` (hub + mounted devframes) resolves. @@ -312,13 +304,12 @@ export async function nextDevframeHub( id: 'example:next-devframe-hub', name: 'Next Devframe Hub', rootDir: cwd, - mcp: { path: mcpPath, token: mcp.authToken }, + mcp: { path: mcpPath }, startedAt: Date.now(), }) const closeStarted = started.close started.close = async () => { registration.unregister() - await mcp.dispose() await closeStarted() } diff --git a/examples/next-devframe-hub/tests/next-devframe-hub.test.ts b/examples/next-devframe-hub/tests/next-devframe-hub.test.ts index 65a416b3..3975ccfe 100644 --- a/examples/next-devframe-hub/tests/next-devframe-hub.test.ts +++ b/examples/next-devframe-hub/tests/next-devframe-hub.test.ts @@ -6,8 +6,8 @@ import { nextDevframeHub } from '../src/client/devframe/next-devframe-hub' vi.stubGlobal('WebSocket', WebSocket) -function bootRpc(port: number, authToken?: string) { - const channel = createWsRpcChannel({ url: `ws://127.0.0.1:${port}`, authToken }) +function bootRpc(port: number) { + const channel = createWsRpcChannel({ url: `ws://127.0.0.1:${port}` }) return createRpcClient({}, { channel }) } @@ -19,24 +19,14 @@ describe('next-devframe-hub (example)', () => { server = undefined }) - it('returns connection meta pointing at the WS backend, in-process MCP, and a host-injected auth token', async () => { + it('returns connection meta pointing at the WS backend and in-process MCP', async () => { server = await nextDevframeHub({ host: '127.0.0.1' }) - expect(server.connectionMeta.backend).toBe('websocket') - expect(server.connectionMeta.websocket).toBe(server.port) - expect(server.connectionMeta.mcp).toEqual({ path: '/__hub/__mcp' }) - // The gated side-car hands its own SPA a pre-shared token via the meta. - expect(server.connectionMeta.authToken).toBeTypeOf('string') - expect(server.connectionMeta.authToken!.length).toBeGreaterThanOrEqual(32) - }) - - it('rejects a connection that does not present the host auth token', async () => { - server = await nextDevframeHub({ host: '127.0.0.1' }) - - const rpc = bootRpc(server.port) - await expect( - rpc.$call('example:next-devframe-hub:messages:list'), - ).rejects.toThrow() + expect(server.connectionMeta).toEqual({ + backend: 'websocket', + websocket: server.port, + mcp: { path: '/__hub/__mcp' }, + }) }) it('registers a hub-owned settings dock and the mounted plugin docks', async () => { @@ -59,7 +49,7 @@ describe('next-devframe-hub (example)', () => { it('lists startup and demo messages through the kit-local RPC', async () => { server = await nextDevframeHub({ host: '127.0.0.1' }) - const rpc = bootRpc(server.port, server.connectionMeta.authToken) + const rpc = bootRpc(server.port) const messages = await rpc.$call('example:next-devframe-hub:messages:list') as { message: string }[] expect(messages.map(m => m.message)).toContain('Next Devframe Hub started') expect(messages.map(m => m.message)).toContain('Next demo devframe loaded') @@ -68,7 +58,7 @@ describe('next-devframe-hub (example)', () => { it('executes the ping command through the hub command RPC', async () => { server = await nextDevframeHub({ host: '127.0.0.1' }) - const rpc = bootRpc(server.port, server.connectionMeta.authToken) + const rpc = bootRpc(server.port) await expect( rpc.$call('hub:commands:execute', 'example:next-devframe-hub:ping'), ).resolves.toBe('pong') diff --git a/examples/vite-devframe-hub/src/vite-devframe-hub.ts b/examples/vite-devframe-hub/src/vite-devframe-hub.ts index d6cbc627..bfc68590 100644 --- a/examples/vite-devframe-hub/src/vite-devframe-hub.ts +++ b/examples/vite-devframe-hub/src/vite-devframe-hub.ts @@ -9,8 +9,6 @@ import { defineHubRpcFunction } from '@devframes/hub' import { createHubContext, mountDevframe } from '@devframes/hub/node' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' import { registerDevframeInstance, startHttpAndWs } from 'devframe/node' -import { createInteractiveAuth } from 'devframe/recipes/interactive-auth' -import { randomToken } from 'devframe/utils/crypto-token' import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { join } from 'pathe' @@ -103,14 +101,6 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { // port. Clients discover whatever was chosen via `__connection.json`. const port = options.port ?? await getPort({ port: 9777, portRange: [9777, 9877] }) - // Gate the side-car RPC/WS server: the hub owns the trust boundary, so it - // mints a per-boot bearer token, accepts it as a pre-shared - // `clientAuthToken`, and hands it to its own SPA through the connection - // meta below. `connectDevframe` presents it automatically — the host - // trusts its own origin with no code prompt, while an arbitrary - // connection is rejected. - const clientAuthToken = randomToken() - // Serve the side-car's connection meta (`__connection.json`) at a URL // base so a browser loaded there can discover the WS endpoint via // `connectDevframe()`'s relative `./__connection.json` fetch. @@ -118,7 +108,7 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { const metaPath = `${metaBase}${DEVFRAME_CONNECTION_META_FILENAME}` server.middlewares.use(metaPath, (_req, res) => { res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify({ backend: 'websocket', websocket: port, authToken: clientAuthToken })) + res.end(JSON.stringify({ backend: 'websocket', websocket: port })) }) } @@ -185,7 +175,11 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { started = await startHttpAndWs({ context, port, - auth: createInteractiveAuth(context, { clientAuthTokens: [clientAuthToken] }), + // Single-user localhost demo: the side-car is reachable only on + // loopback, so it opts out of the gate for a no-friction dev + // experience. A hub reachable beyond localhost should gate (see + // `docs/guide/security.md`). + auth: false, }) // Tell the hub UI (served at `base`) where to find the WS endpoint. diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 61895606..2f55b369 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -3,7 +3,6 @@ import type { StartedServer } from '../node/server' import type { ConnectionMeta } from '../types/context' import type { DevframeDefinition, DevframeSetupInfo, DevframeWsOptions, McpRouteOptions } from '../types/devframe' import process from 'node:process' -import { randomToken } from 'devframe/utils/crypto-token' import { open } from 'devframe/utils/open' import { mountStaticHandler } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' @@ -183,12 +182,7 @@ export async function createDevServer( const mcpConfig = resolveMcpConfig(options.mcp ?? def.cli?.mcp) let mcpDispose: (() => Promise) | undefined let mcpMeta: ConnectionMeta['mcp'] - // Bearer token guarding the MCP route (see `createMcpFetchHandler`). Minted - // per-instance, handed to local discovery tools via the registry record — - // never advertised in `__connection.json`. - let mcpAuthToken: string | undefined if (mcpConfig) { - mcpAuthToken = randomToken() const mcpRoute = withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE) const mcpPath = joinURL(basePath, mcpRoute) let mountMcpHttp: typeof import('./mcp/http').mountMcpHttp @@ -204,7 +198,6 @@ export async function createDevServer( serverVersion: def.version ?? '0.0.0', exposeSharedState: true, allowedOrigins: mcpConfig.allowedOrigins, - authToken: mcpAuthToken, }) mcpDispose = mounted.dispose mcpMeta = { path: mcpRoute } @@ -282,16 +275,10 @@ export async function createDevServer( id: def.id, name: def.name, rootDir: process.cwd(), - mcp: mcpConfig - ? { path: joinURL(basePath, withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE)), token: mcpAuthToken } - : null, + mcp: mcpConfig ? { path: joinURL(basePath, withoutLeadingSlash(mcpConfig.path ?? DEVFRAME_MCP_ROUTE)) } : null, startedAt: Date.now(), }) - // Surface the MCP bearer token on the handle so a host that needs to present - // or forward it (tests, a custom launcher) can read it. - started.mcpAuthToken = mcpAuthToken - // Fold MCP session teardown and registry removal into the server's close so // callers get a single graceful-shutdown handle. const closeServer = started.close diff --git a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts index f6567465..081f175b 100644 --- a/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts +++ b/packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts @@ -60,21 +60,17 @@ describe('mcp adapter (streamable http route)', () => { expect(meta.mcp).toBeUndefined() }) - function authTransport(started: StartedServer): StreamableHTTPClientTransport { + // A native MCP client must send a (loopback) Origin so the route's gate — + // which rejects Origin-less requests — accepts it. + function originTransport(started: StartedServer): StreamableHTTPClientTransport { return new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`), { - requestInit: { headers: { Authorization: `Bearer ${started.mcpAuthToken}` } }, + requestInit: { headers: { origin: started.origin } }, }) } - it('mints a bearer token and exposes it on the server handle', async () => { - const started = await boot() - expect(started.mcpAuthToken).toBeTypeOf('string') - expect(started.mcpAuthToken!.length).toBeGreaterThanOrEqual(32) - }) - it('establishes a stateful session and lists agent tools', async () => { const started = await boot() - const transport = authTransport(started) + const transport = originTransport(started) const client = new Client({ name: 'test-client', version: '0.0.0' }) try { await client.connect(transport) @@ -100,13 +96,13 @@ describe('mcp adapter (streamable http route)', () => { // Initialize over raw HTTP to capture the issued session id from the // response header (the body is an SSE stream we can discard). - const authHeader = { Authorization: `Bearer ${started.mcpAuthToken}` } + const originHeader = { origin: started.origin } const init = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', - ...authHeader, + ...originHeader, }, body: JSON.stringify({ jsonrpc: '2.0', @@ -122,7 +118,7 @@ describe('mcp adapter (streamable http route)', () => { // DELETE ends the session. const del = await fetch(url, { method: 'DELETE', - headers: { 'mcp-session-id': sessionId!, ...authHeader }, + headers: { 'mcp-session-id': sessionId!, ...originHeader }, }) await del.body?.cancel() expect(del.status).toBeLessThan(300) @@ -135,7 +131,7 @@ describe('mcp adapter (streamable http route)', () => { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', 'mcp-session-id': sessionId!, - ...authHeader, + ...originHeader, }, body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }), }) @@ -143,31 +139,26 @@ describe('mcp adapter (streamable http route)', () => { expect(stale.status).toBe(404) }) - it('rejects a request with a missing or invalid bearer token', async () => { + it('rejects an Origin-less request', async () => { const started = await boot() - const url = `${started.origin}/__mcp` - const body = JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, - }) - const headers = { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream' } - - // No Authorization header — the origin gate passes (loopback / Origin-less) - // but the bearer gate rejects. - const missing = await fetch(url, { method: 'POST', headers, body }) - await missing.body?.cancel() - expect(missing.status).toBe(401) - - // Wrong token. - const wrong = await fetch(url, { + // Unlike the WS transport, the MCP route does not allow Origin-less + // requests — a route-based endpoint would otherwise be reachable by any + // local process. + const res = await fetch(`${started.origin}/__mcp`, { method: 'POST', - headers: { ...headers, Authorization: 'Bearer not-the-real-token' }, - body, + headers: { + 'content-type': 'application/json', + 'accept': 'application/json, text/event-stream', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } }, + }), }) - await wrong.body?.cancel() - expect(wrong.status).toBe(401) + await res.body?.cancel() + expect(res.status).toBe(403) }) it('rejects a disallowed cross-origin request', async () => { diff --git a/packages/devframe/src/adapters/mcp/fetch.ts b/packages/devframe/src/adapters/mcp/fetch.ts index 62694661..c6a6d9de 100644 --- a/packages/devframe/src/adapters/mcp/fetch.ts +++ b/packages/devframe/src/adapters/mcp/fetch.ts @@ -2,7 +2,6 @@ import type { DevframeNodeContext } from 'devframe/types' import { randomUUID } from 'node:crypto' import { isInitializeRequest, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server' import { isAllowedOrigin } from 'devframe/rpc/transports/ws-server' -import { timingSafeEqual } from 'devframe/utils/crypto-token' import { buildMcpServerFromContext } from './build-server' export interface CreateMcpFetchHandlerOptions { @@ -14,20 +13,14 @@ export interface CreateMcpFetchHandlerOptions { exposeSharedState: boolean | ((key: string) => boolean) /** * Origin allow-list beyond the loopback default. `false` disables the - * origin gate entirely. Default: loopback-only (mirrors the WS transport). + * origin gate entirely. Default: loopback-only. + * + * Unlike the WS transport, the MCP route does **not** allow `Origin`-less + * requests: a route-based endpoint is reachable by any local process, so a + * request must carry an `Origin` that passes the gate. Native clients + * (e.g. `devframe connect`) send their loopback origin explicitly. */ allowedOrigins?: readonly string[] | false - /** - * Bearer token every request must present as `Authorization: Bearer - * `. This is the endpoint's real authentication: the origin gate - * only ever constrains browsers (a non-browser client can omit or spoof the - * `Origin` header), so without a token any local process could reach every - * tool. Callers that expose the route mint a high-entropy token and hand it - * to trusted clients out-of-band (devframe records it in the instance - * registry so `devframe connect` can present it). Leave unset only for a - * transport that is already authenticated by other means. - */ - authToken?: string } export interface McpFetchHandler { @@ -56,12 +49,10 @@ interface McpSession { * and MCP server (built from the shared, live `ctx` via * `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header: an * `initialize` POST spins up a session; later requests route to it; a `DELETE` - * (or client disconnect) tears it down. Two gates guard every request: the - * origin gate applies devframe's loopback-default DNS-rebinding protection - * (identical semantics to the WS upgrade's `isAllowedOrigin`, and only ever - * constrains browsers), and — when {@link CreateMcpFetchHandlerOptions.authToken} - * is set — a constant-time `Authorization: Bearer` check that is the endpoint's - * real authentication for non-browser clients. + * (or client disconnect) tears it down. The origin gate guards every request: + * loopback-default DNS-rebinding protection that — unlike the WS upgrade's + * `isAllowedOrigin` — also rejects `Origin`-less requests, so a route-based + * endpoint isn't reachable by an arbitrary local process. * * @experimental */ @@ -71,22 +62,6 @@ export function createMcpFetchHandler( ): McpFetchHandler { const sessions = new Map() const allowedOrigins = options.allowedOrigins - const authToken = options.authToken - - /** - * Constant-time check of the request's `Authorization: Bearer ` - * against the expected token. Returns `true` when no token is configured - * (the caller opted out of endpoint auth). - */ - function isAuthorized(req: Request): boolean { - if (!authToken) - return true - const header = req.headers.get('authorization') ?? '' - const prefix = 'bearer ' - if (header.slice(0, prefix.length).toLowerCase() !== prefix) - return false - return timingSafeEqual(header.slice(prefix.length).trim(), authToken) - } function drop(sessionId: string): void { const session = sessions.get(sessionId) @@ -136,17 +111,14 @@ export function createMcpFetchHandler( } async function handle(req: Request): Promise { - // Origin gate — identical semantics to the WS upgrade's `isAllowedOrigin` - // (loopback + `Origin`-less native clients + the configured allow-list). - // This is the endpoint's DNS-rebinding protection. + // Origin gate — the endpoint's DNS-rebinding protection and its guard + // against arbitrary local processes. Unlike the WS transport, an + // `Origin`-less request is rejected: a route-based MCP endpoint would + // otherwise be reachable by any local process. A request must carry an + // `Origin` that is loopback or on the configured allow-list. const origin = req.headers.get('origin') ?? undefined - if (allowedOrigins !== false && !isAllowedOrigin(origin, allowedOrigins ?? [])) - return new Response('Forbidden: origin not allowed', { status: 403 }) - - // Bearer-token gate — the actual authentication (see `authToken` above). - // The origin gate above is defense-in-depth against browsers only. - if (!isAuthorized(req)) - return new Response('Unauthorized: missing or invalid bearer token', { status: 401 }) + if (allowedOrigins !== false && (origin === undefined || !isAllowedOrigin(origin, allowedOrigins ?? []))) + return new Response('Forbidden: origin required', { status: 403 }) const sessionId = req.headers.get('mcp-session-id') ?? undefined let session = sessionId ? sessions.get(sessionId) : undefined diff --git a/packages/devframe/src/cli/connect.ts b/packages/devframe/src/cli/connect.ts index 12e90bbd..bd459c3f 100644 --- a/packages/devframe/src/cli/connect.ts +++ b/packages/devframe/src/cli/connect.ts @@ -166,7 +166,7 @@ async function index(sdk: ConnectSdk, options: ConnectServerOptions): Promise { - return withInstanceClient(sdk, url, token, async (client) => { +async function listInstanceTools(sdk: ConnectSdk, url: string): Promise<{ name: string, description?: string }[]> { + return withInstanceClient(sdk, url, async (client) => { const listed = await client.listTools() return listed.tools.map((tool: { name: string, description?: string }) => ({ name: tool.name, @@ -233,7 +233,7 @@ async function call( throw diagnostics.DF0051({ port: args.port }) const url = `${record.origin}${record.mcp.path}` - return withInstanceClient(sdk, url, record.mcp.token, async (client) => { + return withInstanceClient(sdk, url, async (client) => { const result = await client.callTool({ name: args.tool!, arguments: args.args ?? {} }) return { instance: { id: record.id, port: record.port }, @@ -248,14 +248,14 @@ async function call( async function withInstanceClient( sdk: ConnectSdk, url: string, - token: string | undefined, fn: (client: InstanceType) => Promise, ): Promise { - // Present the instance's MCP bearer token (recorded in the registry) so the - // route's `Authorization: Bearer` gate accepts this Origin-less client. + // Send the instance's own (loopback) origin so the MCP route's origin gate, + // which rejects `Origin`-less requests, accepts this native client. + const origin = new URL(url).origin const transport = new sdk.StreamableHTTPClientTransport( new URL(url), - token ? { requestInit: { headers: { Authorization: `Bearer ${token}` } } } : undefined, + { requestInit: { headers: { origin } } }, ) const client = new sdk.Client({ name: 'devframe-connect', version: '0.0.0' }) await client.connect(transport) diff --git a/packages/devframe/src/helpers/__tests__/vite.test.ts b/packages/devframe/src/helpers/__tests__/vite.test.ts index d2da703a..dae89fa6 100644 --- a/packages/devframe/src/helpers/__tests__/vite.test.ts +++ b/packages/devframe/src/helpers/__tests__/vite.test.ts @@ -1,13 +1,9 @@ import type { DevframeDefinition } from '../../types/devframe' -import { mkdtempSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' import { getPort } from 'get-port-please' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { readDevframeInstances } from '../../node/instance-registry' +import { afterEach, describe, expect, it } from 'vitest' import { viteDevBridge } from '../vite' function defineTestDef(): DevframeDefinition { @@ -60,25 +56,15 @@ describe('viteDevBridge (bridge mode mcp)', () => { afterEach(async () => { await bridge?.closeBundle?.() bridge = undefined - vi.unstubAllEnvs() }) it('forwards the mcp option and advertises the side-car endpoint in the meta', async () => { - // Point the instance registry at a temp dir so we can read back the - // per-instance MCP bearer token the side-car minted (never advertised in - // the meta) and present it, exactly as `devframe connect` does. - const registryDir = mkdtempSync(join(tmpdir(), 'df-vite-bridge-registry-')) - vi.stubEnv('DEVFRAME_INSTANCES_DIR', registryDir) - // The test harness disables the registry globally; re-enable it here so the - // side-car writes the record carrying the MCP token. - vi.stubEnv('DEVFRAME_DISABLE_INSTANCE_REGISTRY', '0') - const port = await getPort({ port: 19710, host: '127.0.0.1' }) bridge = viteDevBridge(defineTestDef(), { devMiddleware: { port, host: '127.0.0.1' }, mcp: true, // The bridge now gates by default; opt out here so this test can dial - // the WS/MCP side-car directly. (MCP still requires its bearer token.) + // the WS/MCP side-car directly. auth: false, }) @@ -91,16 +77,11 @@ describe('viteDevBridge (bridge mode mcp)', () => { expect(meta.backend).toBe('websocket') expect(meta.websocket).toEqual({ port, path: '/__devframe_ws' }) expect(meta.mcp).toEqual({ port, path: '/__mcp' }) - // The token is never advertised in the meta. - expect(meta.mcp.token).toBeUndefined() - - const token = readDevframeInstances({ instancesDir: registryDir }).find(r => r.port === port)?.mcp?.token - expect(token).toBeTypeOf('string') - // The advertised endpoint is live: a real MCP client presenting the - // registry-recorded bearer token can connect and list the agent tools. + // The advertised endpoint is live: a real MCP client presenting a loopback + // Origin (required by the route's gate) can connect and list agent tools. const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/__mcp`), { - requestInit: { headers: { Authorization: `Bearer ${token}` } }, + requestInit: { headers: { origin: `http://127.0.0.1:${port}` } }, }) const client = new Client({ name: 'test-client', version: '0.0.0' }) try { diff --git a/packages/devframe/src/node/instance-registry.ts b/packages/devframe/src/node/instance-registry.ts index cd632cb8..3e793337 100644 --- a/packages/devframe/src/node/instance-registry.ts +++ b/packages/devframe/src/node/instance-registry.ts @@ -27,13 +27,10 @@ export interface DevframeInstanceRecord { /** Working directory the instance was started from. */ rootDir: string /** - * The MCP Streamable-HTTP endpoint on `origin`, or `null` when the instance - * runs without an MCP route. `token` is the bearer credential the endpoint - * requires (`Authorization: Bearer `); it lives only in this - * user-private registry file (written mode `0600`) so local discovery tools - * like `devframe connect` can present it, and is never advertised over HTTP. + * Absolute URL path of the MCP Streamable-HTTP endpoint on `origin`, or + * `null` when the instance runs without an MCP route. */ - mcp: { path: string, token?: string } | null + mcp: { path: string } | null /** Epoch-ms timestamp of registration. */ startedAt: number } @@ -104,16 +101,12 @@ export function registerDevframeInstance( if (!isRegistryDisabled()) { try { - // The record can carry the MCP bearer token, so keep the directory and - // file readable only by the owner (`0700`/`0600`) — the token is a - // secret shared out-of-band with local discovery tools, never a - // world-readable value. - mkdirSync(dir, { recursive: true, mode: 0o700 }) + mkdirSync(dir, { recursive: true }) // Atomic publish: write a temp file *in the same directory* (a rename // is only atomic — and only possible — within one filesystem), then // rename into place. const tmp = join(dir, `.${record.pid}-${record.port}.${Date.now()}.tmp`) - writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 }) + writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`) renameSync(tmp, file) } catch (error) { diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index 633d2998..bd60745a 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -127,14 +127,6 @@ export interface StartedServer { * registered on `context.rpc`. */ connectionMeta: () => ConnectionMeta - /** - * Bearer token required on the MCP Streamable-HTTP route, when one is - * mounted (set by {@link createDevServer} once it enables the route). - * `undefined` when the instance runs without MCP. A host that needs to - * present or forward the token (tests, a custom launcher) reads it here; - * it is never advertised over HTTP. - */ - mcpAuthToken?: string close: () => Promise } diff --git a/packages/next/src/handler.ts b/packages/next/src/handler.ts index bf2bc317..4e07434a 100644 --- a/packages/next/src/handler.ts +++ b/packages/next/src/handler.ts @@ -55,14 +55,6 @@ export interface DevframeNextHandler { fetch: (request: Request) => Promise /** Resolves once the side-car RPC/WS server is listening. */ ready: Promise - /** - * Bearer token the side-car's MCP route requires (`Authorization: Bearer - * `), or `undefined` when no MCP route is mounted. Available after - * {@link DevframeNextHandler.ready} resolves; recorded in the instance - * registry for `devframe connect`, and read here by a caller that dials the - * endpoint directly. - */ - readonly mcpAuthToken?: string /** Shut the side-car server down (call from an app-lifecycle hook / test). */ close: () => Promise } @@ -156,9 +148,6 @@ export function createDevframeNextHandler( return nextHost.fetch(request) }, ready, - get mcpAuthToken() { - return started?.mcpAuthToken - }, async close() { await ready.catch(() => {}) await started?.close() diff --git a/packages/next/src/host.ts b/packages/next/src/host.ts index f6f0d3de..5d5f710c 100644 --- a/packages/next/src/host.ts +++ b/packages/next/src/host.ts @@ -1,6 +1,5 @@ import type { ConnectionMeta, DevframeHost, DevframeNodeContext, DevframeStorageScope } from 'devframe/types' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { randomToken } from 'devframe/utils/crypto-token' import { serveStaticHandler } from 'devframe/utils/serve-static' import { H3 } from 'h3' @@ -34,17 +33,10 @@ export interface DevframeNextHostMcpOptions { exposeSharedState?: boolean | ((key: string) => boolean) /** * Origin allow-list beyond the loopback default. `false` disables the - * origin gate entirely. + * origin gate entirely. Note the MCP route rejects `Origin`-less requests + * (see `createMcpFetchHandler`). */ allowedOrigins?: readonly string[] | false - /** - * Bearer token the endpoint requires as `Authorization: Bearer ` — - * the route's real authentication (the origin gate only ever constrains - * browsers). When omitted a high-entropy token is minted and returned by - * {@link DevframeNextHost.mountMcp}; record it in the instance registry (see - * `registerDevframeInstance`) so `devframe connect` can present it. - */ - authToken?: string } export interface DevframeNextHost { @@ -81,8 +73,7 @@ export interface DevframeNextHost { * `devframe/adapters/mcp` (imported lazily: `@modelcontextprotocol/server` * stays an optional peer). Advertise the path in the connection meta * (`mcp: { path }` — same origin, no port) and register the instance via - * `registerDevframeInstance` (with the returned `authToken` in its `mcp` - * record) so `devframe connect` can discover it and present the token. + * `registerDevframeInstance` so `devframe connect` can discover it. * * @experimental */ @@ -90,7 +81,7 @@ export interface DevframeNextHost { ctx: DevframeNodeContext, path: string, options?: DevframeNextHostMcpOptions, - ) => Promise<{ dispose: () => Promise, authToken: string }> + ) => Promise<{ dispose: () => Promise }> } const META_SUFFIX = `/${DEVFRAME_CONNECTION_META_FILENAME}` @@ -175,16 +166,11 @@ export function createDevframeNextHost( }, async mountMcp(ctx, path, mcpOptions = {}) { const { createMcpFetchHandler } = await import('devframe/adapters/mcp') - // The route requires a bearer token (the origin gate only constrains - // browsers). Mint one when the caller doesn't supply it, and return it - // so the host can record it in the instance registry for `devframe connect`. - const authToken = mcpOptions.authToken ?? randomToken() const handler = createMcpFetchHandler(ctx, { serverName: mcpOptions.serverName ?? 'devframe (next)', serverVersion: mcpOptions.serverVersion ?? '0.0.0', exposeSharedState: mcpOptions.exposeSharedState ?? true, allowedOrigins: mcpOptions.allowedOrigins, - authToken, }) const key = stripTrailingSlash(path) mcpMounts.set(key, handler) @@ -193,7 +179,6 @@ export function createDevframeNextHost( mcpMounts.delete(key) await handler.dispose() }, - authToken, } }, } diff --git a/packages/next/test/handler.test.ts b/packages/next/test/handler.test.ts index a7df1712..0ee5f47c 100644 --- a/packages/next/test/handler.test.ts +++ b/packages/next/test/handler.test.ts @@ -79,13 +79,14 @@ describe('createDevframeNextHandler', () => { expect(body.mcp).toEqual({ port: body.websocket.port, path: '/__mcp' }) // The advertised endpoint answers MCP initialize on the side-car origin - // once the required bearer token is presented. - const init = await fetch(`http://127.0.0.1:${body.mcp!.port}${body.mcp!.path}`, { + // when a loopback Origin (required by the route's gate) is presented. + const sidecarOrigin = `http://127.0.0.1:${body.mcp!.port}` + const init = await fetch(`${sidecarOrigin}${body.mcp!.path}`, { method: 'POST', headers: { 'content-type': 'application/json', 'accept': 'application/json, text/event-stream', - 'Authorization': `Bearer ${handler.mcpAuthToken}`, + 'origin': sidecarOrigin, }, body: JSON.stringify({ jsonrpc: '2.0', @@ -98,8 +99,8 @@ describe('createDevframeNextHandler', () => { expect(init.headers.get('mcp-session-id')).toBeTruthy() await init.body?.cancel() - // Without the token the same request is rejected. - const unauthed = await fetch(`http://127.0.0.1:${body.mcp!.port}${body.mcp!.path}`, { + // Without an Origin header the same request is rejected. + const unauthed = await fetch(`${sidecarOrigin}${body.mcp!.path}`, { method: 'POST', headers: { 'content-type': 'application/json', @@ -113,6 +114,6 @@ describe('createDevframeNextHandler', () => { }), }) await unauthed.body?.cancel() - expect(unauthed.status).toBe(401) + expect(unauthed.status).toBe(403) }) }) diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 386c8efb..a73a908f 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -573,7 +573,7 @@ RPC handlers run with the full privileges of the host process, so the boundary t - **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`. - **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first. - **Origin-lock remote docks** (`originLock`, on by default) so a dock's session token is honored only on a connection whose `Origin` matches the dock — the connect-time gate enforces it. -- **The MCP route needs a token.** The route-based MCP server (`cli.mcp`, `viteDevBridge`/Next handler `mcp`, `createMcpFetchHandler`'s `authToken`) requires `Authorization: Bearer ` — the origin gate only constrains browsers. The dev server mints one per instance and records it (registry file, mode `0600`) so `devframe connect` presents it automatically. +- **The MCP route requires an Origin.** The route-based MCP server (`cli.mcp`, `viteDevBridge`/Next handler `mcp`, `createMcpFetchHandler`) rejects `Origin`-less requests — a request must carry a loopback (or allow-listed) `Origin`, so it isn't reachable by an arbitrary local process. `devframe connect` sends each instance's own loopback origin automatically. See [Security](https://devfra.me/security) for the full reference. diff --git a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts index d71d5225..a691f0cb 100644 --- a/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/next/index.snapshot.d.ts @@ -24,7 +24,6 @@ export interface DevframeNextConfig { export interface DevframeNextHandler { fetch: (_: Request) => Promise; ready: Promise; - readonly mcpAuthToken?: string; close: () => Promise; } export interface DevframeNextHost { @@ -33,7 +32,6 @@ export interface DevframeNextHost { setConnectionMeta: (_: ConnectionMeta) => void; mountMcp: (_: DevframeNodeContext, _: string, _?: DevframeNextHostMcpOptions) => Promise<{ dispose: () => Promise; - authToken: string; }>; } export interface DevframeNextHostMcpOptions { @@ -41,7 +39,6 @@ export interface DevframeNextHostMcpOptions { serverVersion?: string; exposeSharedState?: boolean | ((_: string) => boolean); allowedOrigins?: readonly string[] | false; - authToken?: string; } // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts index 1fce3e6a..9c610d2b 100644 --- a/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts @@ -7,7 +7,6 @@ export interface CreateMcpFetchHandlerOptions { serverVersion: string; exposeSharedState: boolean | ((_: string) => boolean); allowedOrigins?: readonly string[] | false; - authToken?: string; } export interface CreateMcpServerOptions { transport?: 'stdio'; diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts index 9c9836a6..406a5da9 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.d.ts @@ -32,7 +32,6 @@ export interface DevframeInstanceRecord { rootDir: string; mcp: { path: string; - token?: string; } | null; startedAt: number; }