fix(gateway): require read scope for models http (#55683)

This commit is contained in:
Jacob Tomlinson
2026-03-27 02:23:04 -07:00
committed by GitHub
parent 3a7cf5364f
commit 2d80dbfeba
2 changed files with 64 additions and 1 deletions

View File

@@ -3,6 +3,8 @@ import { getFreePort, installGatewayTestHooks } from "./test-helpers.js";
installGatewayTestHooks({ scope: "suite" });
const READ_SCOPE_HEADER = { "x-openclaw-scopes": "operator.read" };
let startGatewayServer: typeof import("./server.js").startGatewayServer;
let enabledServer: Awaited<ReturnType<typeof startServer>>;
let enabledPort: number;
@@ -30,6 +32,7 @@ async function getModels(pathname: string, headers?: Record<string, string>) {
return await fetch(`http://127.0.0.1:${enabledPort}${pathname}`, {
headers: {
authorization: "Bearer secret",
...READ_SCOPE_HEADER,
...headers,
},
});
@@ -63,6 +66,49 @@ describe("OpenAI-compatible models HTTP API (e2e)", () => {
expect(json.id).toBe(firstId);
});
it("rejects operator scopes that lack read access", async () => {
const res = await getModels("/v1/models", { "x-openclaw-scopes": "operator.approvals" });
expect(res.status).toBe(403);
await expect(res.json()).resolves.toMatchObject({
ok: false,
error: {
type: "forbidden",
message: "missing scope: operator.read",
},
});
});
it("rejects requests with no declared operator scopes", async () => {
const res = await getModels("/v1/models", { "x-openclaw-scopes": "" });
expect(res.status).toBe(403);
await expect(res.json()).resolves.toMatchObject({
ok: false,
error: {
type: "forbidden",
message: "missing scope: operator.read",
},
});
});
it("rejects /v1/models/{id} without read access", async () => {
const list = (await (await getModels("/v1/models")).json()) as {
data?: Array<{ id?: string }>;
};
const firstId = list.data?.[0]?.id;
expect(typeof firstId).toBe("string");
const res = await getModels(`/v1/models/${encodeURIComponent(firstId!)}`, {
"x-openclaw-scopes": "operator.approvals",
});
expect(res.status).toBe(403);
await expect(res.json()).resolves.toMatchObject({
ok: false,
error: {
type: "forbidden",
message: "missing scope: operator.read",
},
});
});
it("rejects when disabled", async () => {
const port = await getFreePort();
const server = await startServer(port, { openAiChatCompletionsEnabled: false });

View File

@@ -3,13 +3,17 @@ import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { loadConfig } from "../config/config.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js";
import {
authorizeGatewayBearerRequestOrReply,
resolveGatewayRequestedOperatorScopes,
} from "./http-auth-helpers.js";
import { sendInvalidRequest, sendJson, sendMethodNotAllowed } from "./http-common.js";
import {
OPENCLAW_DEFAULT_MODEL_ID,
OPENCLAW_MODEL_ID,
resolveAgentIdFromModel,
} from "./http-utils.js";
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
type OpenAiModelsHttpOptions = {
auth: ResolvedGatewayAuth;
@@ -85,6 +89,19 @@ export async function handleOpenAiModelsHttpRequest(
return true;
}
const requestedScopes = resolveGatewayRequestedOperatorScopes(req);
const scopeAuth = authorizeOperatorScopesForMethod("models.list", requestedScopes);
if (!scopeAuth.allowed) {
sendJson(res, 403, {
ok: false,
error: {
type: "forbidden",
message: `missing scope: ${scopeAuth.missingScope}`,
},
});
return true;
}
const ids = loadAgentModelIds();
if (requestPath === "/v1/models") {
sendJson(res, 200, {