High severity7.8NVD Advisory· Published May 6, 2026· Updated May 7, 2026
CVE-2026-44118
CVE-2026-44118
Description
OpenClaw before 2026.4.22 derives loopback MCP owner context from spoofable server-issued bearer tokens in request headers. Non-owner loopback clients can present themselves as owner to bypass owner-gated operations by manipulating the sender-owner header metadata.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.22 | 2026.4.22 |
Affected products
2Patches
13cb1a56bfc95fix(gateway): derive loopback owner context from token (#69796)
9 files changed · +91 −55
CHANGELOG.md+1 −0 modified@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - Synology Chat: validate outbound webhook `file_url` values against the shared SSRF policy before forwarding to the NAS, rejecting malformed URLs, non-`http(s)` schemes, and private/blocked network targets so the NAS cannot be used as a confused deputy to fetch internal addresses. (#69784) Thanks @eleqtrizit. - Gateway/Control UI: require gateway auth on the Control UI avatar route (`GET /avatar/<agentId>` and `?meta=1` metadata) when auth is configured, matching the sibling assistant-media route, and propagate the existing gateway token through the UI avatar fetch (bearer header + authenticated blob URL) so authenticated dashboards still load local avatars. (#69775) - Exec/allowlist: reject POSIX parameter expansion forms such as `$VAR`, `$?`, `$$`, `$1`, and `$@` inside unquoted heredocs during shell approval analysis, so these heredocs no longer pass allowlist review as plain text. (#69795) Thanks @drobison00. +- Gateway/MCP loopback: derive owner-only tool visibility from distinct authenticated owner vs non-owner loopback bearers instead of the caller-controlled owner header, so non-owner MCP child processes cannot recover owner access by spoofing request metadata. (#69796) ## 2026.4.20
src/agents/cli-runner/bundle-mcp.test.ts+2 −5 modified@@ -204,14 +204,12 @@ describe("prepareCliBundleMcpConfig", () => { env: { OPENCLAW_MCP_TOKEN: "loopback-token-123", OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123", - OPENCLAW_MCP_SENDER_IS_OWNER: "false", }, }); expect(prepared.env).toEqual({ OPENCLAW_MCP_TOKEN: "loopback-token-123", OPENCLAW_MCP_SESSION_KEY: "agent:main:telegram:group:chat123", - OPENCLAW_MCP_SENDER_IS_OWNER: "false", }); await prepared.cleanup?.(); @@ -250,7 +248,6 @@ describe("prepareCliBundleMcpConfig", () => { headers: { Authorization: "Bearer ${OPENCLAW_MCP_TOKEN}", "x-session-key": "${OPENCLAW_MCP_SESSION_KEY}", - "x-openclaw-sender-is-owner": "${OPENCLAW_MCP_SENDER_IS_OWNER}", }, }, }, @@ -261,14 +258,14 @@ describe("prepareCliBundleMcpConfig", () => { "exec", "--json", "-c", - 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY", x-openclaw-sender-is-owner = "OPENCLAW_MCP_SENDER_IS_OWNER" } } }', + 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', ]); expect(prepared.backend.resumeArgs).toEqual([ "exec", "resume", "{sessionId}", "-c", - 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY", x-openclaw-sender-is-owner = "OPENCLAW_MCP_SENDER_IS_OWNER" } } }', + 'mcp_servers={ openclaw = { url = "http://127.0.0.1:23119/mcp", bearer_token_env_var = "OPENCLAW_MCP_TOKEN", env_http_headers = { x-session-key = "OPENCLAW_MCP_SESSION_KEY" } } }', ]); expect(prepared.cleanup).toBeUndefined(); });
src/agents/cli-runner/prepare.ts+4 −2 modified@@ -168,12 +168,14 @@ export async function prepareCliRunContext( : undefined, env: mcpLoopbackRuntime ? { - OPENCLAW_MCP_TOKEN: mcpLoopbackRuntime.token, + OPENCLAW_MCP_TOKEN: + params.senderIsOwner === true + ? mcpLoopbackRuntime.ownerToken + : mcpLoopbackRuntime.nonOwnerToken, OPENCLAW_MCP_AGENT_ID: sessionAgentId ?? "", OPENCLAW_MCP_ACCOUNT_ID: params.agentAccountId ?? "", OPENCLAW_MCP_SESSION_KEY: params.sessionKey ?? "", OPENCLAW_MCP_MESSAGE_CHANNEL: params.messageProvider ?? "", - OPENCLAW_MCP_SENDER_IS_OWNER: params.senderIsOwner === true ? "true" : "false", } : undefined, warn: (message) => cliBackendLog.warn(message),
src/gateway/gateway-cli-backend.live-helpers.ts+2 −2 modified@@ -28,6 +28,7 @@ import { } from "./live-agent-probes.js"; import { renderCatFacePngBase64 } from "./live-image-probe.js"; import { getActiveMcpLoopbackRuntime } from "./mcp-http.js"; +import { resolveMcpLoopbackBearerToken } from "./mcp-http.loopback-runtime.js"; import { extractPayloadText } from "./test-helpers.agent-results.js"; // Aggregate docker live runs can contend on startup enough that the gateway @@ -261,10 +262,9 @@ async function callLoopbackJsonRpc(params: { throw new Error("mcp loopback runtime is not active"); } const headers: Record<string, string> = { - Authorization: `Bearer ${runtime.token}`, + Authorization: `Bearer ${resolveMcpLoopbackBearerToken(runtime, params.senderIsOwner)}`, "Content-Type": "application/json", "x-session-key": params.sessionKey, - "x-openclaw-sender-is-owner": params.senderIsOwner ? "true" : "false", }; if (params.messageProvider) { headers["x-openclaw-message-channel"] = params.messageProvider;
src/gateway/mcp-http.loopback-runtime.ts+11 −4 modified@@ -1,6 +1,7 @@ export type McpLoopbackRuntime = { port: number; - token: string; + ownerToken: string; + nonOwnerToken: string; }; let activeRuntime: McpLoopbackRuntime | undefined; @@ -13,8 +14,15 @@ export function setActiveMcpLoopbackRuntime(runtime: McpLoopbackRuntime): void { activeRuntime = { ...runtime }; } -export function clearActiveMcpLoopbackRuntime(token: string): void { - if (activeRuntime?.token === token) { +export function resolveMcpLoopbackBearerToken( + runtime: McpLoopbackRuntime, + senderIsOwner: boolean, +): string { + return senderIsOwner ? runtime.ownerToken : runtime.nonOwnerToken; +} + +export function clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken: string): void { + if (activeRuntime?.ownerToken === ownerToken) { activeRuntime = undefined; } } @@ -31,7 +39,6 @@ export function createMcpLoopbackServerConfig(port: number) { "x-openclaw-agent-id": "${OPENCLAW_MCP_AGENT_ID}", "x-openclaw-account-id": "${OPENCLAW_MCP_ACCOUNT_ID}", "x-openclaw-message-channel": "${OPENCLAW_MCP_MESSAGE_CHANNEL}", - "x-openclaw-sender-is-owner": "${OPENCLAW_MCP_SENDER_IS_OWNER}", }, }, },
src/gateway/mcp-http.request.ts+19 −21 modified@@ -3,10 +3,7 @@ import { resolveMainSessionKey } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { safeEqualSecret } from "../security/secret-equal.js"; -import { - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "../shared/string-coerce.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { getHeader } from "./http-utils.js"; import { isLoopbackAddress } from "./net.js"; @@ -32,7 +29,7 @@ export type McpRequestContext = { sessionKey: string; messageProvider: string | undefined; accountId: string | undefined; - senderIsOwner: boolean | undefined; + senderIsOwner: boolean; }; function resolveScopedSessionKey(cfg: OpenClawConfig, rawSessionKey: string | undefined): string { @@ -66,22 +63,23 @@ function rejectsBrowserLoopbackRequest(req: IncomingMessage): boolean { export function validateMcpLoopbackRequest(params: { req: IncomingMessage; res: ServerResponse; - token: string; -}): boolean { + ownerToken: string; + nonOwnerToken: string; +}): { senderIsOwner: boolean } | null { let url: URL; try { url = new URL(params.req.url ?? "/", `http://${params.req.headers.host ?? "localhost"}`); } catch { logMcpLoopbackHttp("reject", { reason: "bad_request_url", method: params.req.method ?? "" }); params.res.writeHead(400, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "bad_request" })); - return false; + return null; } if (params.req.method === "GET" && url.pathname.startsWith("/.well-known/")) { params.res.writeHead(404); params.res.end(); - return false; + return null; } if (url.pathname !== "/mcp") { @@ -92,7 +90,7 @@ export function validateMcpLoopbackRequest(params: { }); params.res.writeHead(404, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "not_found" })); - return false; + return null; } if (params.req.method !== "POST") { @@ -103,7 +101,7 @@ export function validateMcpLoopbackRequest(params: { }); params.res.writeHead(405, { Allow: "POST" }); params.res.end(); - return false; + return null; } if (rejectsBrowserLoopbackRequest(params.req)) { @@ -114,19 +112,22 @@ export function validateMcpLoopbackRequest(params: { }); params.res.writeHead(403, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "forbidden" })); - return false; + return null; } const authHeader = getHeader(params.req, "authorization") ?? ""; - if (!safeEqualSecret(authHeader, `Bearer ${params.token}`)) { + const ownerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.ownerToken}`); + const nonOwnerTokenMatched = safeEqualSecret(authHeader, `Bearer ${params.nonOwnerToken}`); + const senderIsOwner = ownerTokenMatched ? true : nonOwnerTokenMatched ? false : null; + if (senderIsOwner === null) { logMcpLoopbackHttp("reject", { reason: "unauthorized", method: params.req.method ?? "", hasAuthorization: authHeader.length > 0, }); params.res.writeHead(401, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "unauthorized" })); - return false; + return null; } const contentType = getHeader(params.req, "content-type") ?? ""; @@ -138,10 +139,10 @@ export function validateMcpLoopbackRequest(params: { }); params.res.writeHead(415, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "unsupported_media_type" })); - return false; + return null; } - return true; + return { senderIsOwner }; } export async function readMcpHttpBody(req: IncomingMessage): Promise<string> { @@ -165,16 +166,13 @@ export async function readMcpHttpBody(req: IncomingMessage): Promise<string> { export function resolveMcpRequestContext( req: IncomingMessage, cfg: OpenClawConfig, + auth: { senderIsOwner: boolean }, ): McpRequestContext { - const senderIsOwnerRaw = normalizeOptionalLowercaseString( - getHeader(req, "x-openclaw-sender-is-owner"), - ); return { sessionKey: resolveScopedSessionKey(cfg, getHeader(req, "x-session-key")), messageProvider: normalizeMessageChannel(getHeader(req, "x-openclaw-message-channel")) ?? undefined, accountId: normalizeOptionalString(getHeader(req, "x-openclaw-account-id")), - senderIsOwner: - senderIsOwnerRaw === "true" ? true : senderIsOwnerRaw === "false" ? false : undefined, + senderIsOwner: auth.senderIsOwner, }; }
src/gateway/mcp-http.runtime.ts+2 −2 modified@@ -1,6 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { - clearActiveMcpLoopbackRuntime, + clearActiveMcpLoopbackRuntimeByOwnerToken, createMcpLoopbackServerConfig, getActiveMcpLoopbackRuntime, setActiveMcpLoopbackRuntime, @@ -70,7 +70,7 @@ export class McpLoopbackToolCache { } export { - clearActiveMcpLoopbackRuntime, + clearActiveMcpLoopbackRuntimeByOwnerToken, createMcpLoopbackServerConfig, getActiveMcpLoopbackRuntime, setActiveMcpLoopbackRuntime,
src/gateway/mcp-http.test.ts+41 −13 modified@@ -34,6 +34,7 @@ import { createMcpLoopbackServerConfig, closeMcpLoopbackServer, getActiveMcpLoopbackRuntime, + resolveMcpLoopbackBearerToken, ensureMcpLoopbackServer, startMcpLoopbackServer, } from "./mcp-http.js"; @@ -89,7 +90,7 @@ describe("mcp loopback server", () => { const response = await sendRaw({ port: server.port, - token: runtime?.token, + token: runtime ? resolveMcpLoopbackBearerToken(runtime, false) : undefined, headers: { "content-type": "application/json", "x-session-key": "agent:main:telegram:group:chat123", @@ -105,26 +106,27 @@ describe("mcp loopback server", () => { sessionKey: "agent:main:telegram:group:chat123", accountId: "work", messageProvider: "telegram", - senderIsOwner: undefined, + senderIsOwner: false, surface: "loopback", }), ); }); - it("threads senderIsOwner through loopback request context and cache separation", async () => { + it("derives senderIsOwner from the loopback bearer token", async () => { server = await startMcpLoopbackServer(0); const activeServer = server; const runtime = getActiveMcpLoopbackRuntime(); const sendToolsList = async (senderIsOwner: "true" | "false") => await sendRaw({ port: activeServer.port, - token: runtime?.token, + token: runtime + ? resolveMcpLoopbackBearerToken(runtime, senderIsOwner === "true") + : undefined, headers: { "content-type": "application/json", "x-session-key": "agent:main:matrix:dm:test", "x-openclaw-message-channel": "matrix", - "x-openclaw-sender-is-owner": senderIsOwner, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), }); @@ -153,11 +155,39 @@ describe("mcp loopback server", () => { ); }); + it("ignores spoofed owner headers when the bearer token is non-owner scoped", async () => { + server = await startMcpLoopbackServer(0); + const runtime = getActiveMcpLoopbackRuntime(); + + const response = await sendRaw({ + port: server.port, + token: runtime ? resolveMcpLoopbackBearerToken(runtime, false) : undefined, + headers: { + "content-type": "application/json", + "x-session-key": "agent:main:matrix:dm:test", + "x-openclaw-message-channel": "matrix", + "x-openclaw-sender-is-owner": "true", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + + expect(response.status).toBe(200); + expect(resolveGatewayScopedToolsMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:matrix:dm:test", + messageProvider: "matrix", + senderIsOwner: false, + surface: "loopback", + }), + ); + }); + it("tracks the active runtime only while the server is running", async () => { server = await startMcpLoopbackServer(0); const active = getActiveMcpLoopbackRuntime(); expect(active?.port).toBe(server.port); - expect(active?.token).toMatch(/^[0-9a-f]{64}$/); + expect(active?.ownerToken).toMatch(/^[0-9a-f]{64}$/); + expect(active?.nonOwnerToken).toMatch(/^[0-9a-f]{64}$/); await server.close(); server = undefined; @@ -192,7 +222,7 @@ describe("mcp loopback server", () => { const runtime = getActiveMcpLoopbackRuntime(); const response = await sendRaw({ port: server.port, - token: runtime?.token, + token: runtime ? resolveMcpLoopbackBearerToken(runtime, false) : undefined, headers: { "content-type": "text/plain" }, body: "{}", }); @@ -233,7 +263,7 @@ describe("mcp loopback server", () => { const runtime = getActiveMcpLoopbackRuntime(); const response = await sendRaw({ port: server.port, - token: runtime?.token, + token: runtime ? resolveMcpLoopbackBearerToken(runtime, false) : undefined, headers: { "content-type": "application/json", origin: "http://127.0.0.1:43123", @@ -249,7 +279,7 @@ describe("mcp loopback server", () => { const runtime = getActiveMcpLoopbackRuntime(); const response = await sendRaw({ port: server.port, - token: runtime?.token, + token: runtime ? resolveMcpLoopbackBearerToken(runtime, false) : undefined, headers: { "content-type": "application/json", origin: `http://127.0.0.1:${server.port}`, @@ -272,7 +302,7 @@ describe("mcp loopback server", () => { const runtime = getActiveMcpLoopbackRuntime(); const response = await sendRaw({ port: server.port, - token: runtime?.token, + token: runtime ? resolveMcpLoopbackBearerToken(runtime, false) : undefined, headers: { "content-type": "application/json", origin: "http://localhost:43123", @@ -297,8 +327,6 @@ describe("createMcpLoopbackServerConfig", () => { expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-message-channel"]).toBe( "${OPENCLAW_MCP_MESSAGE_CHANNEL}", ); - expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-sender-is-owner"]).toBe( - "${OPENCLAW_MCP_SENDER_IS_OWNER}", - ); + expect(config.mcpServers?.openclaw?.headers?.["x-openclaw-sender-is-owner"]).toBeUndefined(); }); });
src/gateway/mcp-http.ts+9 −6 modified@@ -6,7 +6,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { logDebug, logWarn } from "../logger.js"; import { handleMcpJsonRpc } from "./mcp-http.handlers.js"; import { - clearActiveMcpLoopbackRuntime, + clearActiveMcpLoopbackRuntimeByOwnerToken, createMcpLoopbackServerConfig, getActiveMcpLoopbackRuntime, setActiveMcpLoopbackRuntime, @@ -22,6 +22,7 @@ import { McpLoopbackToolCache } from "./mcp-http.runtime.js"; export { createMcpLoopbackServerConfig, getActiveMcpLoopbackRuntime, + resolveMcpLoopbackBearerToken, } from "./mcp-http.loopback-runtime.js"; type McpLoopbackServer = { @@ -54,11 +55,13 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ port: number; close: () => Promise<void>; }> { - const token = crypto.randomBytes(32).toString("hex"); + const ownerToken = crypto.randomBytes(32).toString("hex"); + const nonOwnerToken = crypto.randomBytes(32).toString("hex"); const toolCache = new McpLoopbackToolCache(); const httpServer = createHttpServer((req, res) => { - if (!validateMcpLoopbackRequest({ req, res, token })) { + const auth = validateMcpLoopbackRequest({ req, res, ownerToken, nonOwnerToken }); + if (!auth) { return; } @@ -67,7 +70,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ const body = await readMcpHttpBody(req); const parsed: JsonRpcRequest | JsonRpcRequest[] = JSON.parse(body); const cfg = loadConfig(); - const requestContext = resolveMcpRequestContext(req, cfg); + const requestContext = resolveMcpRequestContext(req, cfg, auth); const scopedTools = toolCache.resolve({ cfg, sessionKey: requestContext.sessionKey, @@ -144,7 +147,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ if (!address || typeof address === "string") { throw new Error("mcp loopback did not bind to a TCP port"); } - setActiveMcpLoopbackRuntime({ port: address.port, token }); + setActiveMcpLoopbackRuntime({ port: address.port, ownerToken, nonOwnerToken }); logDebug(`mcp loopback listening on 127.0.0.1:${address.port}`); const server: McpLoopbackServer = { @@ -153,7 +156,7 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ new Promise<void>((resolve, reject) => { httpServer.close((error) => { if (!error) { - clearActiveMcpLoopbackRuntime(token); + clearActiveMcpLoopbackRuntimeByOwnerToken(ownerToken); if (activeMcpLoopbackServer === server) { activeMcpLoopbackServer = undefined; }
Vulnerability mechanics
AI mechanics synthesis has not run for this CVE yet.
References
5- github.com/openclaw/openclaw/commit/3cb1a56bfc9579a0f2336f9cfa12a8a744332a19nvdPatchWEB
- github.com/advisories/GHSA-r6xh-pqhr-v4xhghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-r6xh-pqhr-v4xhnvdMitigationVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-44118ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-owner-context-spoofing-via-bearer-token-headernvdThird Party AdvisoryWEB
News mentions
10- Four OpenClaw Flaws Enable Data Theft, Privilege Escalation, and PersistenceThe Hacker News · May 15, 2026
- 30 ClawHub skills secretly turn AI agents into a crypto swarmThe Register Security · Apr 29, 2026
- 30 ClawHub skills secretly turn AI agents into a crypto swarmThe Register Security · Apr 29, 2026
- 27th April – Threat Intelligence ReportCheck Point Research · Apr 27, 2026
- Agents that remember: introducing Agent MemoryCloudflare Blog · Apr 17, 2026
- The Increasing Role of AI in Vulnerability ResearchWordfence Blog · Apr 10, 2026
- 16th March – Threat Intelligence ReportCheck Point Research · Mar 16, 2026
- How AI Assistants are Moving the Security GoalpostsKrebs on Security · Mar 8, 2026
- Risky Business #827 -- Iranian cyber threat actors are down but not outRisky Business · Mar 4, 2026
- Risky Business #826 -- A week of AI mishaps and skulduggeryRisky Business · Feb 25, 2026