Skip to content

Commit 62430d9

Browse files
Harden MCP loopback request validation (#66665)
* fix(mcp): harden loopback request guards * fix(commit): block staged user log * Revert pre-commit USER.md guard from this PR Out of scope for the MCP loopback hardening — keep this PR focused on the loopback request gate and the bearer-comparison fix. The pre-commit worklog guard can land separately if maintainers want it. * changelog: note MCP loopback constant-time + Origin guard (#66665) * fix(mcp): allow loopback flows that browsers flag as cross-site The previous Sec-Fetch-Site early-return rejected legit local browser callers like a UI hosted on http://localhost:<ui-port> talking to MCP on http://127.0.0.1:<mcp-port> — browsers report that host mismatch as cross-site even though both ends are loopback. checkBrowserOrigin already authorizes those via its local-loopback matcher (loopback peer + loopback Origin host), so route every Origin-bearing request through that helper and let it decide. Native MCP clients (no Origin header) continue to short-circuit through to the bearer check unchanged. Adds a regression test asserting that origin: http://localhost:43123, sec-fetch-site: cross-site from a loopback peer is accepted with a valid bearer. --------- Co-authored-by: Devin Robison <drobison@nvidia.com>
1 parent 82a2db7 commit 62430d9

3 files changed

Lines changed: 119 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai
2727
- Slack/native commands: fix option menus for slash commands such as `/verbose` when Slack renders native buttons by giving each button a unique action ID while still routing them through the shared `openclaw_cmdarg*` listener. Thanks @Wangmerlyn.
2828
- Feishu/webhook: harden the webhook transport and card-action replay guards to fail closed on missing `encryptKey` and blank callback tokens — refuse to start the webhook transport without an `encryptKey`, reject unsigned requests when no key is present instead of accepting them, and drop blank card-action tokens before the dedupe claim and dispatcher. Defense-in-depth over the already-closed monitor-account layer. (#66707) Thanks @eleqtrizit.
2929
- Agents/workspace files: route `agents.files.get`, `agents.files.set`, and workspace listing through the shared `fs-safe` helpers (`openFileWithinRoot`/`readFileWithinRoot`/`writeFileWithinRoot`), reject symlink aliases for allowlisted agent files, and have `fs-safe` resolve opened-file real paths from the file descriptor before falling back to path-based `realpath` so a symlink swap between `open` and `realpath` can no longer redirect the validated path off the intended inode. (#66636) Thanks @eleqtrizit.
30+
- Gateway/MCP loopback: switch the `/mcp` bearer comparison from plain `!==` to constant-time `safeEqualSecret` (matching the convention every other auth surface in the codebase uses), and reject non-loopback browser-origin requests via `checkBrowserOrigin` before the auth gate runs. Loopback origins (`127.0.0.1:*`, `localhost:*`, same-origin) still go through, including the `localhost`↔`127.0.0.1` host mismatch that browsers flag as `Sec-Fetch-Site: cross-site`. (#66665) Thanks @eleqtrizit.
3031

3132
## 2026.4.14
3233

src/gateway/mcp-http.request.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type { IncomingMessage, ServerResponse } from "node:http";
22
import { resolveMainSessionKey } from "../config/sessions.js";
33
import type { OpenClawConfig } from "../config/types.openclaw.js";
4+
import { safeEqualSecret } from "../security/secret-equal.js";
45
import {
56
normalizeOptionalLowercaseString,
67
normalizeOptionalString,
78
} from "../shared/string-coerce.js";
89
import { normalizeMessageChannel } from "../utils/message-channel.js";
910
import { getHeader } from "./http-utils.js";
11+
import { isLoopbackAddress } from "./net.js";
12+
import { checkBrowserOrigin } from "./origin-check.js";
1013

1114
const MAX_MCP_BODY_BYTES = 1_048_576;
1215

@@ -22,6 +25,29 @@ function resolveScopedSessionKey(cfg: OpenClawConfig, rawSessionKey: string | un
2225
return !trimmed || trimmed === "main" ? resolveMainSessionKey(cfg) : trimmed;
2326
}
2427

28+
function rejectsBrowserLoopbackRequest(req: IncomingMessage): boolean {
29+
const origin = getHeader(req, "origin");
30+
if (!origin) {
31+
// No Origin header → not a browser request. Native MCP clients
32+
// (curl, codex CLI, scripted MCP clients) never set Origin; let
33+
// them through to the bearer check.
34+
return false;
35+
}
36+
37+
// Defer to checkBrowserOrigin. It already treats loopback peers
38+
// talking to a loopback Origin as `local-loopback`, which covers
39+
// the legitimate `localhost`↔`127.0.0.1` mismatch that browsers
40+
// flag as `Sec-Fetch-Site: cross-site` even though both ends are
41+
// local. A blanket cross-site early-return here would block that
42+
// flow even with a valid bearer; the helper's isLocalClient +
43+
// isLoopbackHost gating is the authoritative check.
44+
return !checkBrowserOrigin({
45+
requestHost: getHeader(req, "host"),
46+
origin,
47+
isLocalClient: isLoopbackAddress(req.socket?.remoteAddress),
48+
}).ok;
49+
}
50+
2551
export function validateMcpLoopbackRequest(params: {
2652
req: IncomingMessage;
2753
res: ServerResponse;
@@ -54,8 +80,14 @@ export function validateMcpLoopbackRequest(params: {
5480
return false;
5581
}
5682

83+
if (rejectsBrowserLoopbackRequest(params.req)) {
84+
params.res.writeHead(403, { "Content-Type": "application/json" });
85+
params.res.end(JSON.stringify({ error: "forbidden" }));
86+
return false;
87+
}
88+
5789
const authHeader = getHeader(params.req, "authorization") ?? "";
58-
if (authHeader !== `Bearer ${params.token}`) {
90+
if (!safeEqualSecret(authHeader, `Bearer ${params.token}`)) {
5991
params.res.writeHead(401, { "Content-Type": "application/json" });
6092
params.res.end(JSON.stringify({ error: "unauthorized" }));
6193
return false;

src/gateway/mcp-http.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,91 @@ describe("mcp loopback server", () => {
198198
});
199199
expect(response.status).toBe(415);
200200
});
201+
202+
it("rejects cross-origin browser requests before auth", async () => {
203+
server = await startMcpLoopbackServer(0);
204+
const response = await sendRaw({
205+
port: server.port,
206+
headers: {
207+
"content-type": "application/json",
208+
origin: "https://evil.example",
209+
"sec-fetch-site": "cross-site",
210+
},
211+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
212+
});
213+
214+
expect(response.status).toBe(403);
215+
});
216+
217+
it("rejects non-loopback origins even without fetch metadata", async () => {
218+
server = await startMcpLoopbackServer(0);
219+
const response = await sendRaw({
220+
port: server.port,
221+
headers: {
222+
"content-type": "application/json",
223+
origin: "https://evil.example",
224+
},
225+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
226+
});
227+
228+
expect(response.status).toBe(403);
229+
});
230+
231+
it("allows loopback browser origins for local clients", async () => {
232+
server = await startMcpLoopbackServer(0);
233+
const runtime = getActiveMcpLoopbackRuntime();
234+
const response = await sendRaw({
235+
port: server.port,
236+
token: runtime?.token,
237+
headers: {
238+
"content-type": "application/json",
239+
origin: "http://127.0.0.1:43123",
240+
},
241+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
242+
});
243+
244+
expect(response.status).toBe(200);
245+
});
246+
247+
it("allows same-origin browser requests from loopback clients", async () => {
248+
server = await startMcpLoopbackServer(0);
249+
const runtime = getActiveMcpLoopbackRuntime();
250+
const response = await sendRaw({
251+
port: server.port,
252+
token: runtime?.token,
253+
headers: {
254+
"content-type": "application/json",
255+
origin: `http://127.0.0.1:${server.port}`,
256+
"sec-fetch-site": "same-origin",
257+
},
258+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
259+
});
260+
261+
expect(response.status).toBe(200);
262+
});
263+
264+
it("allows cross-site fetch metadata when both ends are loopback (localhost ↔ 127.0.0.1)", async () => {
265+
// Browsers report a request from a `http://localhost:<ui-port>`
266+
// page to `http://127.0.0.1:<mcp-port>` as Sec-Fetch-Site:
267+
// cross-site even though both ends are loopback. The gate must
268+
// not blanket-reject on the cross-site signal — checkBrowserOrigin
269+
// already authorizes loopback origins from loopback peers via
270+
// its `local-loopback` matcher.
271+
server = await startMcpLoopbackServer(0);
272+
const runtime = getActiveMcpLoopbackRuntime();
273+
const response = await sendRaw({
274+
port: server.port,
275+
token: runtime?.token,
276+
headers: {
277+
"content-type": "application/json",
278+
origin: "http://localhost:43123",
279+
"sec-fetch-site": "cross-site",
280+
},
281+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
282+
});
283+
284+
expect(response.status).toBe(200);
285+
});
201286
});
202287

203288
describe("createMcpLoopbackServerConfig", () => {

0 commit comments

Comments
 (0)