fix(guardrails): skip credential redaction for base64 image data URLs (#13550)

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!
This commit is contained in:
Koosha Paridehpour
2026-09-14 20:03:42 -07:00
committed by GitHub
parent 93a398f353
commit 30b5bf18fb
2 changed files with 66 additions and 0 deletions

View File

@@ -54,6 +54,9 @@ function walkValue(
seen = new WeakSet<object>()
): { 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,

View File

@@ -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<void>) {
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\]/);
});
});