diff --git a/src/gateway/http-auth-helpers.ts b/src/gateway/http-auth-helpers.ts index fac708c7f79..f9387d81ca7 100644 --- a/src/gateway/http-auth-helpers.ts +++ b/src/gateway/http-auth-helpers.ts @@ -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); +} diff --git a/src/gateway/sessions-history-http.test.ts b/src/gateway/sessions-history-http.test.ts index d08b1102967..4add9f71b02 100644 --- a/src/gateway/sessions-history-http.test.ts +++ b/src/gateway/sessions-history-http.test.ts @@ -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(); + } + }); }); diff --git a/src/gateway/sessions-history-http.ts b/src/gateway/sessions-history-http.ts index 6da4d8fb218..702fc1b0b26 100644 --- a/src/gateway/sessions-history-http.ts +++ b/src/gateway/sessions-history-http.ts @@ -6,7 +6,10 @@ 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, @@ -14,6 +17,7 @@ import { 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);