diff --git a/CHANGELOG.md b/CHANGELOG.md index 27f64c76e03..0e878e6a0d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke. - Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling. - Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195) +- Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit. ## 2026.4.15 diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index c48a0d7134b..fff1557252b 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -294,6 +294,82 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("rejects trusted-proxy assistant media file reads without operator.read scope", async () => { + await withAllowedAssistantMediaRoot({ + prefix: "ui-media-scope-file-", + fn: async (tmpRoot) => { + const filePath = path.join(tmpRoot, "photo.png"); + await fs.writeFile(filePath, Buffer.from("not-a-real-png")); + const { res, handled, end } = await runAssistantMediaRequest({ + url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}`, + method: "GET", + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["10.0.0.1"], + remoteAddress: "10.0.0.1", + headers: { + host: "gateway.example.com", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + "x-openclaw-scopes": "operator.approvals", + }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(403); + expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toMatchObject({ + ok: false, + error: { + type: "forbidden", + message: "missing scope: operator.read", + }, + }); + }, + }); + }); + + it("rejects trusted-proxy assistant media metadata requests with an empty scope set", async () => { + await withAllowedAssistantMediaRoot({ + prefix: "ui-media-scope-meta-", + fn: async (tmpRoot) => { + const filePath = path.join(tmpRoot, "photo.png"); + await fs.writeFile(filePath, Buffer.from("not-a-real-png")); + const { res, handled, end } = await runAssistantMediaRequest({ + url: `/__openclaw__/assistant-media?meta=1&source=${encodeURIComponent(filePath)}`, + method: "GET", + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["10.0.0.1"], + remoteAddress: "10.0.0.1", + headers: { + host: "gateway.example.com", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + "x-openclaw-scopes": "", + }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(403); + expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toMatchObject({ + ok: false, + error: { + type: "forbidden", + message: "missing scope: operator.read", + }, + }); + }, + }); + }); + it("includes CSP hash for inline scripts in index.html", async () => { const scriptContent = "(function(){ var x = 1; })();"; const html = `\n`; diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 1f43123920c..41e487f32ae 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -38,7 +38,12 @@ import { resolveAssistantAvatarUrl, } from "./control-ui-shared.js"; import { sendGatewayAuthFailure } from "./http-common.js"; -import { getBearerToken, resolveHttpBrowserOriginPolicy } from "./http-utils.js"; +import { + getBearerToken, + resolveHttpBrowserOriginPolicy, + resolveTrustedHttpOperatorScopes, +} from "./http-utils.js"; +import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; const ROOT_PREFIX = "/"; const CONTROL_UI_ASSISTANT_MEDIA_PREFIX = "/__openclaw__/assistant-media"; @@ -307,6 +312,26 @@ export async function handleControlUiAssistantMediaRequest( sendGatewayAuthFailure(res, authResult); return true; } + const trustDeclaredOperatorScopes = + authResult.method !== "token" && + authResult.method !== "password" && + authResult.method !== "none"; + if (trustDeclaredOperatorScopes) { + const requestedScopes = resolveTrustedHttpOperatorScopes(req, { + trustDeclaredOperatorScopes, + }); + const scopeAuth = authorizeOperatorScopesForMethod("assistant.media.get", requestedScopes); + if (!scopeAuth.allowed) { + sendJson(res, 403, { + ok: false, + error: { + type: "forbidden", + message: `missing scope: ${scopeAuth.missingScope}`, + }, + }); + return true; + } + } } const source = normalizeAssistantMediaSource(url.searchParams.get("source") ?? ""); if (!source) { diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index db728df75c5..0dcc8c992f8 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -66,6 +66,7 @@ const METHOD_SCOPE_GROUPS: Record = { "node.rename", ], [READ_SCOPE]: [ + "assistant.media.get", "health", "doctor.memory.status", "doctor.memory.dreamDiary",