mirror of https://github.com/openclaw/openclaw.git
fix(gateway): restore shared-secret HTTP tool invoke auth
This commit is contained in:
parent
0c83754246
commit
cbfeecfab4
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue