VYPR
Medium severity4.3NVD Advisory· Published Apr 23, 2026· Updated Apr 28, 2026

CVE-2026-41908

CVE-2026-41908

Description

OpenClaw before 2026.4.20 contains a scope enforcement bypass vulnerability in the assistant-media route that allows trusted-proxy callers without operator.read scope to access protected assistant-media files and metadata. Attackers can bypass identity-bearing HTTP auth path scope validation to retrieve sensitive media content within allowed media roots.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.4.202026.4.20

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.4.20

Patches

1
99ef3a63c584

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

https://github.com/openclaw/openclawAgustin RiveraApr 17, 2026via ghsa
4 files changed · +104 1
  • CHANGELOG.md+1 0 modified
    @@ -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
     
    
  • src/gateway/control-ui.http.test.ts+76 0 modified
    @@ -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`;
    
  • src/gateway/control-ui.ts+26 1 modified
    @@ -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) {
    
  • src/gateway/method-scopes.ts+1 0 modified
    @@ -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",
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.