From 30b5bf18fbe827a0283ce17e91bda22cc8b4c13e Mon Sep 17 00:00:00 2001 From: Koosha Paridehpour <42529354+KooshaPari@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:03:42 -0700 Subject: [PATCH] fix(guardrails): skip credential redaction for base64 image data URLs (#13550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential masker skips `data:image/*;base64,` strings. Credential regexes run over the base64 transport bytes could match by coincidence (the report hit `AIza…` → `[REDACTED:google]` inside a PNG), corrupting the image so strict upstreams returned 400 on every retry of that conversation (#13462). Only image data URLs are exempt; the same key-shaped text anywhere else is still redacted. Maintainer addition: `tests/unit/credential-masker-image-data-url-13462.test.ts` reproduces the collision with a Google-key-shaped run inside a PNG data URL (fails on the release tip, passes with the fix) and guards that plain text is still redacted. Validated in one consolidated batch of this series (37 PRs boarded together on `release/v3.8.51`): `typecheck:core`, `check:open-sse-typecheck` and `check:dashboard-typecheck` clean; ESLint clean on every changed file; file-size, complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync and migration-numbering gates green (only the pre-existing `open-sse/utils/stream.ts` file-size red remains, inherited from the base); 3,743 focused `node:test` cases plus 34 vitest cases green. Thanks @KooshaPari! --- src/lib/guardrails/credentialMasker.ts | 13 +++++ ...ential-masker-image-data-url-13462.test.ts | 53 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/unit/credential-masker-image-data-url-13462.test.ts diff --git a/src/lib/guardrails/credentialMasker.ts b/src/lib/guardrails/credentialMasker.ts index 6ac88f8fb3..16cbb4e702 100644 --- a/src/lib/guardrails/credentialMasker.ts +++ b/src/lib/guardrails/credentialMasker.ts @@ -54,6 +54,9 @@ function walkValue( seen = new WeakSet() ): { modified: boolean; value: unknown } { if (typeof value === "string") { + // #13462: Skip redaction for base64-encoded image data URLs — applying + // credential regexes to binary transport data corrupts valid base64. + if (isImageDataUrl(value)) return { modified: false, value }; const r = redactCredentials(value); if (r.detections.length) detections.push(...r.detections); return { modified: r.modified, value: r.text }; @@ -100,6 +103,16 @@ function walkValue( return { modified: false, value }; } +/** + * Check if a string is a base64-encoded image data URL that should not be + * subject to text-pattern redaction. Applying credential regexes to binary + * transport data causes false-positive corruption of valid base64 content. + */ +function isImageDataUrl(value: string): boolean { + // Match data URLs with image MIME types (data:image/png;base64,... etc.) + return /^data:image\/[a-zA-Z0-9.+-]+;base64,/.test(value); +} + /** Walk request payloads without changing safe values. */ function redactPayload( payload: unknown, diff --git a/tests/unit/credential-masker-image-data-url-13462.test.ts b/tests/unit/credential-masker-image-data-url-13462.test.ts new file mode 100644 index 0000000000..f029eec43b --- /dev/null +++ b/tests/unit/credential-masker-image-data-url-13462.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { CredentialMaskerGuardrail } from "../../src/lib/guardrails/credentialMasker.ts"; + +// #13462: credential regexes applied to a base64 image data URL can match inside the +// binary payload and "redact" part of it, corrupting the image sent upstream. + +async function withCredentialRedactionEnabled(fn: () => Promise) { + const original = process.env.CREDENTIAL_REDACTION_ENABLED; + process.env.CREDENTIAL_REDACTION_ENABLED = "true"; + try { + await fn(); + } finally { + if (original === undefined) delete process.env.CREDENTIAL_REDACTION_ENABLED; + else process.env.CREDENTIAL_REDACTION_ENABLED = original; + } +} + +// Base64 bytes that coincidentally spell a Google API key shape (AIza + 20+ chars), the +// collision the issue observed in a real screenshot. +const KEY_SHAPED = "AIza" + "B7qX".repeat(8); +const IMAGE_URL = `data:image/png;base64,iVBORw0KGgoAAAANSUhEUg${KEY_SHAPED}AAAAAElFTkSuQmCC`; + +test("#13462 base64 image data URLs pass through the credential masker unchanged", async () => { + await withCredentialRedactionEnabled(async () => { + const guardrail = new CredentialMaskerGuardrail(); + const payload = { + messages: [ + { + role: "user", + content: [{ type: "image_url", image_url: { url: IMAGE_URL } }], + }, + ], + }; + const result = await guardrail.preCall(payload, {} as never); + const next = (result?.modifiedPayload ?? payload) as typeof payload; + assert.equal(next.messages[0].content[0].image_url.url, IMAGE_URL); + }); +}); + +test("#13462 the same key-shaped text outside a data URL is still redacted", async () => { + await withCredentialRedactionEnabled(async () => { + const guardrail = new CredentialMaskerGuardrail(); + const result = await guardrail.preCall( + { messages: [{ role: "user", content: `key=${KEY_SHAPED}` }] }, + {} as never + ); + const next = result?.modifiedPayload as { messages: Array<{ content: string }> }; + assert.ok(next, "expected the plain-text token to be redacted"); + assert.match(next.messages[0].content, /\[REDACTED:google\]/); + }); +});