mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:40:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user