From 2d80dbfeba4ddc21eb4a002a23720757acab1d16 Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Fri, 27 Mar 2026 02:23:04 -0700 Subject: [PATCH] fix(gateway): require read scope for models http (#55683) --- src/gateway/models-http.test.ts | 46 +++++++++++++++++++++++++++++++++ src/gateway/models-http.ts | 19 +++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/src/gateway/models-http.test.ts b/src/gateway/models-http.test.ts index 9d9077ded48..030cae28887 100644 --- a/src/gateway/models-http.test.ts +++ b/src/gateway/models-http.test.ts @@ -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>; let enabledPort: number; @@ -30,6 +32,7 @@ async function getModels(pathname: string, headers?: Record) { 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 }); diff --git a/src/gateway/models-http.ts b/src/gateway/models-http.ts index 9c255b54e3f..b7b58bb8b3c 100644 --- a/src/gateway/models-http.ts +++ b/src/gateway/models-http.ts @@ -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, {