Medium severity5.4NVD Advisory· Published Apr 21, 2026· Updated Apr 27, 2026
CVE-2026-41298
CVE-2026-41298
Description
OpenClaw before 2026.4.2 fails to enforce write scopes on the POST /sessions/:sessionKey/kill endpoint in identity-bearing HTTP modes. Read-scoped callers can terminate running subagent sessions by sending requests to this endpoint, bypassing authorization controls.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.4.2 | 2026.4.2 |
Affected products
1Patches
154a087851716fix(gateway): enforce session kill HTTP scopes (#59128)
3 files changed · +110 −46
CHANGELOG.md+1 −0 modified@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai - Image tool/paths: resolve relative local media paths against the agent `workspaceDir` instead of `process.cwd()` so inputs like `inbox/receipt.png` pass the local-path allowlist reliably. (#57222) Thanks Priyansh Gupta. - Podman/launch: remove noisy container output from `scripts/run-openclaw-podman.sh` and align the Podman install guidance with the quieter startup flow. (#59368) Thanks @sallyom. - MS Teams/streaming: strip already-streamed text from fallback block delivery when replies exceed the 4000-character streaming limit so long responses stop duplicating content. (#59297) Thanks @bradgroux. +- Gateway/session kill: enforce HTTP operator scopes on session kill requests and gate authorization before session lookup so unauthenticated callers cannot probe session existence. (#59128) Thanks @jacobtomlinson. - MS Teams/logging: format non-`Error` failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into `[object Object]`. (#59321) Thanks @bradgroux. - Slack/thread context: filter thread starter and history by the effective conversation allowlist without dropping valid open-room, DM, or group DM context. (#58380) Thanks @jacobtomlinson. - ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run.
src/gateway/session-kill-http.test.ts+75 −31 modified@@ -1,11 +1,12 @@ import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayAuthResult } from "./auth.js"; const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; let cfg: Record<string, unknown> = {}; -const authMock = vi.fn(async () => ({ ok: true }) as { ok: boolean; rateLimited?: boolean }); +const authMock = vi.fn(async (): Promise<GatewayAuthResult> => ({ ok: true })); const isLocalDirectRequestMock = vi.fn(() => true); const loadSessionEntryMock = vi.fn(); const getLatestSubagentRunByChildSessionKeyMock = vi.fn(); @@ -76,7 +77,7 @@ afterAll(async () => { beforeEach(() => { cfg = {}; authMock.mockReset(); - authMock.mockResolvedValue({ ok: true }); + authMock.mockResolvedValue({ ok: true, method: "token" }); isLocalDirectRequestMock.mockReset(); isLocalDirectRequestMock.mockReturnValue(true); loadSessionEntryMock.mockReset(); @@ -112,9 +113,16 @@ describe("POST /sessions/:sessionKey/kill", () => { }); it("returns 404 when the session key is not in the session store", async () => { + authMock.mockResolvedValueOnce({ ok: true, method: "trusted-proxy" }); loadSessionEntryMock.mockReturnValue({ entry: undefined }); - const response = await post("/sessions/agent%3Amain%3Asubagent%3Aworker/kill"); + const response = await post( + "/sessions/agent%3Amain%3Asubagent%3Aworker/kill", + TEST_GATEWAY_TOKEN, + { + "x-openclaw-scopes": "operator.admin", + }, + ); expect(response.status).toBe(404); await expect(response.json()).resolves.toMatchObject({ ok: false, @@ -124,13 +132,20 @@ describe("POST /sessions/:sessionKey/kill", () => { }); it("kills a matching session via the admin kill helper using the canonical key", async () => { + authMock.mockResolvedValueOnce({ ok: true, method: "trusted-proxy" }); loadSessionEntryMock.mockReturnValue({ entry: { sessionId: "sess-worker", updatedAt: Date.now() }, canonicalKey: "agent:main:subagent:worker", }); killSubagentRunAdminMock.mockResolvedValue({ found: true, killed: true }); - const response = await post("/sessions/agent%3AMain%3ASubagent%3AWorker/kill"); + const response = await post( + "/sessions/agent%3AMain%3ASubagent%3AWorker/kill", + TEST_GATEWAY_TOKEN, + { + "x-openclaw-scopes": "operator.admin", + }, + ); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ ok: true, killed: true }); expect(killSubagentRunAdminMock).toHaveBeenCalledWith({ @@ -140,17 +155,58 @@ describe("POST /sessions/:sessionKey/kill", () => { }); it("returns killed=false when the target exists but nothing was stopped", async () => { + authMock.mockResolvedValueOnce({ ok: true, method: "trusted-proxy" }); loadSessionEntryMock.mockReturnValue({ entry: { sessionId: "sess-worker", updatedAt: Date.now() }, canonicalKey: "agent:main:subagent:worker", }); killSubagentRunAdminMock.mockResolvedValue({ found: true, killed: false }); - const response = await post("/sessions/agent%3Amain%3Asubagent%3Aworker/kill"); + const response = await post( + "/sessions/agent%3Amain%3Asubagent%3Aworker/kill", + TEST_GATEWAY_TOKEN, + { + "x-openclaw-scopes": "operator.admin", + }, + ); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual({ ok: true, killed: false }); }); + it("rejects local bearer-auth kills without a trusted admin scope surface", async () => { + const response = await post("/sessions/agent%3Amain%3Asubagent%3Aworker/kill"); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { + type: "forbidden", + message: "missing scope: operator.admin", + }, + }); + expect(loadSessionEntryMock).not.toHaveBeenCalled(); + expect(killSubagentRunAdminMock).not.toHaveBeenCalled(); + }); + + it("does not trust x-openclaw-scopes on shared-secret bearer auth", async () => { + const response = await post( + "/sessions/agent%3Amain%3Asubagent%3Aworker/kill", + TEST_GATEWAY_TOKEN, + { + "x-openclaw-scopes": "operator.admin", + }, + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { + type: "forbidden", + message: "missing scope: operator.admin", + }, + }); + expect(loadSessionEntryMock).not.toHaveBeenCalled(); + expect(killSubagentRunAdminMock).not.toHaveBeenCalled(); + }); + it("rejects remote bearer-auth kills without requester ownership", async () => { isLocalDirectRequestMock.mockReturnValue(false); loadSessionEntryMock.mockReturnValue({ @@ -184,7 +240,7 @@ describe("POST /sessions/:sessionKey/kill", () => { it("uses requester ownership checks when a requester session header is provided without admin bypass", async () => { isLocalDirectRequestMock.mockReturnValue(false); - authMock.mockResolvedValueOnce({ ok: true }); + authMock.mockResolvedValueOnce({ ok: true, method: "trusted-proxy" }); loadSessionEntryMock.mockReturnValue({ entry: { sessionId: "sess-worker", updatedAt: Date.now() }, canonicalKey: "agent:main:subagent:worker", @@ -196,6 +252,7 @@ describe("POST /sessions/:sessionKey/kill", () => { killControlledSubagentRunMock.mockResolvedValue({ status: "ok" }); const response = await post("/sessions/agent%3Amain%3Asubagent%3Aworker/kill", "", { + "x-openclaw-scopes": "operator.write", "x-openclaw-requester-session-key": "agent:main:main", }); expect(response.status).toBe(200); @@ -212,7 +269,7 @@ describe("POST /sessions/:sessionKey/kill", () => { it("uses the newest child-session row for requester-owned kills when stale rows still exist", async () => { isLocalDirectRequestMock.mockReturnValue(false); - authMock.mockResolvedValueOnce({ ok: true }); + authMock.mockResolvedValueOnce({ ok: true, method: "trusted-proxy" }); loadSessionEntryMock.mockReturnValue({ entry: { sessionId: "sess-worker", updatedAt: Date.now() }, canonicalKey: "agent:main:subagent:worker", @@ -225,6 +282,7 @@ describe("POST /sessions/:sessionKey/kill", () => { killControlledSubagentRunMock.mockResolvedValue({ status: "done" }); const response = await post("/sessions/agent%3Amain%3Asubagent%3Aworker/kill", "", { + "x-openclaw-scopes": "operator.write", "x-openclaw-requester-session-key": "agent:main:main", }); expect(response.status).toBe(200); @@ -239,37 +297,23 @@ describe("POST /sessions/:sessionKey/kill", () => { }); }); - it("keeps bearer-auth requester kills on the requester-owned path", async () => { + it("rejects bearer-auth requester kills without a trusted write scope surface", async () => { isLocalDirectRequestMock.mockReturnValue(false); - loadSessionEntryMock.mockReturnValue({ - entry: { sessionId: "sess-worker", updatedAt: Date.now() }, - canonicalKey: "agent:main:subagent:worker", - }); - getLatestSubagentRunByChildSessionKeyMock.mockReturnValue({ - runId: "run-1", - childSessionKey: "agent:main:subagent:worker", - }); - killControlledSubagentRunMock.mockResolvedValue({ status: "ok" }); - const response = await post( "/sessions/agent%3Amain%3Asubagent%3Aworker/kill", TEST_GATEWAY_TOKEN, { "x-openclaw-requester-session-key": "agent:other:main" }, ); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toEqual({ ok: true, killed: true }); - expect(resolveSubagentControllerMock).toHaveBeenCalledWith({ - cfg, - agentSessionKey: "agent:other:main", - }); - expect(killControlledSubagentRunMock).toHaveBeenCalledWith({ - cfg, - controller: { controllerSessionKey: "agent:main:main" }, - entry: expect.objectContaining({ - runId: "run-1", - childSessionKey: "agent:main:subagent:worker", - }), + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { + type: "forbidden", + message: "missing scope: operator.write", + }, }); + expect(loadSessionEntryMock).not.toHaveBeenCalled(); expect(killSubagentRunAdminMock).not.toHaveBeenCalled(); + expect(killControlledSubagentRunMock).not.toHaveBeenCalled(); }); });
src/gateway/session-kill-http.ts+34 −15 modified@@ -8,8 +8,12 @@ import { getLatestSubagentRunByChildSessionKey } from "../agents/subagent-regist import { loadConfig } from "../config/config.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { isLocalDirectRequest, type ResolvedGatewayAuth } from "./auth.js"; -import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; import { sendJson, sendMethodNotAllowed } from "./http-common.js"; +import { + authorizeGatewayHttpRequestOrReply, + resolveTrustedHttpOperatorScopes, +} from "./http-utils.js"; +import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; import { loadSessionEntry } from "./session-utils.js"; const REQUESTER_SESSION_KEY_HEADER = "x-openclaw-requester-session-key"; @@ -49,34 +53,23 @@ export async function handleSessionKillHttpRequest( return true; } - const ok = await authorizeGatewayBearerRequestOrReply({ + const requestAuth = await authorizeGatewayHttpRequestOrReply({ req, res, auth: opts.auth, trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); - if (!ok) { - return true; - } - - const { entry, canonicalKey } = loadSessionEntry(sessionKey); - if (!entry) { - sendJson(res, 404, { - ok: false, - error: { - type: "not_found", - message: `Session not found: ${sessionKey}`, - }, - }); + if (!requestAuth) { return true; } const trustedProxies = opts.trustedProxies ?? cfg.gateway?.trustedProxies; const allowRealIpFallback = opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback; const requesterSessionKey = req.headers[REQUESTER_SESSION_KEY_HEADER]?.toString().trim(); const allowLocalAdminKill = isLocalDirectRequest(req, trustedProxies, allowRealIpFallback); + const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth); if (!requesterSessionKey && !allowLocalAdminKill) { sendJson(res, 403, { @@ -89,6 +82,32 @@ export async function handleSessionKillHttpRequest( return true; } + const requiredOperatorMethod = + requesterSessionKey && !allowLocalAdminKill ? "sessions.abort" : "sessions.delete"; + const scopeAuth = authorizeOperatorScopesForMethod(requiredOperatorMethod, requestedScopes); + if (!scopeAuth.allowed) { + sendJson(res, 403, { + ok: false, + error: { + type: "forbidden", + message: `missing scope: ${scopeAuth.missingScope}`, + }, + }); + return true; + } + + const { entry, canonicalKey } = loadSessionEntry(sessionKey); + if (!entry) { + sendJson(res, 404, { + ok: false, + error: { + type: "not_found", + message: `Session not found: ${sessionKey}`, + }, + }); + return true; + } + let killed = false; if (!allowLocalAdminKill && requesterSessionKey) { const runEntry = getLatestSubagentRunByChildSessionKey(canonicalKey);
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/openclaw/openclaw/commit/54a0878517167c6e49900498cf77420dadb74bebnvdPatchWEB
- github.com/advisories/GHSA-5hff-46vh-rxmwghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-5hff-46vh-rxmwnvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-41298ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-authorization-bypass-in-session-termination-endpointnvdThird Party AdvisoryWEB
News mentions
0No linked articles in our index yet.