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
1Patches
1e704323ff388fix(media): drop auth headers on cross-origin redirects (#58224)
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- github.com/openclaw/openclaw/commit/e704323ff388ed21f6963f9b8e0b1b8dfaaabc5fnvdPatch
- github.com/openclaw/openclaw/security/advisories/GHSA-68v4-hmwv-f43hnvdVendor Advisory
- www.vulncheck.com/advisories/openclaw-authorization-header-leak-via-cross-origin-redirect-in-media-downloadnvdThird Party Advisory
News mentions
0No linked articles in our index yet.