fix(gateway): restore shared-secret HTTP tool invoke auth

This commit is contained in:
Peter Steinberger 2026-03-31 22:54:38 +09:00
parent 0c83754246
commit cbfeecfab4
No known key found for this signature in database
6 changed files with 75 additions and 54 deletions

View File

@ -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.

View File

@ -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`.

View File

@ -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();

View File

@ -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);
});
});

View File

@ -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 () => {

View File

@ -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);