fix(mitm): mask bare "Bearer <token>" header values in the inspector (#4358)

sanitizeHeaders() masks header *values* — it calls maskSecret("Bearer
<token>") with the "authorization:" key already stripped. The BEARER regex
was anchored to a literal "authorization:" prefix, so it never fired on those
values; tokens shorter than the sk-(16+)/opaque-(40+) thresholds then leaked
verbatim into the Traffic Inspector buffer (Hard Rule #12).

Found by the AgentBridge live capture: a 'Bearer sk-secret-TESTE' request
header showed up unmasked in /api/tools/traffic-inspector/requests. Real Google
OAuth tokens are long enough to be caught by LONG_TOKEN, but the Bearer pattern
must mask regardless of length.

Re-anchor BEARER to a standalone \bBearer\s+<token> (still ReDoS-safe:
bounded char class, no nested quantifiers). Masks both bare 'Bearer <token>'
header values and 'authorization: Bearer <token>' raw lines; existing cases
(sk-/ak-/pk- keys, short keys, no-secret strings) unchanged.

Tests: bare Bearer value, short opaque Bearer, realistic Google OAuth Bearer,
plus an authorization:-prefixed regression.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-20 05:51:30 -03:00
committed by GitHub
parent 4038e4de4a
commit b710f1a7e3
2 changed files with 34 additions and 3 deletions

View File

@@ -6,14 +6,20 @@
* Pattern sources: plano 11 §4.8 (origin: llm-interceptor proxy.py:310)
*/
// Pre-compiled regex patterns — ORDER IS SIGNIFICANT (BEARER must run first)
const BEARER = /(authorization:\s*Bearer\s+)[A-Za-z0-9._-]+/gi;
// Pre-compiled regex patterns — ORDER IS SIGNIFICANT (BEARER must run first).
// BEARER matches the token after a standalone "Bearer " — NOT only after a
// literal "authorization:" prefix. sanitizeHeaders() masks header *values*
// ("Bearer <token>") with the key already stripped, so a prefix-anchored regex
// never fired there and short/opaque-but-<40 tokens leaked into the inspector
// (found by the AgentBridge live capture). The char class is bounded + linear
// (no nested quantifiers) to stay ReDoS-safe.
const BEARER = /(\bBearer\s+)[A-Za-z0-9._~+/-]+=*/gi;
const SK_KEY = /\b(sk|ak|pk)-[A-Za-z0-9_-]{16,}\b/g;
const LONG_TOKEN = /\b[A-Za-z0-9_-]{40,}\b/g;
/**
* Mask secrets in a string value.
* - Bearer tokens: replaces token after "Bearer " with "***"
* - Bearer tokens: replaces the token after any "Bearer " with "***"
* - sk-/ak-/pk- keys: keeps first 6 chars + last 2 chars
* - Long opaque tokens (≥40 chars): keeps first 4 chars + last 2 chars
*/

View File

@@ -61,3 +61,28 @@ test("maskSecret — short sk- key below 16 chars is NOT masked", () => {
const shortKey = "sk-shortkey";
assert.equal(maskSecret(shortKey), shortKey);
});
// Regression: sanitizeHeaders() masks header *values* ("Bearer <token>" with the
// "authorization:" key already stripped). The previous prefix-anchored BEARER
// regex never fired there, so short/opaque-<40 Bearer tokens leaked into the
// Traffic Inspector (found by the AgentBridge live capture).
test("maskSecret — Bearer token in a bare header value (no authorization: prefix) is masked", () => {
const result = maskSecret("Bearer sk-secret-TESTE");
assert.equal(result, "Bearer ***");
});
test("maskSecret — a short opaque Bearer token is still masked", () => {
assert.equal(maskSecret("Bearer abc123"), "Bearer ***");
});
test("maskSecret — a realistic Google OAuth Bearer value is masked whole", () => {
const result = maskSecret("Bearer ya29.a0AfH6SMxLONGTOKEN_1234567890-abcdefghij");
assert.equal(result, "Bearer ***");
assert.ok(!result.includes("LONGTOKEN"), "no part of the token should survive");
});
test("maskSecret — 'authorization: Bearer <token>' still masks (no regression)", () => {
const result = maskSecret("authorization: Bearer sk-proj-abcdefghijklmnop");
assert.ok(result.startsWith("authorization: Bearer ***"), `got: ${result}`);
assert.ok(!result.includes("abcdefghijklmnop"));
});