VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
< 2026.4.22026.4.2

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.4.2

Patches

1
54a087851716

fix(gateway): enforce session kill HTTP scopes (#59128)

https://github.com/openclaw/openclawAgustin RiveraApr 2, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.