fix(gateway): require read scope for assistant media (#68175)

* fix(gateway): enforce assistant media scopes

* changelog: require read scope for assistant media (#68175)

* skip scope enforcement for auth.mode=none

Exclude method "none" from the identity-bearing scope gate so
gateway.auth.mode=none deployments are not regressed by the new
operator.read check.

---------

Co-authored-by: Devin Robison <drobison@nvidia.com>
This commit is contained in:
Agustin Rivera
2026-04-17 14:03:53 -07:00
committed by GitHub
parent af0f7e1bc7
commit 99ef3a63c5
4 changed files with 104 additions and 1 deletions

View File

@@ -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

View File

@@ -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 = `<html><head><script>${scriptContent}</script></head><body></body></html>\n`;

View File

@@ -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) {

View File

@@ -66,6 +66,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
"node.rename",
],
[READ_SCOPE]: [
"assistant.media.get",
"health",
"doctor.memory.status",
"doctor.memory.dreamDiary",