mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-27 00:52:05 +00:00
fix(gateway): restore compat HTTP operator auth
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,11 +24,24 @@ Operational behavior matches [OpenAI Chat Completions](/gateway/openai-http-api)
|
||||
|
||||
- use `Authorization: Bearer <token>` 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/<agentId>"`, 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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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://<gateway-host>:<port>/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`.
|
||||
|
||||
@@ -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] }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,10 @@ export async function handleGatewayPostJsonEndpoint(
|
||||
allowRealIpFallback?: boolean;
|
||||
rateLimiter?: AuthRateLimiter;
|
||||
requiredOperatorMethod?: "chat.send" | (string & Record<never, never>);
|
||||
resolveOperatorScopes?: (
|
||||
req: IncomingMessage,
|
||||
requestAuth: AuthorizedGatewayHttpRequest,
|
||||
) => string[];
|
||||
},
|
||||
): Promise<false | { body: unknown; requestAuth: AuthorizedGatewayHttpRequest } | undefined> {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() ||
|
||||
|
||||
@@ -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<string, string>) {
|
||||
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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user