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\]/); + }); +});