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

CVE-2026-41345

CVE-2026-41345

Description

OpenClaw before 2026.3.31 contains a credential exposure vulnerability in media download functionality that forwards Authorization headers across cross-origin redirects. Attackers can exploit this by crafting malicious cross-origin redirect chains to intercept sensitive authorization credentials intended for legitimate requests.

Affected products

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

Patches

1
e704323ff388

fix(media): drop auth headers on cross-origin redirects (#58224)

https://github.com/openclaw/openclawVincent KocMar 31, 2026via nvd-ref
6 files changed · +105 64
  • CHANGELOG.md+1 0 modified
    @@ -133,6 +133,7 @@ Docs: https://docs.openclaw.ai
     - Diffs/config: preserve schema-shaped plugin config parsing from `diffsPluginConfigSchema.safeParse()`, so direct callers keep `defaults` and `security` sections instead of receiving flattened tool defaults. (#57904) Thanks @gumadeiras.
     - Feishu/groups: keep quoted replies and topic bootstrap context aligned with group sender allowlists so only allowlisted thread messages seed agent context. Thanks @AntAISecurityLab and @vincentkoc.
     - Diffs: fall back to plain text when `lang` hints are invalid during diff render and viewer hydration, so bad or stale language values no longer break the diff viewer. (#57902) Thanks @gumadeiras.
    +- Media/downloads: stop forwarding auth and cookie headers across cross-origin redirects during media saves, while preserving safe request headers for same-origin redirect chains. Thanks @AntAISecurityLab and @vincentkoc.
     - Doctor/plugins: skip false Matrix legacy-helper warnings when no migration plans exist, and keep bundled `enabledByDefault` plugins in the gateway startup set. (#57931) Thanks @dinakars777.
     - Zalo/webhooks: scope replay dedupe to the authenticated target so one configured account can no longer cause same-id inbound events for another target to be dropped. Thanks @smaeljaish771 and @vincentkoc.
     - Matrix/CLI send: start one-off Matrix send clients before outbound delivery so `openclaw message send --channel matrix` restores E2EE in encrypted rooms instead of sending plain events. (#57936) Thanks @gumadeiras.
    
  • src/infra/net/fetch-guard.ssrf.test.ts+19 1 modified
    @@ -1,5 +1,9 @@
     import { afterEach, describe, expect, it, vi } from "vitest";
    -import { fetchWithSsrFGuard, GUARDED_FETCH_MODE } from "./fetch-guard.js";
    +import {
    +  fetchWithSsrFGuard,
    +  GUARDED_FETCH_MODE,
    +  retainSafeHeadersForCrossOriginRedirectHeaders,
    +} from "./fetch-guard.js";
     
     function redirectResponse(location: string): Response {
       return new Response(null, {
    @@ -242,6 +246,20 @@ describe("fetchWithSsrFGuard hardening", () => {
         await result.release();
       });
     
    +  it("keeps the exported redirect-header helper functional", () => {
    +    const headers = retainSafeHeadersForCrossOriginRedirectHeaders({
    +      Authorization: "Bearer secret",
    +      Cookie: "session=abc",
    +      Accept: "application/json",
    +      "User-Agent": "OpenClaw-Test/1.0",
    +    });
    +
    +    expect(headers).toEqual({
    +      accept: "application/json",
    +      "user-agent": "OpenClaw-Test/1.0",
    +    });
    +  });
    +
       it("keeps headers when redirect stays on same origin", async () => {
         const lookupFn = createPublicLookup();
         const fetchImpl = vi
    
  • src/infra/net/fetch-guard.ts+3 30 modified
    @@ -2,6 +2,7 @@ import type { Dispatcher } from "undici";
     import { logWarn } from "../../logger.js";
     import { buildTimeoutAbortSignal } from "../../utils/fetch-timeout.js";
     import { hasProxyEnvConfigured } from "./proxy-env.js";
    +import { retainSafeHeadersForCrossOriginRedirect as retainSafeRedirectHeaders } from "./redirect-headers.js";
     import {
       closeDispatcher,
       createPinnedDispatcher,
    @@ -55,21 +56,6 @@ type GuardedFetchPresetOptions = Omit<
     >;
     
     const DEFAULT_MAX_REDIRECTS = 3;
    -const CROSS_ORIGIN_REDIRECT_SAFE_HEADERS = new Set([
    -  "accept",
    -  "accept-encoding",
    -  "accept-language",
    -  "cache-control",
    -  "content-language",
    -  "content-type",
    -  "if-match",
    -  "if-modified-since",
    -  "if-none-match",
    -  "if-unmodified-since",
    -  "pragma",
    -  "range",
    -  "user-agent",
    -]);
     
     export function withStrictGuardedFetchMode(params: GuardedFetchPresetOptions): GuardedFetchOptions {
       return { ...params, mode: GUARDED_FETCH_MODE.STRICT };
    @@ -114,27 +100,14 @@ function isRedirectStatus(status: number): boolean {
     export function retainSafeHeadersForCrossOriginRedirectHeaders(
       headers?: HeadersInit,
     ): Record<string, string> | undefined {
    -  if (!headers) {
    -    return undefined;
    -  }
    -  const incoming = new Headers(headers);
    -  const safeHeaders = new Headers();
    -  for (const [key, value] of incoming.entries()) {
    -    if (CROSS_ORIGIN_REDIRECT_SAFE_HEADERS.has(key.toLowerCase())) {
    -      safeHeaders.set(key, value);
    -    }
    -  }
    -  return Object.fromEntries(safeHeaders.entries());
    +  return retainSafeRedirectHeaders(headers);
     }
     
     function retainSafeHeadersForCrossOriginRedirect(init?: RequestInit): RequestInit | undefined {
       if (!init?.headers) {
         return init;
       }
    -  return {
    -    ...init,
    -    headers: retainSafeHeadersForCrossOriginRedirectHeaders(init.headers),
    -  };
    +  return { ...init, headers: retainSafeRedirectHeaders(init.headers) };
     }
     
     export async function fetchWithSsrFGuard(params: GuardedFetchOptions): Promise<GuardedFetchResult> {
    
  • src/infra/net/redirect-headers.ts+31 0 added
    @@ -0,0 +1,31 @@
    +const CROSS_ORIGIN_REDIRECT_SAFE_HEADERS = new Set([
    +  "accept",
    +  "accept-encoding",
    +  "accept-language",
    +  "cache-control",
    +  "content-language",
    +  "content-type",
    +  "if-match",
    +  "if-modified-since",
    +  "if-none-match",
    +  "if-unmodified-since",
    +  "pragma",
    +  "range",
    +  "user-agent",
    +]);
    +
    +export function retainSafeHeadersForCrossOriginRedirect(
    +  headers?: HeadersInit | Record<string, string>,
    +): Record<string, string> | undefined {
    +  if (!headers) {
    +    return headers;
    +  }
    +  const incoming = new Headers(headers);
    +  const safeHeaders: Record<string, string> = {};
    +  for (const [key, value] of incoming.entries()) {
    +    if (CROSS_ORIGIN_REDIRECT_SAFE_HEADERS.has(key.toLowerCase())) {
    +      safeHeaders[key] = value;
    +    }
    +  }
    +  return safeHeaders;
    +}
    
  • src/media/store.redirect.test.ts+49 31 modified
    @@ -59,6 +59,14 @@ function mockSuccessfulTextExchange(params: { text: string; contentType: string
       };
     }
     
    +function getRequestHeaders(callIndex: number): Headers {
    +  const [, options] = mockRequest.mock.calls[callIndex] as [
    +    URL,
    +    { headers?: HeadersInit | Record<string, string> } | undefined,
    +  ];
    +  return new Headers(options?.headers);
    +}
    +
     async function expectRedirectSaveResult(params: {
       expectedText: string;
       expectedContentType: string;
    @@ -136,12 +144,12 @@ describe("media store redirects", () => {
         });
       });
     
    -  it("drops sensitive headers on cross-origin redirects", async () => {
    +  it("strips sensitive headers when a redirect crosses origins", async () => {
         let call = 0;
         mockRequest.mockImplementation((_url, _opts, cb) => {
           call += 1;
           if (call === 1) {
    -        const exchange = mockRedirectExchange({ location: "https://other.example/final" });
    +        const exchange = mockRedirectExchange({ location: "https://cdn.example.com/final" });
             exchange.send(cb);
             return exchange.req;
           }
    @@ -154,36 +162,46 @@ describe("media store redirects", () => {
           return exchange.req;
         });
     
    -    await expectRedirectSaveResult({
    -      expectedText: "redirected",
    -      expectedContentType: "text/plain",
    -      expectedExtension: ".txt",
    -      headers: {
    -        Accept: "text/plain",
    -        Authorization: "Bearer secret-token",
    -        "User-Agent": "OpenClawTest/1.0",
    -      },
    -      assertRequests: () => {
    -        expect(mockRequest.mock.calls[0]?.[1]).toMatchObject({
    -          headers: {
    -            Accept: "text/plain",
    -            Authorization: "Bearer secret-token",
    -            "User-Agent": "OpenClawTest/1.0",
    -          },
    -        });
    -        expect(mockRequest.mock.calls[1]?.[1]).toMatchObject({
    -          headers: {
    -            accept: "text/plain",
    -            "user-agent": "OpenClawTest/1.0",
    -          },
    -        });
    -        expect(mockRequest.mock.calls[1]?.[1]).not.toMatchObject({
    -          headers: {
    -            authorization: expect.any(String),
    -          },
    -        });
    -      },
    +    await saveMediaSource("https://example.com/start", {
    +      Authorization: "Bearer secret",
    +      Cookie: "session=abc",
    +      "X-Api-Key": "custom-secret",
    +      Accept: "text/plain",
    +      "User-Agent": "OpenClaw-Test/1.0",
         });
    +
    +    expect(mockRequest).toHaveBeenCalledTimes(2);
    +    const secondHeaders = getRequestHeaders(1);
    +    expect(secondHeaders.get("authorization")).toBeNull();
    +    expect(secondHeaders.get("cookie")).toBeNull();
    +    expect(secondHeaders.get("x-api-key")).toBeNull();
    +    expect(secondHeaders.get("accept")).toBe("text/plain");
    +    expect(secondHeaders.get("user-agent")).toBe("OpenClaw-Test/1.0");
    +  });
    +
    +  it("keeps headers when a redirect stays on the same origin", async () => {
    +    let call = 0;
    +    mockRequest.mockImplementation((_url, _opts, cb) => {
    +      call += 1;
    +      if (call === 1) {
    +        const exchange = mockRedirectExchange({ location: "/final" });
    +        exchange.send(cb);
    +        return exchange.req;
    +      }
    +
    +      const exchange = mockSuccessfulTextExchange({
    +        text: "redirected",
    +        contentType: "text/plain",
    +      });
    +      exchange.send(cb);
    +      return exchange.req;
    +    });
    +
    +    await saveMediaSource("https://example.com/start", {
    +      Authorization: "Bearer secret",
    +    });
    +
    +    expect(getRequestHeaders(1).get("authorization")).toBe("Bearer secret");
       });
     
       it("fails when redirect response omits location header", async () => {
    
  • src/media/store.ts+2 2 modified
    @@ -6,7 +6,7 @@ import { request as httpsRequest } from "node:https";
     import path from "node:path";
     import { pipeline } from "node:stream/promises";
     import { SafeOpenError, readLocalFileSafely } from "../infra/fs-safe.js";
    -import { retainSafeHeadersForCrossOriginRedirectHeaders } from "../infra/net/fetch-guard.js";
    +import { retainSafeHeadersForCrossOriginRedirect } from "../infra/net/redirect-headers.js";
     import { resolvePinnedHostname } from "../infra/net/ssrf.js";
     import { resolveConfigDir } from "../utils.js";
     import { detectMime, extensionForMime } from "./mime.js";
    @@ -211,7 +211,7 @@ async function downloadToFile(
                 const redirectHeaders =
                   new URL(redirectUrl).origin === parsedUrl.origin
                     ? headers
    -                : retainSafeHeadersForCrossOriginRedirectHeaders(headers);
    +                : retainSafeHeadersForCrossOriginRedirect(headers);
                 resolve(downloadToFile(redirectUrl, dest, redirectHeaders, maxRedirects - 1));
                 return;
               }
    

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

3

News mentions

0

No linked articles in our index yet.