Skip to content

Commit d6428fc

Browse files
committed
fix(security): keep base64-prefix redaction out of media payloads
- pure-base64-alphabet token prefixes (gAAAA, AKIA, ASIA, dapi, ATCTT3xFfG, ATATT, ATBB) now require a non-alphanumeric left boundary, skip explicit ;base64, payload spans, and run unchunked so chunk starts cannot fake the boundary or hide the container from the lookbehind - tokens after URL/path delimiters or assignments still mask; data-URL media survives redaction byte-identical (fixes chat media mirror CI) - regression tests: tiny-PNG data URL, in-blob plus boundary, chunk-aligned large data URL, reset-path Fernet token, path AWS key
1 parent 038f346 commit d6428fc

2 files changed

Lines changed: 65 additions & 9 deletions

File tree

src/logging/redact.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -826,6 +826,46 @@ describe("redactSensitiveText", () => {
826826
expect(output).toBe(input);
827827
});
828828

829+
it("does not corrupt base64 blobs that embed token-prefix shapes", () => {
830+
// Tiny-PNG base64 contains a gAAAA run from zero-filled IHDR bytes; pure-base64-alphabet
831+
// prefixes must not fire mid-blob or media payloads get mangled.
832+
const dataUrl =
833+
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=";
834+
expect(redactSensitiveText(dataUrl, { mode: "tools" })).toBe(dataUrl);
835+
const blobWithUppercaseRun = `blob: ${"x".repeat(4)}AKIAABCDEFGHIJKLMNOPqrstuv`;
836+
expect(redactSensitiveText(blobWithUppercaseRun, { mode: "tools" })).toBe(blobWithUppercaseRun);
837+
const dataUrlWithPlusBoundary = `data:application/octet-stream;base64,AAAA+gAAAA${"B".repeat(24)}`;
838+
expect(redactSensitiveText(dataUrlWithPlusBoundary, { mode: "tools" })).toBe(
839+
dataUrlWithPlusBoundary,
840+
);
841+
expect(redactSensitiveText("aws AKIA_ID=AKIAABCDEFGHIJKLMNOP", { mode: "tools" })).toBe(
842+
"aws AKIA_ID=AKIAAB…MNOP",
843+
);
844+
});
845+
846+
it("does not corrupt large data URLs across chunked replacement boundaries", () => {
847+
// replacePatternBounded slices 32 KiB+ inputs into 16 KiB chunks; a chunk start must not
848+
// satisfy the pure-base64 prefix boundary (`^`) or hide the `;base64,` container from its
849+
// lookbehind, so the boundary patterns run unchunked.
850+
const prefix = "data:application/octet-stream;base64,";
851+
const chunkSize = 16_384;
852+
const pad = "A".repeat(chunkSize * 2 - prefix.length);
853+
const dataUrl = `${prefix}${pad}gAAAA${"B".repeat(24)}${"C".repeat(chunkSize)}`;
854+
expect(redactSensitiveText(dataUrl, { mode: "tools" })).toBe(dataUrl);
855+
});
856+
857+
it("masks pure-base64-alphabet tokens after URL and path delimiters", () => {
858+
const fernet = `gAAAA${"B".repeat(24)}`;
859+
const reset = `https://app.example/reset/${fernet} visited`;
860+
const output = redactSensitiveText(reset, { mode: "tools" });
861+
expect(output).not.toContain(fernet);
862+
expect(output).toContain("https://app.example/reset/");
863+
const s3Path = "fetch /buckets/AKIAABCDEFGHIJKLMNOP/objects";
864+
expect(redactSensitiveText(s3Path, { mode: "tools" })).toBe(
865+
"fetch /buckets/AKIAAB…MNOP/objects",
866+
);
867+
});
868+
829869
it("masks bare sensitive query and form keys through the default options path", () => {
830870
expect(
831871
redactSensitiveText("GET https://example.test/oauth?code=opaque-grant-123", {

src/logging/redact.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -118,13 +118,21 @@ const ESCAPED_ENV_ASSIGNMENT_REDACT_PATTERN = String.raw`/\b[A-Z0-9_]*(?:KEY|TOK
118118
// quotes still mask like plain values instead of escaping both patterns.
119119
const STANDALONE_ASSIGNMENT_QUOTED_REDACT_PATTERN = String.raw`(^|[\s,;])(?:${STANDALONE_ASSIGNMENT_SECRET_KEYS})=(["'\x60])((?:(?!\2)[^\r\n])+)\2`;
120120
const STANDALONE_ASSIGNMENT_REDACT_PATTERN = String.raw`(^|[\s,;])(?:${STANDALONE_ASSIGNMENT_SECRET_KEYS})=(["'\x60]?[^\s&#"'\x60<>]+)`;
121+
// Pure-base64-alphabet token prefixes: require a non-alphanumeric left boundary (URL/path
122+
// delimiters like `/` and `=` still qualify) but skip explicit `;base64,` payload spans, so
123+
// data-URL media is never corrupted while tokens in URL paths or assignments still redact.
124+
const BASE64_SAFE_TOKEN_BOUNDARY = String.raw`(^|[^A-Za-z0-9])(?<!;base64,[A-Za-z0-9+/=]*)`;
121125
const SHELL_REFERENCE_PRESERVING_PATTERN_SOURCES = new Set([
122126
ENV_ASSIGNMENT_REDACT_PATTERN,
123127
ESCAPED_ENV_ASSIGNMENT_REDACT_PATTERN,
124128
STANDALONE_ASSIGNMENT_QUOTED_REDACT_PATTERN,
125129
STANDALONE_ASSIGNMENT_REDACT_PATTERN,
126130
]);
127131
const shellReferencePreservingPatterns = new WeakSet<RegExp>();
132+
// Patterns whose left-context assertions (BASE64_SAFE_TOKEN_BOUNDARY) break under chunked
133+
// replacement: a chunk start satisfies `^` and hides the `;base64,` container from the
134+
// lookbehind, so these must always run against the full string.
135+
const chunkUnsafePatterns = new WeakSet<RegExp>();
128136

129137
const DEFAULT_REDACT_PATTERNS: string[] = [
130138
// ENV-style assignments. Keep this case-sensitive so diagnostics like
@@ -183,7 +191,9 @@ const DEFAULT_REDACT_PATTERNS: string[] = [
183191
String.raw`(fal_[A-Za-z0-9_-]{10,})`,
184192
String.raw`(fc-[A-Za-z0-9]{10,})`,
185193
String.raw`(bb_live_[A-Za-z0-9_-]{10,})`,
186-
String.raw`(gAAAA[A-Za-z0-9_=-]{20,})`,
194+
// Prefixes made only of standard-base64 characters need a non-base64 left boundary so they
195+
// do not fire inside unrelated base64 blobs (e.g. data-URL media), corrupting the payload.
196+
String.raw`${BASE64_SAFE_TOKEN_BOUNDARY}(gAAAA[A-Za-z0-9_=-]{20,})`,
187197
String.raw`(sk_live_[A-Za-z0-9]{10,})`,
188198
String.raw`(sk_test_[A-Za-z0-9]{10,})`,
189199
String.raw`(rk_live_[A-Za-z0-9]{10,})`,
@@ -200,15 +210,15 @@ const DEFAULT_REDACT_PATTERNS: string[] = [
200210
String.raw`(bkua_[a-z0-9]{40})`,
201211
String.raw`(CCIPAT_[A-Za-z0-9]{22}_[A-Fa-f0-9]{40})`,
202212
String.raw`(sbp_[a-z0-9]{40})`,
203-
String.raw`(dapi[0-9a-f]{32}(?:-\d)?)`,
213+
String.raw`${BASE64_SAFE_TOKEN_BOUNDARY}(dapi[0-9a-f]{32}(?:-\d)?)`,
204214
String.raw`(dd[pw]_[A-Za-z0-9]{36})`,
205215
String.raw`(glsa_[A-Za-z0-9_]{41})`,
206216
String.raw`(glc_eyJ[A-Za-z0-9+/=]{60,160})`,
207217
String.raw`(nfp_[A-Za-z0-9_]{36})`,
208218
String.raw`(CFPAT-[A-Za-z0-9_\-]{40,})`,
209-
String.raw`(ATCTT3xFfG[A-Za-z0-9+/=_-]+=[A-Za-z0-9]{8})`,
210-
String.raw`(ATATT[A-Za-z0-9+/=_-]+=[A-Za-z0-9]{8})`,
211-
String.raw`(ATBB[A-Za-z0-9_=.-]{16,})`,
219+
String.raw`${BASE64_SAFE_TOKEN_BOUNDARY}(ATCTT3xFfG[A-Za-z0-9+/=_-]+=[A-Za-z0-9]{8})`,
220+
String.raw`${BASE64_SAFE_TOKEN_BOUNDARY}(ATATT[A-Za-z0-9+/=_-]+=[A-Za-z0-9]{8})`,
221+
String.raw`${BASE64_SAFE_TOKEN_BOUNDARY}(ATBB[A-Za-z0-9_=.-]{16,})`,
212222
String.raw`(BBDC-[A-Za-z0-9+/@_-]{40,50})`,
213223
String.raw`(HRKU-AA[A-Za-z0-9_-]{20,})`,
214224
String.raw`(pat-(?:eu|na)1-[A-Za-z0-9]{8}\-[A-Za-z0-9]{4}\-[A-Za-z0-9]{4}\-[A-Za-z0-9]{4}\-[A-Za-z0-9]{12})`,
@@ -226,8 +236,8 @@ const DEFAULT_REDACT_PATTERNS: string[] = [
226236
String.raw`(brv_[A-Za-z0-9]{10,})`,
227237
String.raw`(xai-[A-Za-z0-9]{30,})`,
228238
// Additional access-key and token-style prefixes.
229-
String.raw`(AKIA[A-Z0-9]{16})`,
230-
String.raw`(ASIA[A-Z0-9]{16})`,
239+
String.raw`${BASE64_SAFE_TOKEN_BOUNDARY}(AKIA[A-Z0-9]{16})`,
240+
String.raw`${BASE64_SAFE_TOKEN_BOUNDARY}(ASIA[A-Z0-9]{16})`,
231241
String.raw`(AKID[A-Za-z0-9]{10,})`,
232242
String.raw`(LTAI[A-Za-z0-9]{10,})`,
233243
String.raw`(hf_[A-Za-z0-9]{10,})`,
@@ -303,6 +313,9 @@ function parsePattern(raw: RedactPattern): RegExp | null {
303313
if (pattern && typeof raw === "string" && SHELL_REFERENCE_PRESERVING_PATTERN_SOURCES.has(raw)) {
304314
shellReferencePreservingPatterns.add(pattern);
305315
}
316+
if (pattern && typeof raw === "string" && raw.startsWith(BASE64_SAFE_TOKEN_BOUNDARY)) {
317+
chunkUnsafePatterns.add(pattern);
318+
}
306319
return pattern;
307320
}
308321

@@ -837,7 +850,7 @@ function redactText(
837850
next = redactFormBody(next);
838851
}
839852
for (const pattern of patterns) {
840-
next = replacePatternBounded(next, pattern, (...args: unknown[]) => {
853+
const replacer = (...args: unknown[]) => {
841854
const hasNamedGroups =
842855
args.length > 0 &&
843856
typeof args[args.length - 1] === "object" &&
@@ -851,7 +864,10 @@ function redactText(
851864
const offset = typeof args[offsetIndex] === "number" ? args[offsetIndex] : -1;
852865
const input = typeof args[inputIndex] === "string" ? args[inputIndex] : "";
853866
return redactMatch(match, groups, pattern, { input, offset });
854-
});
867+
};
868+
next = chunkUnsafePatterns.has(pattern)
869+
? next.replace(pattern, replacer)
870+
: replacePatternBounded(next, pattern, replacer);
855871
}
856872
return next;
857873
}

0 commit comments

Comments
 (0)