From f0af1867267673cb98822469de1e75cb2e5df211 Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Mon, 30 Mar 2026 12:04:33 -0700 Subject: [PATCH] gateway: ignore bearer-declared HTTP operator scopes (#57783) * gateway: ignore bearer-declared HTTP operator scopes * gateway: key HTTP bearer guards to auth mode * gateway: refresh rebased HTTP regression expectations * gateway: honor resolved HTTP auth method * gateway: remove duplicate openresponses owner flags --- src/gateway/http-endpoint-helpers.test.ts | 27 +++-- src/gateway/http-endpoint-helpers.ts | 19 ++-- .../http-utils.authorize-request.test.ts | 89 +++++++++++++++ .../http-utils.request-context.test.ts | 81 +++++++++++++- src/gateway/http-utils.ts | 102 +++++++++++++++++- src/gateway/models-http.test.ts | 5 +- src/gateway/models-http.ts | 16 +-- .../openai-http.message-channel.test.ts | 3 +- src/gateway/openai-http.test.ts | 13 +-- src/gateway/openai-http.ts | 5 +- src/gateway/openresponses-http.test.ts | 7 +- ...atible-http-write-scope-bypass.poc.test.ts | 75 ++++++++----- src/gateway/sessions-history-http.test.ts | 21 ++-- src/gateway/sessions-history-http.ts | 16 +-- src/gateway/tools-invoke-http.test.ts | 83 +++++++++++--- src/gateway/tools-invoke-http.ts | 27 +++-- 16 files changed, 476 insertions(+), 113 deletions(-) create mode 100644 src/gateway/http-utils.authorize-request.test.ts diff --git a/src/gateway/http-endpoint-helpers.test.ts b/src/gateway/http-endpoint-helpers.test.ts index a6cafb5b0c9..e0ada155900 100644 --- a/src/gateway/http-endpoint-helpers.test.ts +++ b/src/gateway/http-endpoint-helpers.test.ts @@ -3,10 +3,10 @@ import { describe, expect, it, vi } from "vitest"; import type { ResolvedGatewayAuth } from "./auth.js"; import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; -vi.mock("./http-auth-helpers.js", () => { +vi.mock("./http-utils.js", () => { return { - authorizeGatewayBearerRequestOrReply: vi.fn(), - resolveGatewayRequestedOperatorScopes: vi.fn(), + authorizeGatewayHttpRequestOrReply: vi.fn(), + resolveTrustedHttpOperatorScopes: vi.fn(), }; }); @@ -24,9 +24,9 @@ vi.mock("./method-scopes.js", () => { }; }); -const { authorizeGatewayBearerRequestOrReply } = await import("./http-auth-helpers.js"); -const { resolveGatewayRequestedOperatorScopes } = await import("./http-auth-helpers.js"); const { readJsonBodyOrError, sendJson, sendMethodNotAllowed } = await import("./http-common.js"); +const { authorizeGatewayHttpRequestOrReply, resolveTrustedHttpOperatorScopes } = + await import("./http-utils.js"); const { authorizeOperatorScopesForMethod } = await import("./method-scopes.js"); describe("handleGatewayPostJsonEndpoint", () => { @@ -60,7 +60,7 @@ describe("handleGatewayPostJsonEndpoint", () => { }); it("returns undefined when auth fails", async () => { - vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(false); + vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue(null); const result = await handleGatewayPostJsonEndpoint( { url: "/v1/ok", @@ -74,7 +74,9 @@ describe("handleGatewayPostJsonEndpoint", () => { }); it("returns body when auth succeeds and JSON parsing succeeds", async () => { - vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(true); + vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue({ + trustDeclaredOperatorScopes: true, + }); vi.mocked(readJsonBodyOrError).mockResolvedValue({ hello: "world" }); const result = await handleGatewayPostJsonEndpoint( { @@ -85,12 +87,17 @@ describe("handleGatewayPostJsonEndpoint", () => { {} as unknown as ServerResponse, { pathname: "/v1/ok", auth: {} as unknown as ResolvedGatewayAuth, maxBodyBytes: 123 }, ); - expect(result).toEqual({ body: { hello: "world" } }); + expect(result).toEqual({ + body: { hello: "world" }, + requestAuth: { trustDeclaredOperatorScopes: true }, + }); }); it("returns undefined and replies when required operator scope is missing", async () => { - vi.mocked(authorizeGatewayBearerRequestOrReply).mockResolvedValue(true); - vi.mocked(resolveGatewayRequestedOperatorScopes).mockReturnValue(["operator.approvals"]); + vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue({ + trustDeclaredOperatorScopes: false, + }); + vi.mocked(resolveTrustedHttpOperatorScopes).mockReturnValue(["operator.approvals"]); vi.mocked(authorizeOperatorScopesForMethod).mockReturnValue({ allowed: false, missingScope: "operator.write", diff --git a/src/gateway/http-endpoint-helpers.ts b/src/gateway/http-endpoint-helpers.ts index 650ac42ac7b..c69a608ee1d 100644 --- a/src/gateway/http-endpoint-helpers.ts +++ b/src/gateway/http-endpoint-helpers.ts @@ -1,11 +1,12 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; -import { - authorizeGatewayBearerRequestOrReply, - resolveGatewayRequestedOperatorScopes, -} from "./http-auth-helpers.js"; import { readJsonBodyOrError, sendJson, sendMethodNotAllowed } from "./http-common.js"; +import { + authorizeGatewayHttpRequestOrReply, + type AuthorizedGatewayHttpRequest, + resolveTrustedHttpOperatorScopes, +} from "./http-utils.js"; import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; export async function handleGatewayPostJsonEndpoint( @@ -20,7 +21,7 @@ export async function handleGatewayPostJsonEndpoint( rateLimiter?: AuthRateLimiter; requiredOperatorMethod?: "chat.send" | (string & Record); }, -): Promise { +): Promise { const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`); if (url.pathname !== opts.pathname) { return false; @@ -31,7 +32,7 @@ export async function handleGatewayPostJsonEndpoint( return undefined; } - const authorized = await authorizeGatewayBearerRequestOrReply({ + const requestAuth = await authorizeGatewayHttpRequestOrReply({ req, res, auth: opts.auth, @@ -39,12 +40,12 @@ export async function handleGatewayPostJsonEndpoint( allowRealIpFallback: opts.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); - if (!authorized) { + if (!requestAuth) { return undefined; } if (opts.requiredOperatorMethod) { - const requestedScopes = resolveGatewayRequestedOperatorScopes(req); + const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth); const scopeAuth = authorizeOperatorScopesForMethod( opts.requiredOperatorMethod, requestedScopes, @@ -66,5 +67,5 @@ export async function handleGatewayPostJsonEndpoint( return undefined; } - return { body }; + return { body, requestAuth }; } diff --git a/src/gateway/http-utils.authorize-request.test.ts b/src/gateway/http-utils.authorize-request.test.ts new file mode 100644 index 00000000000..0644c43e8e9 --- /dev/null +++ b/src/gateway/http-utils.authorize-request.test.ts @@ -0,0 +1,89 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./auth.js", () => ({ + authorizeHttpGatewayConnect: vi.fn(), +})); + +vi.mock("./http-common.js", () => ({ + sendGatewayAuthFailure: vi.fn(), +})); + +const { authorizeHttpGatewayConnect } = await import("./auth.js"); +const { sendGatewayAuthFailure } = await import("./http-common.js"); +const { authorizeGatewayHttpRequestOrReply } = await import("./http-utils.js"); + +function createReq(headers: Record = {}): IncomingMessage { + return { headers } as IncomingMessage; +} + +describe("authorizeGatewayHttpRequestOrReply", () => { + beforeEach(() => { + vi.mocked(authorizeHttpGatewayConnect).mockReset(); + vi.mocked(sendGatewayAuthFailure).mockReset(); + }); + + it("marks token-authenticated requests as untrusted for declared HTTP scopes", async () => { + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ + ok: true, + method: "token", + }); + + await expect( + authorizeGatewayHttpRequestOrReply({ + req: createReq({ authorization: "Bearer secret" }), + res: {} as ServerResponse, + auth: { mode: "trusted-proxy", allowTailscale: false, token: "secret" }, + trustedProxies: ["127.0.0.1"], + }), + ).resolves.toEqual({ + authMethod: "token", + trustDeclaredOperatorScopes: false, + }); + }); + + it("keeps trusted-proxy requests eligible for declared HTTP scopes", async () => { + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ + ok: true, + method: "trusted-proxy", + user: "operator", + }); + + await expect( + authorizeGatewayHttpRequestOrReply({ + req: createReq({ authorization: "Bearer upstream-idp-token" }), + res: {} as ServerResponse, + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { userHeader: "x-user" }, + }, + trustedProxies: ["127.0.0.1"], + }), + ).resolves.toEqual({ + authMethod: "trusted-proxy", + trustDeclaredOperatorScopes: true, + }); + }); + + it("replies with auth failure and returns null when auth fails", async () => { + const res = {} as ServerResponse; + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ + ok: false, + reason: "unauthorized", + }); + + await expect( + authorizeGatewayHttpRequestOrReply({ + req: createReq(), + res, + auth: { mode: "token", allowTailscale: false, token: "secret" }, + }), + ).resolves.toBeNull(); + + expect(sendGatewayAuthFailure).toHaveBeenCalledWith(res, { + ok: false, + reason: "unauthorized", + }); + }); +}); diff --git a/src/gateway/http-utils.request-context.test.ts b/src/gateway/http-utils.request-context.test.ts index 21c7aeb6efc..9bcc91c20d4 100644 --- a/src/gateway/http-utils.request-context.test.ts +++ b/src/gateway/http-utils.request-context.test.ts @@ -1,11 +1,18 @@ import type { IncomingMessage } from "node:http"; import { describe, expect, it } from "vitest"; -import { resolveGatewayRequestContext } from "./http-utils.js"; +import { + resolveGatewayRequestContext, + resolveHttpSenderIsOwner, + resolveTrustedHttpOperatorScopes, +} from "./http-utils.js"; function createReq(headers: Record = {}): IncomingMessage { return { headers } as IncomingMessage; } +const tokenAuth = { mode: "token" as const }; +const noneAuth = { mode: "none" as const }; + describe("resolveGatewayRequestContext", () => { it("uses normalized x-openclaw-message-channel when enabled", () => { const result = resolveGatewayRequestContext({ @@ -43,3 +50,75 @@ describe("resolveGatewayRequestContext", () => { expect(result.sessionKey).toContain("openresponses-user:alice"); }); }); + +describe("resolveTrustedHttpOperatorScopes", () => { + it("drops self-asserted scopes for bearer-authenticated requests", () => { + const scopes = resolveTrustedHttpOperatorScopes( + createReq({ + authorization: "Bearer secret", + "x-openclaw-scopes": "operator.admin, operator.write", + }), + tokenAuth, + ); + + expect(scopes).toEqual([]); + }); + + it("keeps declared scopes for non-bearer HTTP requests", () => { + const scopes = resolveTrustedHttpOperatorScopes( + createReq({ + "x-openclaw-scopes": "operator.admin, operator.write", + }), + noneAuth, + ); + + expect(scopes).toEqual(["operator.admin", "operator.write"]); + }); + + it("keeps declared scopes when auth mode is not shared-secret even if auth headers are forwarded", () => { + const scopes = resolveTrustedHttpOperatorScopes( + createReq({ + authorization: "Bearer upstream-idp-token", + "x-openclaw-scopes": "operator.admin, operator.write", + }), + noneAuth, + ); + + expect(scopes).toEqual(["operator.admin", "operator.write"]); + }); + + it("drops declared scopes when request auth resolved to a shared-secret method", () => { + const scopes = resolveTrustedHttpOperatorScopes( + createReq({ + authorization: "Bearer upstream-idp-token", + "x-openclaw-scopes": "operator.admin, operator.write", + }), + { trustDeclaredOperatorScopes: false }, + ); + + expect(scopes).toEqual([]); + }); +}); + +describe("resolveHttpSenderIsOwner", () => { + it("requires operator.admin on a trusted HTTP scope-bearing request", () => { + expect( + resolveHttpSenderIsOwner(createReq({ "x-openclaw-scopes": "operator.admin" }), noneAuth), + ).toBe(true); + expect( + resolveHttpSenderIsOwner(createReq({ "x-openclaw-scopes": "operator.write" }), noneAuth), + ).toBe(false); + }); + + it("returns false for bearer requests even with operator.admin in headers", () => { + expect( + resolveHttpSenderIsOwner( + createReq({ + authorization: "Bearer secret", + "x-openclaw-scopes": "operator.admin", + }), + tokenAuth, + ), + ).toBe(false); + }); +}); diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index e5cfe9020a2..4b031ffd63d 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import type { IncomingMessage } from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { buildAllowedModelSet, @@ -10,6 +10,14 @@ import { import { loadConfig } from "../config/config.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; +import { + authorizeHttpGatewayConnect, + type GatewayAuthResult, + type ResolvedGatewayAuth, +} from "./auth.js"; +import { sendGatewayAuthFailure } from "./http-common.js"; +import { ADMIN_SCOPE } from "./method-scopes.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; export const OPENCLAW_MODEL_ID = "openclaw"; @@ -35,6 +43,98 @@ export function getBearerToken(req: IncomingMessage): string | undefined { return token || undefined; } +type SharedSecretGatewayAuth = Pick; +export type AuthorizedGatewayHttpRequest = { + authMethod?: GatewayAuthResult["method"]; + trustDeclaredOperatorScopes: boolean; +}; + +function usesSharedSecretHttpAuth(auth: SharedSecretGatewayAuth | undefined): boolean { + return auth?.mode === "token" || auth?.mode === "password"; +} + +function usesSharedSecretGatewayMethod(method: GatewayAuthResult["method"] | undefined): boolean { + return method === "token" || method === "password"; +} + +function shouldTrustDeclaredHttpOperatorScopes( + req: IncomingMessage, + authOrRequest: + | SharedSecretGatewayAuth + | Pick + | undefined, +): boolean { + if (authOrRequest && "trustDeclaredOperatorScopes" in authOrRequest) { + return authOrRequest.trustDeclaredOperatorScopes; + } + return !isGatewayBearerHttpRequest(req, authOrRequest); +} + +export async function authorizeGatewayHttpRequestOrReply(params: { + req: IncomingMessage; + res: ServerResponse; + auth: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; +}): Promise { + const token = getBearerToken(params.req); + const authResult = await authorizeHttpGatewayConnect({ + auth: params.auth, + connectAuth: token ? { token, password: token } : null, + req: params.req, + trustedProxies: params.trustedProxies, + allowRealIpFallback: params.allowRealIpFallback, + rateLimiter: params.rateLimiter, + }); + if (!authResult.ok) { + sendGatewayAuthFailure(params.res, authResult); + return null; + } + return { + authMethod: authResult.method, + trustDeclaredOperatorScopes: !usesSharedSecretGatewayMethod(authResult.method), + }; +} + +export function isGatewayBearerHttpRequest( + req: IncomingMessage, + auth?: SharedSecretGatewayAuth, +): boolean { + return usesSharedSecretHttpAuth(auth) && Boolean(getBearerToken(req)); +} + +export function resolveTrustedHttpOperatorScopes( + req: IncomingMessage, + authOrRequest?: + | SharedSecretGatewayAuth + | Pick, +): string[] { + if (!shouldTrustDeclaredHttpOperatorScopes(req, authOrRequest)) { + // Gateway bearer auth only proves possession of the shared secret. Do not + // let HTTP clients self-assert operator scopes through request headers. + return []; + } + + const raw = getHeader(req, "x-openclaw-scopes")?.trim(); + if (!raw) { + return []; + } + return raw + .split(",") + .map((scope) => scope.trim()) + .filter((scope) => scope.length > 0); +} + +export function resolveHttpSenderIsOwner( + req: IncomingMessage, + authOrRequest?: + | SharedSecretGatewayAuth + | Pick, +): boolean { + return resolveTrustedHttpOperatorScopes(req, authOrRequest).includes(ADMIN_SCOPE); +} + export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined { const raw = getHeader(req, "x-openclaw-agent-id")?.trim() || diff --git a/src/gateway/models-http.test.ts b/src/gateway/models-http.test.ts index 030cae28887..0a93a697a05 100644 --- a/src/gateway/models-http.test.ts +++ b/src/gateway/models-http.test.ts @@ -22,7 +22,7 @@ afterAll(async () => { async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) { return await startGatewayServer(port, { host: "127.0.0.1", - auth: { mode: "token", token: "secret" }, + auth: { mode: "none" }, controlUiEnabled: false, openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? false, }); @@ -31,7 +31,6 @@ async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: async function getModels(pathname: string, headers?: Record) { return await fetch(`http://127.0.0.1:${enabledPort}${pathname}`, { headers: { - authorization: "Bearer secret", ...READ_SCOPE_HEADER, ...headers, }, @@ -114,7 +113,7 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => { const server = await startServer(port, { openAiChatCompletionsEnabled: false }); try { const res = await fetch(`http://127.0.0.1:${port}/v1/models`, { - headers: { authorization: "Bearer secret" }, + headers: {}, }); expect(res.status).toBe(404); } finally { diff --git a/src/gateway/models-http.ts b/src/gateway/models-http.ts index b7b58bb8b3c..3db7ab2e80a 100644 --- a/src/gateway/models-http.ts +++ b/src/gateway/models-http.ts @@ -3,15 +3,14 @@ import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { loadConfig } from "../config/config.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; -import { - authorizeGatewayBearerRequestOrReply, - resolveGatewayRequestedOperatorScopes, -} from "./http-auth-helpers.js"; import { sendInvalidRequest, sendJson, sendMethodNotAllowed } from "./http-common.js"; import { OPENCLAW_DEFAULT_MODEL_ID, OPENCLAW_MODEL_ID, + authorizeGatewayHttpRequestOrReply, + type AuthorizedGatewayHttpRequest, resolveAgentIdFromModel, + resolveTrustedHttpOperatorScopes, } from "./http-utils.js"; import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; @@ -44,8 +43,8 @@ async function authorizeRequest( req: IncomingMessage, res: ServerResponse, opts: OpenAiModelsHttpOptions, -): Promise { - return await authorizeGatewayBearerRequestOrReply({ +): Promise { + return await authorizeGatewayHttpRequestOrReply({ req, res, auth: opts.auth, @@ -85,11 +84,12 @@ export async function handleOpenAiModelsHttpRequest( return true; } - if (!(await authorizeRequest(req, res, opts))) { + const requestAuth = await authorizeRequest(req, res, opts); + if (!requestAuth) { return true; } - const requestedScopes = resolveGatewayRequestedOperatorScopes(req); + const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth); const scopeAuth = authorizeOperatorScopesForMethod("models.list", requestedScopes); if (!scopeAuth.allowed) { sendJson(res, 403, { diff --git a/src/gateway/openai-http.message-channel.test.ts b/src/gateway/openai-http.message-channel.test.ts index caee85778eb..db6a21d15ec 100644 --- a/src/gateway/openai-http.message-channel.test.ts +++ b/src/gateway/openai-http.message-channel.test.ts @@ -5,7 +5,7 @@ installGatewayTestHooks({ scope: "test" }); const OPENAI_SERVER_OPTIONS = { host: "127.0.0.1", - auth: { mode: "token" as const, token: "secret" }, + auth: { mode: "none" as const }, controlUiEnabled: false, openAiChatCompletionsEnabled: true, }; @@ -19,7 +19,6 @@ async function runOpenAiMessageChannelRequest(params?: { messageChannelHeader?: async ({ port }) => { const headers: Record = { "content-type": "application/json", - authorization: "Bearer secret", "x-openclaw-scopes": "operator.write", }; if (params?.messageChannelHeader) { diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 6c6d12f4903..6d21ead82ba 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -32,7 +32,7 @@ afterAll(async () => { async function startServerWithDefaultConfig(port: number) { return await startGatewayServer(port, { host: "127.0.0.1", - auth: { mode: "token", token: "secret" }, + auth: { mode: "none" }, controlUiEnabled: false, openAiChatCompletionsEnabled: false, }); @@ -41,7 +41,7 @@ async function startServerWithDefaultConfig(port: number) { async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) { return await startGatewayServer(port, { host: "127.0.0.1", - auth: { mode: "token", token: "secret" }, + auth: { mode: "none" }, controlUiEnabled: false, openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? true, }); @@ -61,7 +61,6 @@ async function postChatCompletions(port: number, body: unknown, headers?: Record method: "POST", headers: { "content-type": "application/json", - authorization: "Bearer secret", "x-openclaw-scopes": "operator.write", ...headers, }, @@ -96,7 +95,7 @@ function parseSseDataLines(text: string): string[] { } describe("OpenAI-compatible HTTP API (e2e)", () => { - it("rejects when disabled (default + config)", { timeout: 15_000 }, async () => { + it("rejects when disabled (default + config)", { timeout: 90_000 }, async () => { await expectChatCompletionsDisabled(startServerWithDefaultConfig); await expectChatCompletionsDisabled((port) => startServer(port, { @@ -187,10 +186,12 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { { const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + }, body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }), }); - expect(res.status).toBe(401); + expect(res.status).toBe(403); await res.text(); } diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 138799f0698..4c21b2cd153 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -27,7 +27,10 @@ import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; import { sendJson, setSseHeaders, writeDone } from "./http-common.js"; import { handleGatewayPostJsonEndpoint } from "./http-endpoint-helpers.js"; -import { resolveGatewayRequestContext, resolveOpenAiCompatModelOverride } from "./http-utils.js"; +import { + resolveGatewayRequestContext, + resolveOpenAiCompatModelOverride, +} from "./http-utils.js"; import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; type OpenAiHttpOptions = { diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index ef1275d49fd..c432fb25593 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -45,7 +45,7 @@ async function startServer(port: number, opts?: { openResponsesEnabled?: boolean const { startGatewayServer } = await import("./server.js"); const serverOpts = { host: "127.0.0.1", - auth: { mode: "token", token: "secret" }, + auth: { mode: "none" as const }, controlUiEnabled: false, } as const; return await startGatewayServer( @@ -70,7 +70,6 @@ async function postResponses(port: number, body: unknown, headers?: Record { - it("rejects when disabled (default + config)", { timeout: 15_000 }, async () => { + it("rejects when disabled (default + config)", { timeout: 90_000 }, async () => { const port = await getFreePort(); const server = await startServer(port); try { @@ -224,7 +223,7 @@ describe("OpenResponses HTTP API (e2e)", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "openclaw", input: "hi" }), }); - expect(resMissingAuth.status).toBe(401); + expect(resMissingAuth.status).toBe(403); await ensureResponseConsumed(resMissingAuth); const resMissingModel = await postResponses(port, { input: "hi" }); 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 493be36e5ce..71fc735ca09 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 @@ -69,14 +69,13 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { }), }); - expect(missingHeaderRes.status).toBe(200); + expect(missingHeaderRes.status).toBe(403); const missingHeaderBody = (await missingHeaderRes.json()) as { - object?: string; - choices?: Array<{ message?: { content?: string } }>; + error?: { type?: string; message?: string }; }; - expect(missingHeaderBody.object).toBe("chat.completion"); - expect(missingHeaderBody.choices?.[0]?.message?.content).toBe("hello"); - expect(agentCommand).toHaveBeenCalledTimes(1); + expect(missingHeaderBody.error?.type).toBe("forbidden"); + expect(missingHeaderBody.error?.message).toBe("missing scope: operator.write"); + expect(agentCommand).toHaveBeenCalledTimes(0); } finally { started.ws.close(); await started.server.close(); @@ -84,7 +83,7 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { } }); - test("operator.write can still use /v1/chat/completions", async () => { + test("bearer auth cannot self-assert operator.write for /v1/chat/completions", async () => { const started = await startServerWithClient("secret", { openAiChatCompletionsEnabled: true, }); @@ -106,14 +105,13 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { }), }); - expect(httpRes.status).toBe(200); + expect(httpRes.status).toBe(403); const body = (await httpRes.json()) as { - object?: string; - choices?: Array<{ message?: { content?: string } }>; + error?: { type?: string; message?: string }; }; - expect(body.object).toBe("chat.completion"); - expect(body.choices?.[0]?.message?.content).toBe("hello"); - expect(agentCommand).toHaveBeenCalledTimes(1); + expect(body.error?.type).toBe("forbidden"); + expect(body.error?.message).toBe("missing scope: operator.write"); + expect(agentCommand).toHaveBeenCalledTimes(0); } finally { started.ws.close(); await started.server.close(); @@ -169,7 +167,7 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { } }); - test("operator.write can still use /v1/responses", async () => { + test("bearer auth cannot self-assert operator.write for /v1/responses", async () => { const started = await startServerWithClient("secret", { openResponsesEnabled: true, }); @@ -192,23 +190,42 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { }), }); - expect(httpRes.status).toBe(200); + expect(httpRes.status).toBe(403); const body = (await httpRes.json()) as { - object?: string; - status?: string; - output?: Array<{ - type?: string; - role?: string; - content?: Array<{ type?: string; text?: string }>; - }>; + error?: { type?: string; message?: string }; }; - expect(body.object).toBe("response"); - expect(body.status).toBe("completed"); - expect(body.output?.[0]?.type).toBe("message"); - expect(body.output?.[0]?.role).toBe("assistant"); - expect(body.output?.[0]?.content?.[0]?.type).toBe("output_text"); - expect(body.output?.[0]?.content?.[0]?.text).toBe("hello"); - expect(agentCommand).toHaveBeenCalledTimes(1); + expect(body.error?.type).toBe("forbidden"); + expect(body.error?.message).toBe("missing scope: operator.write"); + expect(agentCommand).toHaveBeenCalledTimes(0); + } finally { + started.ws.close(); + await started.server.close(); + started.envSnapshot.restore(); + } + }); + + test("bearer auth cannot use /tools/invoke", async () => { + const started = await startServerWithClient("secret"); + + try { + const httpRes = await fetch(`http://127.0.0.1:${started.port}/tools/invoke`, { + method: "POST", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + }, + body: JSON.stringify({ + tool: "agents_list", + args: {}, + }), + }); + + expect(httpRes.status).toBe(403); + const body = (await httpRes.json()) as { + error?: { type?: string; message?: string }; + }; + expect(body.error?.type).toBe("forbidden"); + expect(body.error?.message).toBe("gateway bearer auth cannot invoke tools over HTTP"); } 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 03e8d6ed84f..c8dfc74b3d5 100644 --- a/src/gateway/sessions-history-http.test.ts +++ b/src/gateway/sessions-history-http.test.ts @@ -62,7 +62,7 @@ async function fetchSessionHistory( headers?: HeadersInit; }, ) { - const headers = new Headers(AUTH_HEADER); + const headers = new Headers(); for (const [key, value] of new Headers(READ_SCOPE_HEADER).entries()) { headers.set(key, value); } @@ -80,7 +80,11 @@ async function fetchSessionHistory( async function withGatewayHarness( run: (harness: Awaited>) => Promise, ) { - const harness = await createGatewaySuiteHarness(); + const harness = await createGatewaySuiteHarness({ + serverOptions: { + auth: { mode: "none" }, + }, + }); try { return await run(harness); } finally { @@ -422,17 +426,20 @@ 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(200); + 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 702fc1b0b26..db11823b023 100644 --- a/src/gateway/sessions-history-http.ts +++ b/src/gateway/sessions-history-http.ts @@ -6,17 +6,17 @@ 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, - resolveGatewayRequestedOperatorScopes, -} from "./http-auth-helpers.js"; import { sendInvalidRequest, sendJson, sendMethodNotAllowed, setSseHeaders, } from "./http-common.js"; -import { getHeader } from "./http-utils.js"; +import { + authorizeGatewayHttpRequestOrReply, + getHeader, + resolveTrustedHttpOperatorScopes, +} from "./http-utils.js"; import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; import { attachOpenClawTranscriptMeta, @@ -158,7 +158,7 @@ export async function handleSessionHistoryHttpRequest( } const cfg = loadConfig(); - const ok = await authorizeGatewayBearerRequestOrReply({ + const requestAuth = await authorizeGatewayHttpRequestOrReply({ req, res, auth: opts.auth, @@ -166,13 +166,13 @@ export async function handleSessionHistoryHttpRequest( allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); - if (!ok) { + if (!requestAuth) { 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 requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth); const scopeAuth = authorizeOperatorScopesForMethod("chat.history", requestedScopes); if (!scopeAuth.allowed) { sendJson(res, 403, { diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index cca11c2c6e8..8a9a0a3321f 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -7,8 +7,6 @@ type RunBeforeToolCallHook = typeof runBeforeToolCallHookType; type RunBeforeToolCallHookArgs = Parameters[0]; type RunBeforeToolCallHookResult = Awaited>; -const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890"; - const hookMocks = vi.hoisted(() => ({ resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })), runBeforeToolCallHook: vi.fn( @@ -50,7 +48,7 @@ vi.mock("../config/sessions.js", () => ({ })); vi.mock("./auth.js", () => ({ - authorizeHttpGatewayConnect: async () => ({ ok: true }), + authorizeHttpGatewayConnect: vi.fn(async () => ({ ok: true })), })); vi.mock("../logger.js", () => ({ @@ -197,6 +195,7 @@ vi.mock("../agents/pi-tools.before-tool-call.js", () => ({ runBeforeToolCallHook: hookMocks.runBeforeToolCallHook, })); +const { authorizeHttpGatewayConnect } = await import("./auth.js"); const { handleToolsInvokeHttpRequest } = await import("./tools-invoke-http.js"); let pluginHttpHandlers: Array<(req: IncomingMessage, res: ServerResponse) => Promise> = []; @@ -208,7 +207,7 @@ beforeAll(async () => { sharedServer = createServer((req, res) => { void (async () => { const handled = await handleToolsInvokeHttpRequest(req, res, { - auth: { mode: "token", token: TEST_GATEWAY_TOKEN, allowTailscale: false }, + auth: { mode: "none", allowTailscale: false }, }); if (handled) { return; @@ -260,17 +259,11 @@ beforeEach(() => { params: args.params, }), ); + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValue({ ok: true }); }); -const resolveGatewayToken = (): string => TEST_GATEWAY_TOKEN; -const gatewayAuthHeaders = () => ({ - authorization: `Bearer ${resolveGatewayToken()}`, - "x-openclaw-scopes": "operator.write", -}); -const gatewayAdminHeaders = () => ({ - authorization: `Bearer ${resolveGatewayToken()}`, - "x-openclaw-scopes": "operator.admin", -}); +const gatewayAuthHeaders = () => ({ "x-openclaw-scopes": "operator.write" }); +const gatewayAdminHeaders = () => ({ "x-openclaw-scopes": "operator.admin" }); const allowAgentsListForMain = () => { cfg = { @@ -440,6 +433,36 @@ describe("POST /tools/invoke", () => { }); }); + it("blocks trusted-proxy local-direct token fallback from invoking tools over HTTP", async () => { + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValueOnce({ + ok: true, + method: "token", + }); + + const res = await postToolsInvoke({ + port: sharedPort, + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + }, + body: { + tool: "agents_list", + action: "json", + args: {}, + sessionKey: "main", + }, + }); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ + ok: false, + error: { + type: "forbidden", + message: "gateway bearer auth cannot invoke tools over HTTP", + }, + }); + }); + it("uses before_tool_call adjusted params for HTTP tool execution", async () => { setMainAllowedTools({ allow: ["tools_invoke_test"] }); hookMocks.runBeforeToolCallHook.mockImplementationOnce(async () => ({ @@ -718,9 +741,7 @@ describe("POST /tools/invoke", () => { const res = await invokeTool({ port: sharedPort, - headers: { - authorization: `Bearer ${resolveGatewayToken()}`, - }, + headers: {}, tool: "agents_list", sessionKey: "main", }); @@ -735,6 +756,36 @@ describe("POST /tools/invoke", () => { }); }); + it("blocks trusted-proxy local-direct token fallback from invoking tools over HTTP", async () => { + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValueOnce({ + ok: true, + method: "token", + }); + + const res = await postToolsInvoke({ + port: sharedPort, + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + }, + body: { + tool: "agents_list", + action: "json", + args: {}, + sessionKey: "main", + }, + }); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ + ok: false, + error: { + type: "forbidden", + message: "gateway bearer auth cannot invoke tools over HTTP", + }, + }); + }); + it("applies owner-only tool policy on the HTTP path", async () => { setMainAllowedTools({ allow: ["owner_only_test"] }); diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 43d08b28e1c..66d7b1a9a13 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -29,17 +29,17 @@ import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "../security/dangerous-tools.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import type { AuthRateLimiter } from "./auth-rate-limit.js"; import type { ResolvedGatewayAuth } from "./auth.js"; -import { - authorizeGatewayBearerRequestOrReply, - resolveGatewayRequestedOperatorScopes, -} from "./http-auth-helpers.js"; import { readJsonBodyOrError, sendInvalidRequest, sendJson, sendMethodNotAllowed, } from "./http-common.js"; -import { getHeader } from "./http-utils.js"; +import { + authorizeGatewayHttpRequestOrReply, + getHeader, + resolveTrustedHttpOperatorScopes, +} from "./http-utils.js"; import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; const DEFAULT_BODY_BYTES = 2 * 1024 * 1024; @@ -161,7 +161,7 @@ export async function handleToolsInvokeHttpRequest( } const cfg = loadConfig(); - const ok = await authorizeGatewayBearerRequestOrReply({ + const requestAuth = await authorizeGatewayHttpRequestOrReply({ req, res, auth: opts.auth, @@ -169,11 +169,22 @@ export async function handleToolsInvokeHttpRequest( allowRealIpFallback: opts.allowRealIpFallback ?? cfg.gateway?.allowRealIpFallback, rateLimiter: opts.rateLimiter, }); - if (!ok) { + if (!requestAuth) { return true; } - const requestedScopes = resolveGatewayRequestedOperatorScopes(req); + if (!requestAuth.trustDeclaredOperatorScopes) { + sendJson(res, 403, { + ok: false, + error: { + type: "forbidden", + message: "gateway bearer auth cannot invoke tools over HTTP", + }, + }); + return true; + } + + const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth); const scopeAuth = authorizeOperatorScopesForMethod("agent", requestedScopes); if (!scopeAuth.allowed) { sendJson(res, 403, {