mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-28 18:33:37 +00:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user