From 0633406ff608b77e781204aa6a35abca27299c14 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 31 Mar 2026 16:48:15 +0900 Subject: [PATCH] fix(gateway): restore compat HTTP operator auth --- SECURITY.md | 7 ++ docs/gateway/openai-http-api.md | 14 ++++ docs/gateway/openresponses-http-api.md | 13 ++++ docs/gateway/security/index.md | 5 +- docs/gateway/tools-invoke-http-api.md | 13 ++-- src/gateway/embeddings-http.test.ts | 33 ++++----- src/gateway/embeddings-http.ts | 2 + src/gateway/http-endpoint-helpers.test.ts | 38 +++++++++++ src/gateway/http-endpoint-helpers.ts | 8 ++- .../http-utils.request-context.test.ts | 62 +++++++++++++++++ src/gateway/http-utils.ts | 33 ++++++++- src/gateway/models-http.test.ts | 28 ++++++++ src/gateway/models-http.ts | 4 +- src/gateway/openai-http.test.ts | 40 +++++++++++ src/gateway/openai-http.ts | 18 ++++- src/gateway/openresponses-http.test.ts | 46 +++++++++++++ src/gateway/openresponses-http.ts | 12 +++- ...atible-http-write-scope-bypass.poc.test.ts | 68 ++++++++----------- 18 files changed, 369 insertions(+), 75 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index cd8006ac6d34..33c515eef65b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -57,6 +57,7 @@ These are frequently reported but are typically closed with no code change: - Reports that only show a malicious plugin executing privileged actions after a trusted operator installs/enables it. - Reports that assume per-user multi-tenant authorization on a shared gateway host/config. - Reports that treat the Gateway HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) as if they implemented scoped operator auth (`operator.write` vs `operator.admin`). These endpoints authenticate the shared Gateway bearer secret/password and are documented full operator-access surfaces, not per-user/per-scope boundaries. +- Reports that assume `x-openclaw-scopes` can reduce or redefine shared-secret bearer auth on the OpenAI-compatible HTTP endpoints. For shared-secret auth (`gateway.auth.mode="token"` or `"password"`), those endpoints ignore narrower bearer-declared scopes and restore the full default operator scope set plus owner semantics. - Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries. - ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass. - Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive. @@ -94,6 +95,12 @@ OpenClaw does **not** model one gateway as a multi-tenant, adversarial user boun - Authenticated Gateway callers are treated as trusted operators for that gateway instance. - The HTTP compatibility endpoints (`POST /v1/chat/completions`, `POST /v1/responses`) are in that same trusted-operator bucket. Passing Gateway bearer auth there is equivalent to operator access for that gateway; they do not implement a narrower `operator.write` vs `operator.admin` trust split. +- Concretely, on the OpenAI-compatible HTTP surface: + - shared-secret bearer auth (`token` / `password`) authenticates possession of the gateway operator secret + - those requests receive the full default operator scope set (`operator.admin`, `operator.read`, `operator.write`, `operator.approvals`, `operator.pairing`) + - chat-turn endpoints (`/v1/chat/completions`, `/v1/responses`) also treat those shared-secret callers as owner senders for owner-only tool policy + - narrower `x-openclaw-scopes` headers are ignored for that shared-secret path + - only identity-bearing HTTP modes (for example trusted proxy auth or `gateway.auth.mode="none"` on private ingress) honor declared per-request operator scopes - Session identifiers (`sessionKey`, session IDs, labels) are routing controls, not per-user authorization boundaries. - If one operator can view data from another operator on the same gateway, that is expected in this trust model. - OpenClaw can technically run multiple gateway instances on one machine, but recommended operations are clean separation by trust boundary. diff --git a/docs/gateway/openai-http-api.md b/docs/gateway/openai-http-api.md index ef7fa745066a..22cff0e16b98 100644 --- a/docs/gateway/openai-http-api.md +++ b/docs/gateway/openai-http-api.md @@ -43,9 +43,23 @@ Treat this endpoint as a **full operator-access** surface for the gateway instan - A valid Gateway token/password for this endpoint should be treated like an owner/operator credential. - Requests run through the same control-plane agent path as trusted operator actions. - There is no separate non-owner/per-user tool boundary on this endpoint; once a caller passes Gateway auth here, OpenClaw treats that caller as a trusted operator for this gateway. +- For shared-secret auth modes (`token` and `password`), the endpoint restores the normal full operator defaults even if the caller sends a narrower `x-openclaw-scopes` header. +- Trusted identity-bearing HTTP modes (for example trusted proxy auth or `gateway.auth.mode="none"`) still honor the declared operator scopes on the request. - If the target agent policy allows sensitive tools, this endpoint can use them. - Keep this endpoint on loopback/tailnet/private ingress only; do not expose it directly to the public internet. +Auth matrix: + +- `gateway.auth.mode="token"` or `"password"` + `Authorization: Bearer ...` + - proves possession of the shared gateway operator secret + - ignores narrower `x-openclaw-scopes` + - restores the full default operator scope set + - treats chat turns on this endpoint as owner-sender turns +- trusted identity-bearing HTTP modes (for example trusted proxy auth, or `gateway.auth.mode="none"` on private ingress) + - authenticate some outer trusted identity or deployment boundary + - honor the declared `x-openclaw-scopes` header + - only get owner semantics when `operator.admin` is actually present in those declared scopes + See [Security](/gateway/security) and [Remote access](/gateway/remote). ## Agent-first model contract diff --git a/docs/gateway/openresponses-http-api.md b/docs/gateway/openresponses-http-api.md index a73b91bbfde5..7e229cbfe587 100644 --- a/docs/gateway/openresponses-http-api.md +++ b/docs/gateway/openresponses-http-api.md @@ -24,11 +24,24 @@ Operational behavior matches [OpenAI Chat Completions](/gateway/openai-http-api) - use `Authorization: Bearer ` with the normal Gateway auth config - treat the endpoint as full operator access for the gateway instance +- for shared-secret auth modes (`token` and `password`), ignore narrower bearer-declared `x-openclaw-scopes` values and restore the normal full operator defaults +- for trusted identity-bearing HTTP modes (for example trusted proxy auth or `gateway.auth.mode="none"`), still honor the declared operator scopes on the request - select agents with `model: "openclaw"`, `model: "openclaw/default"`, `model: "openclaw/"`, or `x-openclaw-agent-id` - use `x-openclaw-model` when you want to override the selected agent's backend model - use `x-openclaw-session-key` for explicit session routing - use `x-openclaw-message-channel` when you want a non-default synthetic ingress channel context +Auth matrix: + +- `gateway.auth.mode="token"` or `"password"` + `Authorization: Bearer ...` + - proves possession of the shared gateway operator secret + - ignores narrower `x-openclaw-scopes` + - restores the full default operator scope set + - treats chat turns on this endpoint as owner-sender turns +- trusted identity-bearing HTTP modes (for example trusted proxy auth, or `gateway.auth.mode="none"` on private ingress) + - honor the declared `x-openclaw-scopes` header + - only get owner semantics when `operator.admin` is actually present in those declared scopes + Enable or disable this endpoint with `gateway.http.endpoints.responses.enabled`. The same compatibility surface also includes: diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 7c20971d8865..0ffe4ba381a6 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -802,7 +802,10 @@ still require token/password auth. Important boundary note: - Gateway HTTP bearer auth is effectively all-or-nothing operator access. -- Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, `/tools/invoke`, or `/api/channels/*` as full-access operator secrets for that gateway. +- Treat credentials that can call `/v1/chat/completions`, `/v1/responses`, or `/api/channels/*` as full-access operator secrets for that gateway. +- On the OpenAI-compatible HTTP surface, shared-secret bearer auth restores the full default operator scopes and owner semantics for agent turns; narrower `x-openclaw-scopes` values do not reduce that shared-secret path. +- Per-request scope semantics on HTTP only apply when the request comes from an identity-bearing mode such as trusted proxy auth or `gateway.auth.mode="none"` on a private ingress. +- `/tools/invoke` is stricter: shared-secret bearer auth is rejected there, and the endpoint only runs when the HTTP request carries a trusted operator identity plus declared scopes. - Do not share these credentials with untrusted callers; prefer separate gateways per trust boundary. **Trust assumption:** tokenless Serve auth assumes the gateway host is trusted. diff --git a/docs/gateway/tools-invoke-http-api.md b/docs/gateway/tools-invoke-http-api.md index 303b10945188..7e5392896bea 100644 --- a/docs/gateway/tools-invoke-http-api.md +++ b/docs/gateway/tools-invoke-http-api.md @@ -8,7 +8,7 @@ title: "Tools Invoke API" # Tools Invoke (HTTP) -OpenClaw’s Gateway exposes a simple HTTP endpoint for invoking a single tool directly. It is always enabled and uses Gateway auth plus tool policy, but callers that pass Gateway bearer auth are treated as trusted operators for that gateway. +OpenClaw’s Gateway exposes a simple HTTP endpoint for invoking a single tool directly. It is always enabled and uses Gateway auth plus tool policy, but unlike the OpenAI-compatible `/v1/*` surface, shared-secret bearer auth is not enough to use it. - `POST /tools/invoke` - Same port as the Gateway (WS + HTTP multiplex): `http://:/tools/invoke` @@ -26,7 +26,8 @@ Notes: - When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`). - When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`). - If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`. -- Treat this credential as a full-access operator secret for that gateway. It is not a scoped API token for a narrower `/tools/invoke` role. +- Shared-secret bearer auth (`gateway.auth.mode="token"` or `"password"`) is rejected here with `403`. +- To use `/tools/invoke`, the request must come from an HTTP mode that carries a trusted operator identity and declared scopes (for example trusted proxy auth or `gateway.auth.mode="none"` on a private ingress). ## Request body @@ -62,7 +63,7 @@ If a tool is not allowed by policy, the endpoint returns **404**. Important boundary notes: -- `POST /tools/invoke` is in the same trusted-operator bucket as other Gateway HTTP APIs such as `/v1/chat/completions`, `/v1/responses`, and `/api/channels/*`. +- `POST /tools/invoke` is intentionally stricter than `/v1/chat/completions` and `/v1/responses`: shared-secret bearer auth does not unlock direct tool invocation over HTTP. - Exec approvals are operator guardrails, not a separate authorization boundary for this HTTP endpoint. If a tool is reachable here via Gateway auth + tool policy, `/tools/invoke` does not add an extra per-call approval prompt. - Do not share Gateway bearer credentials with untrusted callers. If you need separation across trust boundaries, run separate gateways (and ideally separate OS users/hosts). @@ -116,11 +117,15 @@ To help group policies resolve context, you can optionally set: ```bash curl -sS http://127.0.0.1:18789/tools/invoke \ - -H 'Authorization: Bearer YOUR_TOKEN' \ -H 'Content-Type: application/json' \ + -H 'x-openclaw-scopes: operator.write' \ -d '{ "tool": "sessions_list", "action": "json", "args": {} }' ``` + +Use this example only on a private ingress with a trusted identity-bearing HTTP +mode (for example trusted proxy auth or `gateway.auth.mode="none"`). +Shared-secret bearer auth does not work on `/tools/invoke`. diff --git a/src/gateway/embeddings-http.test.ts b/src/gateway/embeddings-http.test.ts index aa8b7832497a..06ad5700c729 100644 --- a/src/gateway/embeddings-http.test.ts +++ b/src/gateway/embeddings-http.test.ts @@ -167,7 +167,7 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { expect(json.error?.type).toBe("invalid_request_error"); }); - it("rejects operator scopes that lack write access", async () => { + it("ignores narrower declared scopes for shared-secret bearer auth", async () => { const res = await postEmbeddings( { model: "openclaw/default", @@ -175,17 +175,14 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { }, { "x-openclaw-scopes": "operator.read" }, ); - expect(res.status).toBe(403); + expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ - ok: false, - error: { - type: "forbidden", - message: "missing scope: operator.write", - }, + object: "list", + data: [{ object: "embedding", embedding: [0.1, 0.2] }], }); }); - it("rejects requests with no declared operator scopes", async () => { + it("allows requests with an empty declared scopes header", async () => { const res = await postEmbeddings( { model: "openclaw/default", @@ -193,17 +190,14 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { }, { "x-openclaw-scopes": "" }, ); - expect(res.status).toBe(403); + expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ - ok: false, - error: { - type: "forbidden", - message: "missing scope: operator.write", - }, + object: "list", + data: [{ object: "embedding", embedding: [0.1, 0.2] }], }); }); - it("rejects requests when the operator scopes header is missing", async () => { + it("allows requests when the operator scopes header is missing", async () => { const res = await fetch(`http://127.0.0.1:${enabledPort}/v1/embeddings`, { method: "POST", headers: { @@ -215,13 +209,10 @@ describe("OpenAI-compatible embeddings HTTP API (e2e)", () => { input: "hello", }), }); - expect(res.status).toBe(403); + expect(res.status).toBe(200); await expect(res.json()).resolves.toMatchObject({ - ok: false, - error: { - type: "forbidden", - message: "missing scope: operator.write", - }, + object: "list", + data: [{ object: "embedding", embedding: [0.1, 0.2] }], }); }); diff --git a/src/gateway/embeddings-http.ts b/src/gateway/embeddings-http.ts index c82821d3d8e5..9a8e2e2672c8 100644 --- a/src/gateway/embeddings-http.ts +++ b/src/gateway/embeddings-http.ts @@ -19,6 +19,7 @@ import { getHeader, resolveAgentIdForRequest, resolveAgentIdFromModel, + resolveOpenAiCompatibleHttpOperatorScopes, } from "./http-utils.js"; type OpenAiEmbeddingsHttpOptions = { @@ -210,6 +211,7 @@ export async function handleOpenAiEmbeddingsHttpRequest( const handled = await handleGatewayPostJsonEndpoint(req, res, { pathname: "/v1/embeddings", requiredOperatorMethod: "chat.send", + resolveOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopes, auth: opts.auth, trustedProxies: opts.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback, diff --git a/src/gateway/http-endpoint-helpers.test.ts b/src/gateway/http-endpoint-helpers.test.ts index e0ada155900d..cc0d7de4eaaf 100644 --- a/src/gateway/http-endpoint-helpers.test.ts +++ b/src/gateway/http-endpoint-helpers.test.ts @@ -138,4 +138,42 @@ describe("handleGatewayPostJsonEndpoint", () => { ); expect(vi.mocked(readJsonBodyOrError)).not.toHaveBeenCalled(); }); + + it("uses a custom operator scope resolver when provided", async () => { + vi.mocked(authorizeGatewayHttpRequestOrReply).mockResolvedValue({ + authMethod: "token", + trustDeclaredOperatorScopes: false, + }); + vi.mocked(authorizeOperatorScopesForMethod).mockReturnValue({ allowed: true }); + vi.mocked(readJsonBodyOrError).mockResolvedValue({ ok: true }); + const resolveOperatorScopes = vi.fn(() => ["operator.admin", "operator.write"]); + + const result = await handleGatewayPostJsonEndpoint( + { + url: "/v1/ok", + method: "POST", + headers: { host: "localhost" }, + } as unknown as IncomingMessage, + {} as unknown as ServerResponse, + { + pathname: "/v1/ok", + auth: {} as unknown as ResolvedGatewayAuth, + maxBodyBytes: 123, + requiredOperatorMethod: "chat.send", + resolveOperatorScopes, + }, + ); + + expect(resolveOperatorScopes).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + authMethod: "token", + trustDeclaredOperatorScopes: false, + }), + ); + expect(result).toEqual({ + body: { ok: true }, + requestAuth: { authMethod: "token", trustDeclaredOperatorScopes: false }, + }); + }); }); diff --git a/src/gateway/http-endpoint-helpers.ts b/src/gateway/http-endpoint-helpers.ts index c69a608ee1d8..8b29ce5ebf89 100644 --- a/src/gateway/http-endpoint-helpers.ts +++ b/src/gateway/http-endpoint-helpers.ts @@ -20,6 +20,10 @@ export async function handleGatewayPostJsonEndpoint( allowRealIpFallback?: boolean; rateLimiter?: AuthRateLimiter; requiredOperatorMethod?: "chat.send" | (string & Record); + resolveOperatorScopes?: ( + req: IncomingMessage, + requestAuth: AuthorizedGatewayHttpRequest, + ) => string[]; }, ): Promise { const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`); @@ -45,7 +49,9 @@ export async function handleGatewayPostJsonEndpoint( } if (opts.requiredOperatorMethod) { - const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth); + const requestedScopes = + opts.resolveOperatorScopes?.(req, requestAuth) ?? + resolveTrustedHttpOperatorScopes(req, requestAuth); const scopeAuth = authorizeOperatorScopesForMethod( opts.requiredOperatorMethod, requestedScopes, diff --git a/src/gateway/http-utils.request-context.test.ts b/src/gateway/http-utils.request-context.test.ts index 9bcc91c20d4b..8624529706e1 100644 --- a/src/gateway/http-utils.request-context.test.ts +++ b/src/gateway/http-utils.request-context.test.ts @@ -1,6 +1,8 @@ import type { IncomingMessage } from "node:http"; import { describe, expect, it } from "vitest"; import { + resolveOpenAiCompatibleHttpOperatorScopes, + resolveOpenAiCompatibleHttpSenderIsOwner, resolveGatewayRequestContext, resolveHttpSenderIsOwner, resolveTrustedHttpOperatorScopes, @@ -122,3 +124,63 @@ describe("resolveHttpSenderIsOwner", () => { ).toBe(false); }); }); + +describe("resolveOpenAiCompatibleHttpOperatorScopes", () => { + it("restores default operator scopes for shared-secret bearer auth", () => { + const scopes = resolveOpenAiCompatibleHttpOperatorScopes( + createReq({ + authorization: "Bearer secret", + "x-openclaw-scopes": "operator.approvals", + }), + { authMethod: "token", trustDeclaredOperatorScopes: false }, + ); + + expect(scopes).toEqual([ + "operator.admin", + "operator.read", + "operator.write", + "operator.approvals", + "operator.pairing", + ]); + }); + + it("keeps declared scopes for trusted HTTP identity-bearing requests", () => { + const scopes = resolveOpenAiCompatibleHttpOperatorScopes( + createReq({ + "x-openclaw-scopes": "operator.write", + }), + { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true }, + ); + + expect(scopes).toEqual(["operator.write"]); + }); +}); + +describe("resolveOpenAiCompatibleHttpSenderIsOwner", () => { + it("treats shared-secret bearer auth as owner on the compat surface", () => { + expect( + resolveOpenAiCompatibleHttpSenderIsOwner( + createReq({ + authorization: "Bearer secret", + "x-openclaw-scopes": "operator.approvals", + }), + { authMethod: "token", trustDeclaredOperatorScopes: false }, + ), + ).toBe(true); + }); + + it("still requires operator.admin for trusted scope-bearing requests", () => { + expect( + resolveOpenAiCompatibleHttpSenderIsOwner( + createReq({ "x-openclaw-scopes": "operator.write" }), + { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true }, + ), + ).toBe(false); + expect( + resolveOpenAiCompatibleHttpSenderIsOwner( + createReq({ "x-openclaw-scopes": "operator.admin" }), + { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true }, + ), + ).toBe(true); + }); +}); diff --git a/src/gateway/http-utils.ts b/src/gateway/http-utils.ts index 4b031ffd63da..8fe8637f1624 100644 --- a/src/gateway/http-utils.ts +++ b/src/gateway/http-utils.ts @@ -17,7 +17,7 @@ import { type ResolvedGatewayAuth, } from "./auth.js"; import { sendGatewayAuthFailure } from "./http-common.js"; -import { ADMIN_SCOPE } from "./method-scopes.js"; +import { ADMIN_SCOPE, CLI_DEFAULT_OPERATOR_SCOPES } from "./method-scopes.js"; import { loadGatewayModelCatalog } from "./server-model-catalog.js"; export const OPENCLAW_MODEL_ID = "openclaw"; @@ -93,6 +93,10 @@ export async function authorizeGatewayHttpRequestOrReply(params: { } return { authMethod: authResult.method, + // Shared-secret bearer auth proves possession of the gateway secret, but it + // does not prove a narrower per-request operator identity. HTTP endpoints + // must opt in explicitly if they want to treat that shared-secret path as a + // full trusted-operator surface. trustDeclaredOperatorScopes: !usesSharedSecretGatewayMethod(authResult.method), }; } @@ -126,6 +130,19 @@ export function resolveTrustedHttpOperatorScopes( .filter((scope) => scope.length > 0); } +export function resolveOpenAiCompatibleHttpOperatorScopes( + req: IncomingMessage, + requestAuth: AuthorizedGatewayHttpRequest, +): string[] { + if (usesSharedSecretGatewayMethod(requestAuth.authMethod)) { + // OpenAI-compatible HTTP bearer auth is documented as a trusted-operator + // surface. Shared-secret auth does not carry a narrower per-request scope + // identity, so restore the normal operator defaults for this surface. + return [...CLI_DEFAULT_OPERATOR_SCOPES]; + } + return resolveTrustedHttpOperatorScopes(req, requestAuth); +} + export function resolveHttpSenderIsOwner( req: IncomingMessage, authOrRequest?: @@ -135,6 +152,20 @@ export function resolveHttpSenderIsOwner( return resolveTrustedHttpOperatorScopes(req, authOrRequest).includes(ADMIN_SCOPE); } +export function resolveOpenAiCompatibleHttpSenderIsOwner( + req: IncomingMessage, + requestAuth: AuthorizedGatewayHttpRequest, +): boolean { + if (usesSharedSecretGatewayMethod(requestAuth.authMethod)) { + // The OpenAI-compatible HTTP surface treats shared-secret bearer auth as + // trusted operator access for the whole gateway. There is no separate owner + // authentication primitive on that path, so owner-only tools remain + // available to those compat requests. + return true; + } + return resolveHttpSenderIsOwner(req, requestAuth); +} + 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 0a93a697a059..c79ba3a425b6 100644 --- a/src/gateway/models-http.test.ts +++ b/src/gateway/models-http.test.ts @@ -28,6 +28,15 @@ async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: }); } +async function startTokenServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) { + return await startGatewayServer(port, { + host: "127.0.0.1", + auth: { mode: "token", token: "secret" }, + controlUiEnabled: false, + openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? false, + }); +} + async function getModels(pathname: string, headers?: Record) { return await fetch(`http://127.0.0.1:${enabledPort}${pathname}`, { headers: { @@ -120,4 +129,23 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => { await server.close({ reason: "models disabled test done" }); } }); + + it("treats shared-secret bearer auth as full compat operator access", async () => { + const port = await getFreePort(); + const server = await startTokenServer(port, { openAiChatCompletionsEnabled: true }); + try { + const res = await fetch(`http://127.0.0.1:${port}/v1/models`, { + headers: { + authorization: "Bearer secret", + "x-openclaw-scopes": "operator.approvals", + }, + }); + expect(res.status).toBe(200); + const json = (await res.json()) as { object?: string; data?: Array<{ id?: string }> }; + expect(json.object).toBe("list"); + expect(json.data?.map((entry) => entry.id)).toContain("openclaw/default"); + } finally { + await server.close({ reason: "models token auth compat test done" }); + } + }); }); diff --git a/src/gateway/models-http.ts b/src/gateway/models-http.ts index 3db7ab2e80aa..fc774aab681b 100644 --- a/src/gateway/models-http.ts +++ b/src/gateway/models-http.ts @@ -10,7 +10,7 @@ import { authorizeGatewayHttpRequestOrReply, type AuthorizedGatewayHttpRequest, resolveAgentIdFromModel, - resolveTrustedHttpOperatorScopes, + resolveOpenAiCompatibleHttpOperatorScopes, } from "./http-utils.js"; import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; @@ -89,7 +89,7 @@ export async function handleOpenAiModelsHttpRequest( return true; } - const requestedScopes = resolveTrustedHttpOperatorScopes(req, requestAuth); + const requestedScopes = resolveOpenAiCompatibleHttpOperatorScopes(req, requestAuth); const scopeAuth = authorizeOperatorScopesForMethod("models.list", requestedScopes); if (!scopeAuth.allowed) { sendJson(res, 403, { diff --git a/src/gateway/openai-http.test.ts b/src/gateway/openai-http.test.ts index 6d21ead82ba2..08af7d66468f 100644 --- a/src/gateway/openai-http.test.ts +++ b/src/gateway/openai-http.test.ts @@ -47,6 +47,15 @@ async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: }); } +async function startTokenServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) { + return await startGatewayServer(port, { + host: "127.0.0.1", + auth: { mode: "token", token: "secret" }, + controlUiEnabled: false, + openAiChatCompletionsEnabled: opts?.openAiChatCompletionsEnabled ?? true, + }); +} + async function writeGatewayConfig(config: Record) { const configPath = process.env.OPENCLAW_CONFIG_PATH; if (!configPath) { @@ -840,4 +849,35 @@ describe("OpenAI-compatible HTTP API (e2e)", () => { // shared server } }); + + it("treats shared-secret bearer callers as owner operators", async () => { + const port = await getFreePort(); + const server = await startTokenServer(port); + try { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never); + + const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: "POST", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + "x-openclaw-scopes": "operator.approvals", + }, + body: JSON.stringify({ + model: "openclaw", + messages: [{ role: "user", content: "hi" }], + }), + }); + + expect(res.status).toBe(200); + const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(firstCall?.senderIsOwner).toBe(true); + await res.text(); + } finally { + await server.close({ reason: "openai token auth owner test done" }); + } + }); }); diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 138799f06984..3864e3839201 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -27,7 +27,12 @@ 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, + resolveOpenAiCompatibleHttpOperatorScopes, + resolveOpenAiCompatibleHttpSenderIsOwner, +} from "./http-utils.js"; import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; type OpenAiHttpOptions = { @@ -106,6 +111,7 @@ function buildAgentCommandInput(params: { sessionKey: string; runId: string; messageChannel: string; + senderIsOwner: boolean; }) { return { message: params.prompt.message, @@ -117,8 +123,7 @@ function buildAgentCommandInput(params: { deliver: false as const, messageChannel: params.messageChannel, bestEffortDeliver: false as const, - // OpenAI-compatible HTTP ingress is external input and must not inherit owner-only tools. - senderIsOwner: false as const, + senderIsOwner: params.senderIsOwner, allowModelOverride: true as const, }; } @@ -417,6 +422,9 @@ export async function handleOpenAiHttpRequest( const handled = await handleGatewayPostJsonEndpoint(req, res, { pathname: "/v1/chat/completions", requiredOperatorMethod: "chat.send", + // Compat HTTP uses a different scope model from generic HTTP helpers: + // shared-secret bearer auth is treated as full operator access here. + resolveOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopes, auth: opts.auth, trustedProxies: opts.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback, @@ -429,6 +437,9 @@ export async function handleOpenAiHttpRequest( if (!handled) { return true; } + // On the compat surface, shared-secret bearer auth is also treated as an + // owner sender so owner-only tool policy matches the documented contract. + const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, handled.requestAuth); const payload = coerceRequest(handled.body); const stream = Boolean(payload.stream); @@ -492,6 +503,7 @@ export async function handleOpenAiHttpRequest( sessionKey, runId, messageChannel, + senderIsOwner, }); if (!stream) { diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index c432fb255939..abe065ddfb39 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -56,6 +56,21 @@ async function startServer(port: number, opts?: { openResponsesEnabled?: boolean ); } +async function startTokenServer(port: number, opts?: { openResponsesEnabled?: boolean }) { + const { startGatewayServer } = await import("./server.js"); + const serverOpts = { + host: "127.0.0.1", + auth: { mode: "token" as const, token: "secret" }, + controlUiEnabled: false, + } as const; + return await startGatewayServer( + port, + opts?.openResponsesEnabled === undefined + ? { ...serverOpts, openResponsesEnabled: true } + : { ...serverOpts, openResponsesEnabled: opts.openResponsesEnabled }, + ); +} + async function writeGatewayConfig(config: Record) { const configPath = process.env.OPENCLAW_CONFIG_PATH; if (!configPath) { @@ -755,6 +770,37 @@ describe("OpenResponses HTTP API (e2e)", () => { expect(streamingEvents.some((event) => event.event === "response.completed")).toBe(true); }); + it("treats shared-secret bearer callers as owner operators", async () => { + const port = await getFreePort(); + const server = await startTokenServer(port); + try { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ payloads: [{ text: "hello" }] } as never); + + const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, { + method: "POST", + headers: { + authorization: "Bearer secret", + "content-type": "application/json", + "x-openclaw-scopes": "operator.approvals", + }, + body: JSON.stringify({ + model: "openclaw", + input: "hi", + }), + }); + + expect(res.status).toBe(200); + const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(firstCall?.senderIsOwner).toBe(true); + await ensureResponseConsumed(res); + } finally { + await server.close({ reason: "openresponses token auth owner test done" }); + } + }); + it("preserves assistant text alongside non-stream function_call output", async () => { const port = enabledPort; agentCommand.mockClear(); diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 3cf09117e1ef..c8112ca26d67 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -41,6 +41,8 @@ import { resolveAgentIdForRequest, resolveGatewayRequestContext, resolveOpenAiCompatModelOverride, + resolveOpenAiCompatibleHttpOperatorScopes, + resolveOpenAiCompatibleHttpSenderIsOwner, } from "./http-utils.js"; import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; import { @@ -462,6 +464,9 @@ export async function handleOpenResponsesHttpRequest( const handled = await handleGatewayPostJsonEndpoint(req, res, { pathname: "/v1/responses", requiredOperatorMethod: "chat.send", + // Compat HTTP uses a different scope model from generic HTTP helpers: + // shared-secret bearer auth is treated as full operator access here. + resolveOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopes, auth: opts.auth, trustedProxies: opts.trustedProxies, allowRealIpFallback: opts.allowRealIpFallback, @@ -474,6 +479,9 @@ export async function handleOpenResponsesHttpRequest( if (!handled) { return true; } + // On the compat surface, shared-secret bearer auth is also treated as an + // owner sender so owner-only tool policy matches the documented contract. + const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, handled.requestAuth); // Validate request body with Zod const parseResult = CreateResponseBodySchema.safeParse(handled.body); @@ -704,7 +712,7 @@ export async function handleOpenResponsesHttpRequest( sessionKey, runId: responseId, messageChannel, - senderIsOwner: false, + senderIsOwner, deps, }); @@ -957,7 +965,7 @@ export async function handleOpenResponsesHttpRequest( sessionKey, runId: responseId, messageChannel, - senderIsOwner: false, + senderIsOwner, deps, }); 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 71fc735ca09e..d35d42e76e59 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 @@ -9,8 +9,8 @@ import { installGatewayTestHooks({ scope: "suite" }); -describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { - test("operator.approvals is denied by chat.send and /v1/chat/completions without operator.write", async () => { +describe("gateway OpenAI-compatible HTTP shared-secret auth", () => { + test("operator.approvals stays denied on WS chat.send but compat chat HTTP restores full operator defaults", async () => { const started = await startServerWithClient("secret", { openAiChatCompletionsEnabled: true, }); @@ -43,18 +43,18 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { }), }); - expect(httpRes.status).toBe(403); + expect(httpRes.status).toBe(200); const body = (await httpRes.json()) as { - error?: { type?: string; message?: string }; + id?: string; + object?: string; }; - expect(body.error?.type).toBe("forbidden"); - expect(body.error?.message).toBe("missing scope: operator.write"); - expect(agentCommand).toHaveBeenCalledTimes(0); + expect(body.object).toBe("chat.completion"); + expect(agentCommand).toHaveBeenCalledTimes(1); + const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(firstCall?.senderIsOwner).toBe(true); - // 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`, { @@ -69,13 +69,8 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { }), }); - expect(missingHeaderRes.status).toBe(403); - const missingHeaderBody = (await missingHeaderRes.json()) as { - error?: { type?: string; message?: string }; - }; - expect(missingHeaderBody.error?.type).toBe("forbidden"); - expect(missingHeaderBody.error?.message).toBe("missing scope: operator.write"); - expect(agentCommand).toHaveBeenCalledTimes(0); + expect(missingHeaderRes.status).toBe(200); + expect(agentCommand).toHaveBeenCalledTimes(1); } finally { started.ws.close(); await started.server.close(); @@ -83,7 +78,7 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { } }); - test("bearer auth cannot self-assert operator.write for /v1/chat/completions", async () => { + test("shared-secret bearer auth ignores narrower declared write scopes for /v1/chat/completions", async () => { const started = await startServerWithClient("secret", { openAiChatCompletionsEnabled: true, }); @@ -105,13 +100,8 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { }), }); - 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("missing scope: operator.write"); - expect(agentCommand).toHaveBeenCalledTimes(0); + expect(httpRes.status).toBe(200); + expect(agentCommand).toHaveBeenCalledTimes(1); } finally { started.ws.close(); await started.server.close(); @@ -119,7 +109,7 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { } }); - test("operator.approvals is denied by chat.send and /v1/responses without operator.write", async () => { + test("operator.approvals stays denied on WS chat.send but compat responses HTTP restores full operator defaults", async () => { const started = await startServerWithClient("secret", { openResponsesEnabled: true, }); @@ -153,13 +143,16 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { }), }); - expect(httpRes.status).toBe(403); + expect(httpRes.status).toBe(200); const body = (await httpRes.json()) as { - error?: { type?: string; message?: string }; + object?: string; }; - expect(body.error?.type).toBe("forbidden"); - expect(body.error?.message).toBe("missing scope: operator.write"); - expect(agentCommand).toHaveBeenCalledTimes(0); + expect(body.object).toBe("response"); + expect(agentCommand).toHaveBeenCalledTimes(1); + const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as + | { senderIsOwner?: boolean } + | undefined; + expect(firstCall?.senderIsOwner).toBe(true); } finally { started.ws.close(); await started.server.close(); @@ -167,7 +160,7 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { } }); - test("bearer auth cannot self-assert operator.write for /v1/responses", async () => { + test("shared-secret bearer auth ignores narrower declared write scopes for /v1/responses", async () => { const started = await startServerWithClient("secret", { openResponsesEnabled: true, }); @@ -190,13 +183,8 @@ describe("gateway OpenAI-compatible HTTP write-scope bypass PoC", () => { }), }); - 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("missing scope: operator.write"); - expect(agentCommand).toHaveBeenCalledTimes(0); + expect(httpRes.status).toBe(200); + expect(agentCommand).toHaveBeenCalledTimes(1); } finally { started.ws.close(); await started.server.close();