From fe2eb185ffa256a4cf5ad221f59fd36f82a795bb Mon Sep 17 00:00:00 2001 From: openperf <16864032@qq.com> Date: Mon, 30 Mar 2026 16:43:24 +0800 Subject: [PATCH] fix(gateway ): restore default operator scopes for pure HTTP token auth --- src/gateway/http-auth-helpers.test.ts | 66 ++++++++++++++++++- src/gateway/http-auth-helpers.ts | 14 ++++ ...atible-http-write-scope-bypass.poc.test.ts | 16 +++-- src/gateway/sessions-history-http.test.ts | 13 ++-- 4 files changed, 94 insertions(+), 15 deletions(-) diff --git a/src/gateway/http-auth-helpers.test.ts b/src/gateway/http-auth-helpers.test.ts index e8c611b7229..bbf0227de05 100644 --- a/src/gateway/http-auth-helpers.test.ts +++ b/src/gateway/http-auth-helpers.test.ts @@ -1,7 +1,11 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ResolvedGatewayAuth } from "./auth.js"; -import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js"; +import { + authorizeGatewayBearerRequestOrReply, + resolveGatewayRequestedOperatorScopes, +} from "./http-auth-helpers.js"; +import { CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js"; vi.mock("./auth.js", () => ({ authorizeHttpGatewayConnect: vi.fn(), @@ -13,11 +17,12 @@ vi.mock("./http-common.js", () => ({ vi.mock("./http-utils.js", () => ({ getBearerToken: vi.fn(), + getHeader: vi.fn(), })); const { authorizeHttpGatewayConnect } = await import("./auth.js"); const { sendGatewayAuthFailure } = await import("./http-common.js"); -const { getBearerToken } = await import("./http-utils.js"); +const { getBearerToken, getHeader } = await import("./http-utils.js"); describe("authorizeGatewayBearerRequestOrReply", () => { const bearerAuth = { @@ -70,3 +75,60 @@ describe("authorizeGatewayBearerRequestOrReply", () => { expect(vi.mocked(sendGatewayAuthFailure)).not.toHaveBeenCalled(); }); }); + +describe("resolveGatewayRequestedOperatorScopes", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns CLI_DEFAULT_OPERATOR_SCOPES when header is absent", () => { + vi.mocked(getHeader).mockReturnValue(undefined); + const req = {} as IncomingMessage; + const scopes = resolveGatewayRequestedOperatorScopes(req); + expect(scopes).toEqual(CLI_DEFAULT_OPERATOR_SCOPES); + // Returned array must be a copy, not the original constant. + expect(scopes).not.toBe(CLI_DEFAULT_OPERATOR_SCOPES); + }); + + it("returns empty array when header is present but empty", () => { + vi.mocked(getHeader).mockReturnValue(""); + const req = {} as IncomingMessage; + const scopes = resolveGatewayRequestedOperatorScopes(req); + expect(scopes).toEqual([]); + }); + + it("returns empty array when header is present but only whitespace", () => { + vi.mocked(getHeader).mockReturnValue(" "); + const req = {} as IncomingMessage; + const scopes = resolveGatewayRequestedOperatorScopes(req); + expect(scopes).toEqual([]); + }); + + it("parses comma-separated scopes from header", () => { + vi.mocked(getHeader).mockReturnValue("operator.write,operator.read"); + const req = {} as IncomingMessage; + const scopes = resolveGatewayRequestedOperatorScopes(req); + expect(scopes).toEqual(["operator.write", "operator.read"]); + }); + + it("trims whitespace around individual scopes", () => { + vi.mocked(getHeader).mockReturnValue(" operator.write , operator.read "); + const req = {} as IncomingMessage; + const scopes = resolveGatewayRequestedOperatorScopes(req); + expect(scopes).toEqual(["operator.write", "operator.read"]); + }); + + it("filters out empty segments from trailing commas", () => { + vi.mocked(getHeader).mockReturnValue("operator.write,,operator.read,"); + const req = {} as IncomingMessage; + const scopes = resolveGatewayRequestedOperatorScopes(req); + expect(scopes).toEqual(["operator.write", "operator.read"]); + }); + + it("returns single scope when only one is declared", () => { + vi.mocked(getHeader).mockReturnValue("operator.approvals"); + const req = {} as IncomingMessage; + const scopes = resolveGatewayRequestedOperatorScopes(req); + expect(scopes).toEqual(["operator.approvals"]); + }); +}); diff --git a/src/gateway/http-auth-helpers.ts b/src/gateway/http-auth-helpers.ts index f9387d81ca7..e6f8e41ad3f 100644 --- a/src/gateway/http-auth-helpers.ts +++ b/src/gateway/http-auth-helpers.ts @@ -3,6 +3,7 @@ import type { AuthRateLimiter } from "./auth-rate-limit.js"; import { authorizeHttpGatewayConnect, type ResolvedGatewayAuth } from "./auth.js"; import { sendGatewayAuthFailure } from "./http-common.js"; import { getBearerToken, getHeader } from "./http-utils.js"; +import { CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js"; const OPERATOR_SCOPES_HEADER = "x-openclaw-scopes"; @@ -32,7 +33,20 @@ export async function authorizeGatewayBearerRequestOrReply(params: { export function resolveGatewayRequestedOperatorScopes(req: IncomingMessage): string[] { const raw = getHeader(req, OPERATOR_SCOPES_HEADER)?.trim(); + if (raw === undefined || raw === null) { + // No x-openclaw-scopes header present at all: the caller is a plain + // Bearer-token HTTP client (curl, OpenAI SDK, etc.) that has already + // passed gateway token authentication. Grant the same default operator + // scopes that the CLI and other first-party callers receive so that + // authenticated HTTP requests are not denied by the method-scope gate. + // + // When the header IS present (even if empty), honour the declared value + // so that callers can voluntarily restrict their own privilege set and + // the CVE-2026-32919 / CVE-2026-28473 security boundary is preserved. + return [...CLI_DEFAULT_OPERATOR_SCOPES]; + } if (!raw) { + // Header present but empty string → caller explicitly declared no scopes. return []; } return raw diff --git a/src/gateway/server.openai-compatible-http-write-scope-bypass.poc.test.ts b/src/gateway/server.openai-compatible-http-write-scope-bypass.poc.test.ts index fc4819126c0..493be36e5ce 100644 --- a/src/gateway/server.openai-compatible-http-write-scope-bypass.poc.test.ts +++ b/src/gateway/server.openai-compatible-http-write-scope-bypass.poc.test.ts @@ -51,7 +51,12 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { expect(body.error?.message).toBe("missing scope: operator.write"); expect(agentCommand).toHaveBeenCalledTimes(0); + // Requests without x-openclaw-scopes header now receive default + // CLI_DEFAULT_OPERATOR_SCOPES (which include operator.write), so they + // are authorised. The explicit-header test above still proves that a + // caller who *declares* only operator.approvals is correctly rejected. agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never); const missingHeaderRes = await fetch(`http://127.0.0.1:${started.port}/v1/chat/completions`, { method: "POST", headers: { @@ -64,13 +69,14 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { }), }); - expect(missingHeaderRes.status).toBe(403); + expect(missingHeaderRes.status).toBe(200); const missingHeaderBody = (await missingHeaderRes.json()) as { - error?: { type?: string; message?: string }; + object?: string; + choices?: Array<{ message?: { content?: string } }>; }; - expect(missingHeaderBody.error?.type).toBe("forbidden"); - expect(missingHeaderBody.error?.message).toBe("missing scope: operator.write"); - expect(agentCommand).toHaveBeenCalledTimes(0); + expect(missingHeaderBody.object).toBe("chat.completion"); + expect(missingHeaderBody.choices?.[0]?.message?.content).toBe("hello"); + expect(agentCommand).toHaveBeenCalledTimes(1); } finally { started.ws.close(); await started.server.close(); diff --git a/src/gateway/sessions-history-http.test.ts b/src/gateway/sessions-history-http.test.ts index 2398b08e464..03e8d6ed84f 100644 --- a/src/gateway/sessions-history-http.test.ts +++ b/src/gateway/sessions-history-http.test.ts @@ -422,20 +422,17 @@ describe("session history HTTP endpoints", () => { }, }); + // Requests without x-openclaw-scopes header now receive default + // CLI_DEFAULT_OPERATOR_SCOPES (which include operator.read), so they + // are authorised. The explicit-header test above still proves that a + // caller who *declares* only operator.approvals is correctly rejected. 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", - }, - }); + expect(httpHistoryWithoutScopes.status).toBe(200); } finally { ws.close(); await harness.close();