VYPR
Medium severity6.5NVD Advisory· Published Apr 10, 2026· Updated Apr 13, 2026

CVE-2026-35657

CVE-2026-35657

Description

OpenClaw before 2026.3.25 contains an authorization bypass vulnerability in the HTTP /sessions/:sessionKey/history route that skips operator.read scope validation. Attackers can access session history without proper operator read permissions by sending HTTP requests to the vulnerable endpoint.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.252026.3.25

Affected products

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

Patches

1
1c4512323151

Gateway: align HTTP session history scopes (#55285)

https://github.com/openclaw/openclawJacob TomlinsonMar 26, 2026via ghsa
3 files changed · +101 8
  • src/gateway/http-auth-helpers.ts+14 1 modified
    @@ -2,7 +2,9 @@ import type { IncomingMessage, ServerResponse } from "node:http";
     import type { AuthRateLimiter } from "./auth-rate-limit.js";
     import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";
     import { sendGatewayAuthFailure } from "./http-common.js";
    -import { getBearerToken } from "./http-utils.js";
    +import { getBearerToken, getHeader } from "./http-utils.js";
    +
    +const OPERATOR_SCOPES_HEADER = "x-openclaw-scopes";
     
     export async function authorizeGatewayBearerRequestOrReply(params: {
       req: IncomingMessage;
    @@ -27,3 +29,14 @@ export async function authorizeGatewayBearerRequestOrReply(params: {
       }
       return true;
     }
    +
    +export function resolveGatewayRequestedOperatorScopes(req: IncomingMessage): string[] {
    +  const raw = getHeader(req, OPERATOR_SCOPES_HEADER)?.trim();
    +  if (!raw) {
    +    return [];
    +  }
    +  return raw
    +    .split(",")
    +    .map((scope) => scope.trim())
    +    .filter((scope) => scope.length > 0);
    +}
    
  • src/gateway/sessions-history-http.test.ts+67 5 modified
    @@ -5,14 +5,17 @@ import { afterEach, describe, expect, test } from "vitest";
     import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js";
     import { testState } from "./test-helpers.mocks.js";
     import {
    +  connectReq,
       createGatewaySuiteHarness,
       installGatewayTestHooks,
    +  rpcReq,
       writeSessionStore,
     } from "./test-helpers.server.js";
     
     installGatewayTestHooks();
     
     const AUTH_HEADER = { Authorization: "Bearer test-gateway-token-1234567890" };
    +const READ_SCOPE_HEADER = { "x-openclaw-scopes": "operator.read" };
     const cleanupDirs: string[] = [];
     
     afterEach(async () => {
    @@ -93,7 +96,7 @@ describe("session history HTTP endpoints", () => {
           const res = await fetch(
             `http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history`,
             {
    -          headers: AUTH_HEADER,
    +          headers: { ...AUTH_HEADER, ...READ_SCOPE_HEADER },
             },
           );
     
    @@ -127,7 +130,7 @@ describe("session history HTTP endpoints", () => {
           const res = await fetch(
             `http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:missing")}/history`,
             {
    -          headers: AUTH_HEADER,
    +          headers: { ...AUTH_HEADER, ...READ_SCOPE_HEADER },
             },
           );
     
    @@ -195,7 +198,7 @@ describe("session history HTTP endpoints", () => {
           const res = await fetch(
             `http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history`,
             {
    -          headers: AUTH_HEADER,
    +          headers: { ...AUTH_HEADER, ...READ_SCOPE_HEADER },
             },
           );
     
    @@ -231,7 +234,7 @@ describe("session history HTTP endpoints", () => {
           const firstPage = await fetch(
             `http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=2`,
             {
    -          headers: AUTH_HEADER,
    +          headers: { ...AUTH_HEADER, ...READ_SCOPE_HEADER },
             },
           );
           expect(firstPage.status).toBe(200);
    @@ -254,7 +257,7 @@ describe("session history HTTP endpoints", () => {
           const secondPage = await fetch(
             `http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=2&cursor=${encodeURIComponent(firstBody.nextCursor ?? "")}`,
             {
    -          headers: AUTH_HEADER,
    +          headers: { ...AUTH_HEADER, ...READ_SCOPE_HEADER },
             },
           );
           expect(secondPage.status).toBe(200);
    @@ -291,6 +294,7 @@ describe("session history HTTP endpoints", () => {
             {
               headers: {
                 ...AUTH_HEADER,
    +            ...READ_SCOPE_HEADER,
                 Accept: "text/event-stream",
               },
             },
    @@ -337,6 +341,7 @@ describe("session history HTTP endpoints", () => {
             {
               headers: {
                 ...AUTH_HEADER,
    +            ...READ_SCOPE_HEADER,
                 Accept: "text/event-stream",
               },
             },
    @@ -395,4 +400,61 @@ describe("session history HTTP endpoints", () => {
           await harness.close();
         }
       });
    +
    +  test("rejects session history when operator.read is not requested", async () => {
    +    await seedSession({ text: "scope-guarded history" });
    +
    +    const harness = await createGatewaySuiteHarness();
    +    const ws = await harness.openWs();
    +    try {
    +      const connect = await connectReq(ws, {
    +        token: "test-gateway-token-1234567890",
    +        scopes: ["operator.approvals"],
    +      });
    +      expect(connect.ok).toBe(true);
    +
    +      const wsHistory = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
    +        sessionKey: "agent:main:main",
    +        limit: 1,
    +      });
    +      expect(wsHistory.ok).toBe(false);
    +      expect(wsHistory.error?.message).toBe("missing scope: operator.read");
    +
    +      const httpHistory = await fetch(
    +        `http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=1`,
    +        {
    +          headers: {
    +            ...AUTH_HEADER,
    +            "x-openclaw-scopes": "operator.approvals",
    +          },
    +        },
    +      );
    +      expect(httpHistory.status).toBe(403);
    +      await expect(httpHistory.json()).resolves.toMatchObject({
    +        ok: false,
    +        error: {
    +          type: "forbidden",
    +          message: "missing scope: operator.read",
    +        },
    +      });
    +
    +      const httpHistoryWithoutScopes = await fetch(
    +        `http://127.0.0.1:${harness.port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=1`,
    +        {
    +          headers: AUTH_HEADER,
    +        },
    +      );
    +      expect(httpHistoryWithoutScopes.status).toBe(403);
    +      await expect(httpHistoryWithoutScopes.json()).resolves.toMatchObject({
    +        ok: false,
    +        error: {
    +          type: "forbidden",
    +          message: "missing scope: operator.read",
    +        },
    +      });
    +    } finally {
    +      ws.close();
    +      await harness.close();
    +    }
    +  });
     });
    
  • src/gateway/sessions-history-http.ts+20 2 modified
    @@ -6,14 +6,18 @@ import { loadSessionStore } from "../config/sessions.js";
     import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js";
     import type { AuthRateLimiter } from "./auth-rate-limit.js";
     import type { ResolvedGatewayAuth } from "./auth.js";
    -import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js";
    +import {
    +  authorizeGatewayBearerRequestOrReply,
    +  resolveGatewayRequestedOperatorScopes,
    +} from "./http-auth-helpers.js";
     import {
       sendInvalidRequest,
       sendJson,
       sendMethodNotAllowed,
       setSseHeaders,
     } from "./http-common.js";
     import { getHeader } from "./http-utils.js";
    +import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
     import {
       attachOpenClawTranscriptMeta,
       readSessionMessages,
    @@ -23,7 +27,6 @@ import {
     } from "./session-utils.js";
     
     const MAX_SESSION_HISTORY_LIMIT = 1000;
    -
     function resolveSessionHistoryPath(req: IncomingMessage): string | null {
       const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
       const match = url.pathname.match(/^\/sessions\/([^/]+)\/history$/);
    @@ -167,6 +170,21 @@ export async function handleSessionHistoryHttpRequest(
         return true;
       }
     
    +  // HTTP callers must declare the same least-privilege operator scopes they
    +  // intend to use over WS so both transport surfaces enforce the same gate.
    +  const requestedScopes = resolveGatewayRequestedOperatorScopes(req);
    +  const scopeAuth = authorizeOperatorScopesForMethod("chat.history", requestedScopes);
    +  if (!scopeAuth.allowed) {
    +    sendJson(res, 403, {
    +      ok: false,
    +      error: {
    +        type: "forbidden",
    +        message: `missing scope: ${scopeAuth.missingScope}`,
    +      },
    +    });
    +    return true;
    +  }
    +
       const target = resolveGatewaySessionStoreTarget({ cfg, key: sessionKey });
       const store = loadSessionStore(target.storePath);
       const entry = resolveFreshestSessionEntryFromStoreKeys(store, target.storeKeys);
    

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

4

News mentions

0

No linked articles in our index yet.