Skip to content

Commit c671c49

Browse files
authored
feat(security): enforce remote-dock originLock, gate hosted bridges by default, reject Origin-less MCP requests, move OTP to URL fragment (#161)
1 parent 1166a60 commit c671c49

21 files changed

Lines changed: 324 additions & 82 deletions

File tree

‎docs/adapters/mcp.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export default defineDevframe({
3535

3636
The endpoint speaks the MCP Streamable-HTTP transport at `/__mcp` (relative to the base path — `/__<id>/__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.
3737

38-
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:
38+
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:
3939

4040
```ts
4141
defineDevframe({
@@ -90,6 +90,6 @@ It exposes two gateway tools (the wire names of the `devframe:connect:*` ids —
9090
- **`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`.
9191
- **`devframe_connect_call-tool`** — invoke one tool on one instance (`{ port, tool, args }`) over its Streamable-HTTP endpoint.
9292

93-
Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/<pid>-<port>.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 <n>` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out.
93+
Discovery reads the **instance registry**: every `createDevServer` (CLI `dev`, `viteDevBridge`, `@devframes/next`'s handler) writes a record to `~/.devframe/instances/<pid>-<port>.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 <n>` probes an explicit port besides the registry; `DEVFRAME_INSTANCES_DIR` relocates the registry and `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts a server out.
9494

9595
See the [Agent-Native](/guide/agent-native) page for the full API, safety model, and Claude Desktop integration example.

‎docs/guide/client.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ const ok = await rpc.requestTrustWithCode('047204')
135135

136136
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.
137137

138-
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.
138+
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.
139139

140140
### Re-using an existing token
141141

‎docs/guide/security.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,20 +84,21 @@ Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a cod
8484
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):
8585

8686
```
87-
Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456
87+
Devtools ready — authenticate this browser: http://localhost:3000/#devframe_otp=123456
8888
```
8989

90-
`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.
90+
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.
9191

9292
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`.
9393

9494
## Practices for tools built on devframe
9595

9696
- **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.
97-
- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere.
97+
- **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.
98+
- **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).
9899
- **Treat tokens as secrets.** Never log the bearer token or the one-time code, and never bake either into build output.
99100
- **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.
100-
- **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.
101+
- **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.
101102
- **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`.
102103

103104
## External viewer origins

‎examples/next-devframe-hub/src/client/devframe/next-devframe-hub.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,9 @@ export async function nextDevframeHub(
264264
},
265265
})
266266

267+
// Single-user localhost demo: the side-car is reachable only on loopback, so
268+
// it opts out of the gate for a no-friction dev experience. A hub reachable
269+
// beyond localhost should gate (see `docs/guide/security.md`).
267270
const started = await startHttpAndWs({
268271
context,
269272
host: hostName,

‎examples/vite-devframe-hub/src/vite-devframe-hub.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,10 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin {
175175
started = await startHttpAndWs({
176176
context,
177177
port,
178+
// Single-user localhost demo: the side-car is reachable only on
179+
// loopback, so it opts out of the gate for a no-friction dev
180+
// experience. A hub reachable beyond localhost should gate (see
181+
// `docs/guide/security.md`).
178182
auth: false,
179183
})
180184

‎packages/devframe/src/adapters/__tests__/dev.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ describe('adapters/dev', () => {
349349
try {
350350
expect(mockedOpen).toHaveBeenCalledTimes(1)
351351
const [target] = mockedOpen.mock.calls[0]
352-
expect(target).toBe(`http://localhost:${port}/?devframe_otp=${code}`)
352+
expect(target).toBe(`http://localhost:${port}/#devframe_otp=${code}`)
353353
}
354354
finally {
355355
spy.mockRestore()

‎packages/devframe/src/adapters/mcp/__tests__/mcp-http.test.ts‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,17 @@ describe('mcp adapter (streamable http route)', () => {
6060
expect(meta.mcp).toBeUndefined()
6161
})
6262

63+
// A native MCP client must send a (loopback) Origin so the route's gate —
64+
// which rejects Origin-less requests — accepts it.
65+
function originTransport(started: StartedServer): StreamableHTTPClientTransport {
66+
return new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`), {
67+
requestInit: { headers: { origin: started.origin } },
68+
})
69+
}
70+
6371
it('establishes a stateful session and lists agent tools', async () => {
6472
const started = await boot()
65-
const transport = new StreamableHTTPClientTransport(new URL(`${started.origin}/__mcp`))
73+
const transport = originTransport(started)
6674
const client = new Client({ name: 'test-client', version: '0.0.0' })
6775
try {
6876
await client.connect(transport)
@@ -88,11 +96,13 @@ describe('mcp adapter (streamable http route)', () => {
8896

8997
// Initialize over raw HTTP to capture the issued session id from the
9098
// response header (the body is an SSE stream we can discard).
99+
const originHeader = { origin: started.origin }
91100
const init = await fetch(url, {
92101
method: 'POST',
93102
headers: {
94103
'content-type': 'application/json',
95104
'accept': 'application/json, text/event-stream',
105+
...originHeader,
96106
},
97107
body: JSON.stringify({
98108
jsonrpc: '2.0',
@@ -108,7 +118,7 @@ describe('mcp adapter (streamable http route)', () => {
108118
// DELETE ends the session.
109119
const del = await fetch(url, {
110120
method: 'DELETE',
111-
headers: { 'mcp-session-id': sessionId! },
121+
headers: { 'mcp-session-id': sessionId!, ...originHeader },
112122
})
113123
await del.body?.cancel()
114124
expect(del.status).toBeLessThan(300)
@@ -121,13 +131,36 @@ describe('mcp adapter (streamable http route)', () => {
121131
'content-type': 'application/json',
122132
'accept': 'application/json, text/event-stream',
123133
'mcp-session-id': sessionId!,
134+
...originHeader,
124135
},
125136
body: JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }),
126137
})
127138
await stale.body?.cancel()
128139
expect(stale.status).toBe(404)
129140
})
130141

142+
it('rejects an Origin-less request', async () => {
143+
const started = await boot()
144+
// Unlike the WS transport, the MCP route does not allow Origin-less
145+
// requests — a route-based endpoint would otherwise be reachable by any
146+
// local process.
147+
const res = await fetch(`${started.origin}/__mcp`, {
148+
method: 'POST',
149+
headers: {
150+
'content-type': 'application/json',
151+
'accept': 'application/json, text/event-stream',
152+
},
153+
body: JSON.stringify({
154+
jsonrpc: '2.0',
155+
id: 1,
156+
method: 'initialize',
157+
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } },
158+
}),
159+
})
160+
await res.body?.cancel()
161+
expect(res.status).toBe(403)
162+
})
163+
131164
it('rejects a disallowed cross-origin request', async () => {
132165
const started = await boot()
133166
const res = await fetch(`${started.origin}/__mcp`, {

‎packages/devframe/src/adapters/mcp/fetch.ts‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ export interface CreateMcpFetchHandlerOptions {
1313
exposeSharedState: boolean | ((key: string) => boolean)
1414
/**
1515
* Origin allow-list beyond the loopback default. `false` disables the
16-
* origin gate entirely. Default: loopback-only (mirrors the WS transport).
16+
* origin gate entirely. Default: loopback-only.
17+
*
18+
* Unlike the WS transport, the MCP route does **not** allow `Origin`-less
19+
* requests: a route-based endpoint is reachable by any local process, so a
20+
* request must carry an `Origin` that passes the gate. Native clients
21+
* (e.g. `devframe connect`) send their loopback origin explicitly.
1722
*/
1823
allowedOrigins?: readonly string[] | false
1924
}
@@ -44,9 +49,10 @@ interface McpSession {
4449
* and MCP server (built from the shared, live `ctx` via
4550
* `buildMcpServerFromContext`), correlated by the `Mcp-Session-Id` header: an
4651
* `initialize` POST spins up a session; later requests route to it; a `DELETE`
47-
* (or client disconnect) tears it down. The origin gate applies devframe's
48-
* loopback-default DNS-rebinding protection (identical semantics to the WS
49-
* upgrade's `isAllowedOrigin`).
52+
* (or client disconnect) tears it down. The origin gate guards every request:
53+
* loopback-default DNS-rebinding protection that — unlike the WS upgrade's
54+
* `isAllowedOrigin` — also rejects `Origin`-less requests, so a route-based
55+
* endpoint isn't reachable by an arbitrary local process.
5056
*
5157
* @experimental
5258
*/
@@ -105,12 +111,14 @@ export function createMcpFetchHandler(
105111
}
106112

107113
async function handle(req: Request): Promise<Response> {
108-
// Origin gate — identical semantics to the WS upgrade's `isAllowedOrigin`
109-
// (loopback + `Origin`-less native clients + the configured allow-list).
110-
// This is the endpoint's DNS-rebinding protection.
114+
// Origin gate — the endpoint's DNS-rebinding protection and its guard
115+
// against arbitrary local processes. Unlike the WS transport, an
116+
// `Origin`-less request is rejected: a route-based MCP endpoint would
117+
// otherwise be reachable by any local process. A request must carry an
118+
// `Origin` that is loopback or on the configured allow-list.
111119
const origin = req.headers.get('origin') ?? undefined
112-
if (allowedOrigins !== false && !isAllowedOrigin(origin, allowedOrigins ?? []))
113-
return new Response('Forbidden: origin not allowed', { status: 403 })
120+
if (allowedOrigins !== false && (origin === undefined || !isAllowedOrigin(origin, allowedOrigins ?? [])))
121+
return new Response('Forbidden: origin required', { status: 403 })
114122

115123
const sessionId = req.headers.get('mcp-session-id') ?? undefined
116124
let session = sessionId ? sessions.get(sessionId) : undefined

‎packages/devframe/src/cli/connect.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,13 @@ async function withInstanceClient<T>(
250250
url: string,
251251
fn: (client: InstanceType<ConnectSdk['Client']>) => Promise<T>,
252252
): Promise<T> {
253-
const transport = new sdk.StreamableHTTPClientTransport(new URL(url))
253+
// Send the instance's own (loopback) origin so the MCP route's origin gate,
254+
// which rejects `Origin`-less requests, accepts this native client.
255+
const origin = new URL(url).origin
256+
const transport = new sdk.StreamableHTTPClientTransport(
257+
new URL(url),
258+
{ requestInit: { headers: { origin } } },
259+
)
254260
const client = new sdk.Client({ name: 'devframe-connect', version: '0.0.0' })
255261
await client.connect(transport)
256262
try {

0 commit comments

Comments
 (0)