From cbfeecfab463b5e904b016973285e6085b11e1f7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 31 Mar 2026 22:54:38 +0900 Subject: [PATCH] fix(gateway): restore shared-secret HTTP tool invoke auth --- docs/gateway/security/index.md | 2 +- docs/gateway/tools-invoke-http-api.md | 32 ++++++++--- ...atible-http-write-scope-bypass.poc.test.ts | 11 ++-- .../tools-invoke-http.cron-regression.test.ts | 8 +-- src/gateway/tools-invoke-http.test.ts | 55 ++++++++++++------- src/gateway/tools-invoke-http.ts | 21 ++----- 6 files changed, 75 insertions(+), 54 deletions(-) diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index db8cfc8bb6a..55b23de1ab9 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -806,7 +806,7 @@ Important boundary note: - 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. +- `/tools/invoke` follows the same shared-secret rule: token/password bearer auth is treated as full operator access there too, while identity-bearing modes still honor 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 7e5392896be..95b36117e95 100644 --- a/docs/gateway/tools-invoke-http-api.md +++ b/docs/gateway/tools-invoke-http-api.md @@ -26,8 +26,29 @@ 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`. -- 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). + +## Security boundary (important) + +Treat this endpoint as a **full operator-access** surface for the gateway instance. + +- HTTP bearer auth here is not a narrow per-user scope model. +- A valid Gateway token/password for this endpoint should be treated like an owner/operator credential. +- 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. +- Shared-secret auth also treats direct tool invokes on this endpoint as owner-sender turns. +- Trusted identity-bearing HTTP modes (for example trusted proxy auth or `gateway.auth.mode="none"` on a private ingress) still honor the declared operator scopes on the request. +- 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 direct tool invokes 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 ## Request body @@ -63,7 +84,6 @@ If a tool is not allowed by policy, the endpoint returns **404**. Important boundary notes: -- `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). @@ -117,15 +137,11 @@ To help group policies resolve context, you can optionally set: ```bash curl -sS http://127.0.0.1:18789/tools/invoke \ + -H 'Authorization: Bearer secret' \ -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/server.openai-compatible-http-write-scope-bypass.poc.test.ts b/src/gateway/server.openai-compatible-http-write-scope-bypass.poc.test.ts index d35d42e76e5..58f7075c839 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 @@ -192,7 +192,7 @@ describe("gateway OpenAI-compatible HTTP shared-secret auth", () => { } }); - test("bearer auth cannot use /tools/invoke", async () => { + test("shared-secret bearer auth can use /tools/invoke", async () => { const started = await startServerWithClient("secret"); try { @@ -208,12 +208,13 @@ describe("gateway OpenAI-compatible HTTP shared-secret auth", () => { }), }); - expect(httpRes.status).toBe(403); + expect(httpRes.status).toBe(200); const body = (await httpRes.json()) as { - error?: { type?: string; message?: string }; + ok?: boolean; + result?: unknown; }; - expect(body.error?.type).toBe("forbidden"); - expect(body.error?.message).toBe("gateway bearer auth cannot invoke tools over HTTP"); + expect(body.ok).toBe(true); + expect(body.result).toBeTruthy(); } finally { started.ws.close(); await started.server.close(); diff --git a/src/gateway/tools-invoke-http.cron-regression.test.ts b/src/gateway/tools-invoke-http.cron-regression.test.ts index aae048c5788..789c4437605 100644 --- a/src/gateway/tools-invoke-http.cron-regression.test.ts +++ b/src/gateway/tools-invoke-http.cron-regression.test.ts @@ -125,7 +125,7 @@ describe("tools invoke HTTP denylist", () => { expect(cronRes.status).toBe(404); }); - it("keeps cron hidden even when explicitly enabled in gateway.tools.allow", async () => { + it("allows cron once gateway.tools.allow explicitly removes the default deny", async () => { cfg = { gateway: { tools: { @@ -136,10 +136,10 @@ describe("tools invoke HTTP denylist", () => { const cronRes = await invoke("cron", "operator.admin"); - expect(cronRes.status).toBe(404); + expect(cronRes.status).toBe(200); }); - it("keeps cron and gateway hidden under the coding profile", async () => { + it("keeps gateway denied under the coding profile while honoring explicit cron allow", async () => { cfg = { tools: { profile: "coding", @@ -154,7 +154,7 @@ describe("tools invoke HTTP denylist", () => { const cronRes = await invoke("cron", "operator.admin"); const gatewayRes = await invoke("gateway"); - expect(cronRes.status).toBe(404); + expect(cronRes.status).toBe(200); expect(gatewayRes.status).toBe(404); }); }); diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index 8a9a0a3321f..a275871364c 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -433,7 +433,8 @@ describe("POST /tools/invoke", () => { }); }); - it("blocks trusted-proxy local-direct token fallback from invoking tools over HTTP", async () => { + it("accepts shared-secret bearer auth on the HTTP tools surface", async () => { + allowAgentsListForMain(); vi.mocked(authorizeHttpGatewayConnect).mockResolvedValueOnce({ ok: true, method: "token", @@ -453,14 +454,8 @@ describe("POST /tools/invoke", () => { }, }); - 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", - }, - }); + const body = await expectOkInvokeResponse(res); + expect(body.result).toEqual({ ok: true, result: [] }); }); it("uses before_tool_call adjusted params for HTTP tool execution", async () => { @@ -632,7 +627,10 @@ describe("POST /tools/invoke", () => { sessionKey: "main", }); - expect(res.status).toBe(404); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.ok).toBe(false); + expect(body.error?.type).toBe("tool_error"); }); it("treats gateway.tools.deny as higher priority than gateway.tools.allow", async () => { @@ -756,7 +754,8 @@ describe("POST /tools/invoke", () => { }); }); - it("blocks trusted-proxy local-direct token fallback from invoking tools over HTTP", async () => { + it("treats shared-secret bearer auth as full operator access on /tools/invoke", async () => { + allowAgentsListForMain(); vi.mocked(authorizeHttpGatewayConnect).mockResolvedValueOnce({ ok: true, method: "token", @@ -776,14 +775,8 @@ describe("POST /tools/invoke", () => { }, }); - 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", - }, - }); + const body = await expectOkInvokeResponse(res); + expect(body.result).toEqual({ ok: true, result: [] }); }); it("applies owner-only tool policy on the HTTP path", async () => { @@ -801,7 +794,29 @@ describe("POST /tools/invoke", () => { tool: "owner_only_test", sessionKey: "main", }); - expect(allowedRes.status).toBe(404); + const allowedBody = await expectOkInvokeResponse(allowedRes); + expect(allowedBody.result).toEqual({ ok: true, result: "owner-only" }); + }); + + it("treats shared-secret bearer auth as owner on /tools/invoke", async () => { + setMainAllowedTools({ allow: ["owner_only_test"] }); + vi.mocked(authorizeHttpGatewayConnect).mockResolvedValueOnce({ + ok: true, + method: "token", + }); + + const res = await invokeTool({ + port: sharedPort, + headers: { + authorization: "Bearer secret", + "x-openclaw-scopes": "operator.approvals", + }, + tool: "owner_only_test", + sessionKey: "main", + }); + + const body = await expectOkInvokeResponse(res); + expect(body.result).toEqual({ ok: true, result: "owner-only" }); }); it("extends the HTTP deny list to high-risk execution and file tools", async () => { diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 66d7b1a9a13..a9710d47287 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -38,7 +38,8 @@ import { import { authorizeGatewayHttpRequestOrReply, getHeader, - resolveTrustedHttpOperatorScopes, + resolveOpenAiCompatibleHttpOperatorScopes, + resolveOpenAiCompatibleHttpSenderIsOwner, } from "./http-utils.js"; import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; @@ -173,18 +174,7 @@ export async function handleToolsInvokeHttpRequest( return true; } - 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 requestedScopes = resolveOpenAiCompatibleHttpOperatorScopes(req, requestAuth); const scopeAuth = authorizeOperatorScopesForMethod("agent", requestedScopes); if (!scopeAuth.allowed) { sendJson(res, 403, { @@ -334,9 +324,8 @@ export async function handleToolsInvokeHttpRequest( Array.isArray(gatewayToolsCfg?.deny) ? gatewayToolsCfg.deny : [], ); const gatewayDenySet = new Set(gatewayDenyNames); - // HTTP bearer auth does not bind a device-owner identity, so owner-only tools - // stay unavailable on this surface even when callers assert admin scopes. - const ownerFiltered = applyOwnerOnlyToolPolicy(subagentFiltered, false); + const senderIsOwner = resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth); + const ownerFiltered = applyOwnerOnlyToolPolicy(subagentFiltered, senderIsOwner); const gatewayFiltered = ownerFiltered.filter((t) => !gatewayDenySet.has(t.name)); const tool = gatewayFiltered.find((t) => t.name === toolName);