Skip to content

Commit 06cac68

Browse files
committed
fix(mcp): prove same-machine callers by peer address, not Origin
The route-based MCP endpoint trusted a caller-supplied loopback Origin as proof of a same-machine caller. A non-browser client forges the header, so a network-reachable dev server (vite --host and friends) exposed the whole agent tool surface, including the terminal spawn action, to unauthenticated remote callers. On the zero-config default (no widened allowedOrigins, no identity check), require the connected peer address to be loopback. The address comes from the socket via getRequestIP (never X-Forwarded-For), so a client cannot forge it. Configuring authorization or allowedOrigins: false opts out; a host that can't resolve a peer keeps the prior origin-only behavior.
1 parent f314d02 commit 06cac68

10 files changed

Lines changed: 201 additions & 17 deletions

File tree

‎docs/content/1.guide/14.security.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ For your own auth UI, disable built-in handling with `otpParam: false`, then cal
7575

7676
- **Stay on loopback.** Bind to a routable address only intentionally, and require authentication when you do.
7777
- **Keep `auth: false` local.** The hosted bridges (`devframeViteBridge`, `@devframes/next`'s handler) gate their side-car by default; opt out with an explicit `auth: false` only when the host framework owns the trust boundary another way.
78-
- **The MCP route trusts same-machine callers, harden it when that's not your boundary.** The origin gate keeps browsers and remote hosts out (loopback-only, `Origin`-less rejected), so the `'auto'` default - which mounts the route once agent tools exist - and `mcp: true` are enough for a local dev tool. `Origin` proves nothing about *which* local process is calling, though, so when the route is reachable beyond loopback (a widened `allowedOrigins`, a hosted app) or exposes destructive tools, add an identity check with `mcp: { authorization }` (a bearer from an env var, or a callback), or turn the route off with `mcp: false`. See [MCP](/adapters/mcp).
78+
- **The MCP route trusts same-machine callers, harden it when that's not your boundary.** Two gates enforce that default: an origin gate (loopback-only, `Origin`-less rejected) is browser DNS-rebinding hardening, and a peer-address gate rejects a non-loopback caller even with a forged loopback `Origin` (the socket address can't be forged the way a header can). So the `'auto'` default - which mounts the route once agent tools exist - and `mcp: true` are enough for a local dev tool. Neither gate proves *which* caller it is, though, so to intentionally reach the route beyond loopback (a widened `allowedOrigins`, a hosted app) or to expose destructive tools, add an identity check with `mcp: { authorization }` (a bearer from an env var, or a callback), which also lifts the loopback-peer restriction - or turn the route off with `mcp: false`. See [MCP](/adapters/mcp).
7979
- **Treat tokens as secrets.** Never log the bearer token or the one-time code, or bake either into build output.
8080
- **Authorize every handler.** Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them.
8181
- **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.

‎docs/content/2.adapters/7.mcp.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ The endpoint is **stateless**: it serves the [2026-07-28 revision](https://model
3535

3636
### Origin gate, and opt-in identity
3737

38-
The **origin gate** guards every request: `Origin` must be loopback (or allow-listed), and `Origin`-less requests are rejected (a disallowed origin gets `403`). This is DNS-rebinding hardening that keeps browsers and remote hosts out, and it trusts same-machine callers - the `'auto'` default and `mcp: true` both mount origin-only, all a local dev tool needs.
38+
The **origin gate** guards every request: `Origin` must be loopback (or allow-listed), and `Origin`-less requests are rejected (a disallowed origin gets `403`). This is DNS-rebinding hardening that keeps a browser from reaching the route across origins. It is **not** a network-locality check: `Origin` is a request header, so a non-browser client (curl, a script) sends any value it likes.
3939

40-
`Origin` proves nothing about *who* is calling, though: a native process on the same box can send any `Origin`. When a same-machine process isn't your trust boundary (a LAN/tunnel origin, a shared/CI host, a destructive tool surface), layer on an **identity check** with `authorization`:
40+
Same-machine locality is instead proven from the **connected peer**: on the origin-only default (the `'auto'` default and `mcp: true`, with no widened `allowedOrigins` and no identity check), a request whose peer address is not loopback gets `403` even with a loopback `Origin`. The peer address comes from the socket, not a header, so a remote client cannot forge it. This is what makes "trusts same-machine callers" hold, and it's all a local dev tool needs. (A host that can't resolve a peer address, such as a serverless route, keeps the origin-only behavior; harden it with `authorization`.)
41+
42+
`Origin` still proves nothing about *who* is calling, and the peer check only proves *where from*. When same-machine isn't your trust boundary (a LAN/tunnel origin, a shared/CI host, a destructive tool surface), layer on an **identity check** with `authorization` - which also lifts the loopback-peer restriction, so authenticated callers may be remote:
4143

4244
```ts
4345
createCac(myDevframe, {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { McpAuthorization } from '../../../types/devframe'
2+
import type { DevframeHost } from '../../../types/host'
3+
import type { McpConnectionInfo } from '../fetch'
4+
import { createHostContext } from 'devframe/node'
5+
import { isLoopbackAddress } from 'devframe/utils/origin'
6+
import { afterEach, describe, expect, it } from 'vitest'
7+
import { createMcpFetchHandler } from '../fetch'
8+
9+
function nullHost(): DevframeHost {
10+
return {
11+
mountStatic: () => { /* no-op */ },
12+
resolveOrigin: () => 'http://localhost',
13+
getStorageDir: () => '/tmp/devframe-test-storage',
14+
}
15+
}
16+
17+
const disposers: Array<() => Promise<void>> = []
18+
19+
afterEach(async () => {
20+
await Promise.all(disposers.splice(0).map(d => d()))
21+
})
22+
23+
async function handlerWith(options: { authorization?: McpAuthorization, allowedOrigins?: readonly string[] | false } = {}) {
24+
const ctx = await createHostContext({ cwd: process.cwd(), mode: 'dev', host: nullHost() })
25+
ctx.agent.registerTool({ id: 'greet', description: 'Say hello.', safety: 'read', handler: () => ({ greeting: 'hi' }) })
26+
const handler = createMcpFetchHandler(ctx, {
27+
serverName: 'test',
28+
serverVersion: '0.0.0-test',
29+
exposeSharedState: true,
30+
...options,
31+
})
32+
disposers.push(handler.dispose)
33+
return handler
34+
}
35+
36+
/** A well-formed `initialize` request carrying a (forgeable) loopback Origin. */
37+
function initRequest(headers: Record<string, string> = {}): Request {
38+
return new Request('http://localhost/__mcp', {
39+
method: 'POST',
40+
headers: {
41+
'content-type': 'application/json',
42+
'accept': 'application/json, text/event-stream',
43+
'origin': 'http://localhost',
44+
...headers,
45+
},
46+
body: JSON.stringify({
47+
jsonrpc: '2.0',
48+
id: 1,
49+
method: 'initialize',
50+
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'x', version: '0' } },
51+
}),
52+
})
53+
}
54+
55+
async function status(handler: Awaited<ReturnType<typeof handlerWith>>, connection?: McpConnectionInfo): Promise<number> {
56+
const res = await handler.fetch(initRequest(), connection)
57+
await res.body?.cancel()
58+
return res.status
59+
}
60+
61+
describe('mcp locality gate (origin-only default)', () => {
62+
it('rejects a forged loopback Origin from a non-loopback peer with 403', async () => {
63+
// The reported RCE: a raw network client sends `Origin: http://localhost`
64+
// to pass the origin gate. The peer address it cannot forge gives it away.
65+
const handler = await handlerWith()
66+
expect(await status(handler, { remoteAddress: '203.0.113.7' })).toBe(403)
67+
})
68+
69+
it('allows a loopback peer (local dev keeps working with zero config)', async () => {
70+
const handler = await handlerWith()
71+
expect(await status(handler, { remoteAddress: '127.0.0.1' })).toBe(200)
72+
})
73+
74+
it('allows an IPv4-mapped IPv6 loopback peer from a dual-stack listener', async () => {
75+
const handler = await handlerWith()
76+
expect(await status(handler, { remoteAddress: '::ffff:127.0.0.1' })).toBe(200)
77+
})
78+
79+
it('falls back to origin-only when the host cannot resolve a peer address', async () => {
80+
const handler = await handlerWith()
81+
expect(await status(handler, {})).toBe(200)
82+
expect(await status(handler, undefined)).toBe(200)
83+
})
84+
85+
it('an identity check lifts the loopback-peer restriction', async () => {
86+
const handler = await handlerWith({ authorization: 'a-high-entropy-test-bearer-token' })
87+
expect(await status(handler, { remoteAddress: '203.0.113.7' })).toBe(401)
88+
const ok = await handler.fetch(
89+
initRequest({ authorization: 'Bearer a-high-entropy-test-bearer-token' }),
90+
{ remoteAddress: '203.0.113.7' },
91+
)
92+
await ok.body?.cancel()
93+
expect(ok.status).toBe(200)
94+
})
95+
96+
it('allowedOrigins: false opts out of both origin and locality gates', async () => {
97+
const handler = await handlerWith({ allowedOrigins: false })
98+
expect(await status(handler, { remoteAddress: '203.0.113.7' })).toBe(200)
99+
})
100+
})
101+
102+
describe('isLoopbackAddress', () => {
103+
it('accepts loopback literals a socket reports', () => {
104+
for (const a of ['127.0.0.1', '127.5.5.5', '::1', '::ffff:127.0.0.1', '[::1]'])
105+
expect(isLoopbackAddress(a)).toBe(true)
106+
})
107+
108+
it('rejects routable and mapped-routable addresses', () => {
109+
for (const a of ['203.0.113.7', '10.0.0.5', '192.168.1.9', '::ffff:203.0.113.7', '0.0.0.0'])
110+
expect(isLoopbackAddress(a)).toBe(false)
111+
})
112+
})

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

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { DevframeNodeContext, McpAuthorization } from 'devframe/types'
22
import { createMcpHandler } from '@modelcontextprotocol/server'
33
import { timingSafeEqual } from 'devframe/utils/crypto-token'
4-
import { isAllowedOrigin } from 'devframe/utils/origin'
4+
import { isAllowedOrigin, isLoopbackAddress } from 'devframe/utils/origin'
55
import { bridgeListChanged, buildMcpServerFromContext } from './build-server'
66

77
export interface CreateMcpFetchHandlerOptions {
@@ -62,13 +62,27 @@ async function isAuthorized(req: Request, authorization: McpAuthorization): Prom
6262
return timingSafeEqual(token, authorization)
6363
}
6464

65+
/** Connection facts a host knows about a request beyond the `Request` itself. */
66+
export interface McpConnectionInfo {
67+
/**
68+
* The connecting peer's remote address (a node socket's `remoteAddress`),
69+
* used to prove a same-machine caller when the endpoint relies on the
70+
* loopback origin default with no identity check. A host that can resolve a
71+
* trustworthy peer address (the h3/node mount) supplies it; when it's
72+
* omitted the origin gate stays the only locality signal.
73+
*/
74+
remoteAddress?: string
75+
}
76+
6577
export interface McpFetchHandler {
6678
/**
6779
* WHATWG-`fetch` handler for the MCP endpoint. Hand every method
6880
* (POST/GET/DELETE) on the endpoint's path to it; routing by path is the
69-
* host's job.
81+
* host's job. Pass {@link McpConnectionInfo} when the host can resolve the
82+
* peer address so the default trust boundary can enforce same-machine
83+
* locality.
7084
*/
71-
fetch: (request: Request) => Promise<Response>
85+
fetch: (request: Request, connection?: McpConnectionInfo) => Promise<Response>
7286
/** Tear down the handler (aborts in-flight exchanges, drops the change bridge). */
7387
dispose: () => Promise<void>
7488
}
@@ -89,13 +103,19 @@ export interface McpFetchHandler {
89103
*
90104
* The origin gate guards every request: loopback-default DNS-rebinding
91105
* protection that (unlike the WS upgrade's `isAllowedOrigin`) also rejects
92-
* `Origin`-less requests, so a route-based endpoint isn't reachable by a
93-
* browser or a remote host (a disallowed origin gets `403`). It trusts
94-
* same-machine callers by default. When that isn't your trust boundary, add
95-
* an optional identity gate ({@link CreateMcpFetchHandlerOptions.authorization}),
96-
* checked after the origin gate: a bearer/callback check that proves *who* is
97-
* calling (a missing/invalid credential gets `401` with a
98-
* `WWW-Authenticate: Bearer` challenge).
106+
* `Origin`-less requests, so a browser can't reach the route across origins (a
107+
* disallowed origin gets `403`). The `Origin` header is only browser hardening:
108+
* a non-browser client forges it. So on the zero-config default (no widened
109+
* `allowedOrigins`, no identity check) a second locality gate requires the
110+
* connected peer to be loopback, proven from the host-supplied
111+
* {@link McpConnectionInfo.remoteAddress} (which a client cannot forge), so the
112+
* "trusts same-machine callers" default holds against a remote raw client.
113+
* When same-machine isn't your trust boundary, add an identity gate
114+
* ({@link CreateMcpFetchHandlerOptions.authorization}), checked after the
115+
* origin gate: a bearer/callback check that proves *who* is calling (a
116+
* missing/invalid credential gets `401` with a `WWW-Authenticate: Bearer`
117+
* challenge), which also lifts the loopback-peer restriction for authenticated
118+
* callers.
99119
*/
100120
export function createMcpFetchHandler(
101121
ctx: DevframeNodeContext,
@@ -120,7 +140,13 @@ export function createMcpFetchHandler(
120140
resources: () => { handler.notify.resourcesChanged() },
121141
})
122142

123-
async function handle(req: Request): Promise<Response> {
143+
// The zero-config trust boundary: no widened origin allow-list and no
144+
// identity check, so the endpoint trusts same-machine callers alone. A
145+
// loopback `Origin` is only browser hardening (a raw client forges it), so
146+
// here locality is proven from the connected peer instead.
147+
const originOnlyDefault = allowedOrigins === undefined && authorization === false
148+
149+
async function handle(req: Request, connection?: McpConnectionInfo): Promise<Response> {
124150
// Origin gate: the endpoint's DNS-rebinding protection and its guard
125151
// against arbitrary local processes. Unlike the WS transport, an
126152
// `Origin`-less request is rejected: a route-based MCP endpoint would
@@ -130,6 +156,14 @@ export function createMcpFetchHandler(
130156
if (allowedOrigins !== false && (origin === undefined || !isAllowedOrigin(origin, allowedOrigins ?? [])))
131157
return new Response('Forbidden', { status: 403 })
132158

159+
// Locality gate: a raw client forges `Origin: http://localhost`, so when
160+
// that default is the only trust boundary, require the connected peer to be
161+
// loopback (an address it cannot forge). Opt out with `authorization` or
162+
// `allowedOrigins: false`; a host that can't resolve a peer stays
163+
// origin-only.
164+
if (originOnlyDefault && connection?.remoteAddress !== undefined && !isLoopbackAddress(connection.remoteAddress))
165+
return new Response('Forbidden', { status: 403 })
166+
133167
// Identity gate: a request that cleared the origin check still has to
134168
// prove *who* it is. A generic 401 (with the `WWW-Authenticate` challenge)
135169
// whether the bearer is absent, malformed, or wrong: no response reveals

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { DevframeNodeContext } from 'devframe/types'
22
import type { H3, H3Event } from 'h3'
33
import type { CreateMcpFetchHandlerOptions } from './fetch'
4-
import { defineHandler } from 'h3'
4+
import { defineHandler, getRequestIP } from 'h3'
55
import { createMcpFetchHandler } from './fetch'
66

77
export interface MountMcpHttpOptions extends CreateMcpFetchHandlerOptions {}
@@ -32,7 +32,14 @@ export function mountMcpHttp(
3232
): MountedMcpHttp {
3333
const handler = createMcpFetchHandler(ctx, options)
3434

35-
app.use(path, defineHandler(async event => respond(event, await handler.fetch(event.req))))
35+
// `getRequestIP` (default, `xForwardedFor: false`) returns the connected
36+
// socket's own address, never a client-supplied `X-Forwarded-For`, so the
37+
// handler's locality gate proves a same-machine caller from an address the
38+
// client cannot forge (a widened `allowedOrigins` or a proxy deployment opts
39+
// out via `authorization` / `allowedOrigins: false`).
40+
app.use(path, defineHandler(async event =>
41+
respond(event, await handler.fetch(event.req, { remoteAddress: getRequestIP(event) })),
42+
))
3643

3744
return {
3845
dispose: handler.dispose,

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export {
1818
export {
1919
createMcpFetchHandler,
2020
type CreateMcpFetchHandlerOptions,
21+
type McpConnectionInfo,
2122
type McpFetchHandler,
2223
} from './fetch'
2324

‎packages/devframe/src/utils/origin.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,29 @@ export function isLoopbackHostname(hostname: string): boolean {
2828
return isLoopbackIPv4(h)
2929
}
3030

31+
/**
32+
* Whether `address` is a loopback peer address as reported by a socket
33+
* (`net.Socket.remoteAddress`): the IPv6 loopback `::1`, an IPv4 literal in
34+
* `127.0.0.0/8`, or an IPv4-mapped IPv6 form of one (`::ffff:127.0.0.1`).
35+
*
36+
* Unlike {@link isLoopbackHostname} this takes a raw address, not a hostname:
37+
* it never accepts a `localhost`-style name (a socket peer is always a literal
38+
* address) and understands the IPv4-mapped IPv6 form the OS hands back on a
39+
* dual-stack listener. Used to prove a same-machine caller from the connected
40+
* peer, which a client cannot forge, rather than from the `Origin` header,
41+
* which it can.
42+
*/
43+
export function isLoopbackAddress(address: string): boolean {
44+
let h = address.trim().replace(/^\[|\]$/g, '') // strip IPv6 brackets
45+
const zone = h.indexOf('%') // drop an IPv6 zone id (fe80::1%eth0)
46+
if (zone !== -1)
47+
h = h.slice(0, zone)
48+
if (h === '::1')
49+
return true
50+
const mapped = /^::ffff:(.+)$/i.exec(h)
51+
return isLoopbackIPv4(mapped ? mapped[1] : h)
52+
}
53+
3154
/** A canonical dotted-decimal IPv4 literal in `127.0.0.0/8`. */
3255
function isLoopbackIPv4(hostname: string): boolean {
3356
const octets = hostname.split('.')

‎tests/__snapshots__/tsnapi/devframe/adapters/mcp.snapshot.d.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ export interface CreateMcpServerOptions {
1818
transport: 'stdio';
1919
}) => void;
2020
}
21+
export interface McpConnectionInfo {
22+
remoteAddress?: string;
23+
}
2124
export interface McpFetchHandler {
22-
fetch: (_: Request) => Promise<Response>;
25+
fetch: (_: Request, _?: McpConnectionInfo) => Promise<Response>;
2326
dispose: () => Promise<void>;
2427
}
2528
export interface McpServerHandle {

‎tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.d.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
// #region Other
55
export { isAllowedOrigin }
6+
export { isLoopbackAddress }
67
export { isLoopbackHostname }
78
export { validateOriginCandidate }
89
// #endregion

‎tests/__snapshots__/tsnapi/devframe/utils/origin.snapshot.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
// #region Functions
55
export function isAllowedOrigin(_, _) {}
6+
export function isLoopbackAddress(_) {}
67
export function isLoopbackHostname(_) {}
78
export function validateOriginCandidate(_, _) {}
89
// #endregion

0 commit comments

Comments
 (0)