Gateway: align HTTP session history scopes (#55285)

* Gateway: require scopes for HTTP session history

* Gateway: cover missing HTTP history scope header
This commit is contained in:
Jacob Tomlinson
2026-03-26 10:43:57 -07:00
committed by GitHub
parent f8c9863078
commit 1c45123231
3 changed files with 101 additions and 8 deletions

View File

@@ -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);
}

View File

@@ -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();
}
});
});

View File

@@ -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);