diff --git a/packages/devtools/src/__tests__/jwt-redaction.test.ts b/packages/devtools/src/__tests__/jwt-redaction.test.ts new file mode 100644 index 00000000..4ba9801b --- /dev/null +++ b/packages/devtools/src/__tests__/jwt-redaction.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { redactMessage as redactLogMessage } from '../analog-server-log.ts'; +import { redactJwt, redactMessage } from '../forms-privacy.ts'; +import { redactText } from '../text-redact.ts'; +import { toPluginsPage } from '../rpc/plugins-register.ts'; +import { resolvePangularConfig } from '../config.ts'; + +const token = 'eyJabcd.payload.signature'; + +describe('JWT redaction', () => { + it('keeps the original matching rules for signed, unsigned and adjacent tokens', () => { + const samples = [ + `before ${token} after`, + 'eyJabcd.efgh.', + `${token},eyJ1234.5678.-_9`, + 'eyJabc.efgh.signature eyJabcd.efg.signature', + `eyJbad.${token}`, + `prefix${token}!`, + 'eyJabcd.efgh.é', + 'eyJeyJeyJeyJ.nope', + ]; + for (const sample of samples) { + expect(redactJwt(sample)).toBe( + sample.replace(/eyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]*/g, '[redacted]'), + ); + } + for (const redact of [redactMessage, redactText, redactLogMessage]) { + expect(redact(`before ${token} after`)).toBe('before [redacted] after'); + expect(redact('eyJabcd.efgh.')).toBe('[redacted]'); + } + }); + + it('handles many overlapping candidates and still masks a later token', () => { + const input = `${'eyJ'.repeat(100_000)}!${token}`; + expect(redactJwt(input)).toBe(`${'eyJ'.repeat(100_000)}![redacted]`); + }); + + it('processes an adversarial plugin view and masks its token before clipping', () => { + const page = toPluginsPage( + { + pageId: 'tab1', + plugins: [ + { + id: 'test', + view: { + sections: [{ kind: 'text', title: 'Test', text: `${token}!${'eyJ'.repeat(20_000)}` }], + }, + }, + ], + }, + resolvePangularConfig(), + ); + expect(page?.plugins[0].view?.sections).toHaveLength(1); + expect(page?.plugins[0].view?.sections[0]).toMatchObject({ + text: expect.stringMatching(/^\[redacted\]!/), + }); + expect(JSON.stringify(page)).not.toContain(token); + }); +}); diff --git a/packages/devtools/src/analog-server-log.ts b/packages/devtools/src/analog-server-log.ts index 3a327070..2aaf84e6 100644 --- a/packages/devtools/src/analog-server-log.ts +++ b/packages/devtools/src/analog-server-log.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { redactCredentials } from './credential-redact.ts'; -import { JWT, isRedactedKey } from './forms-privacy.ts'; +import { isRedactedKey, redactJwt } from './forms-privacy.ts'; import { redactJsonText } from './json-text-redact.ts'; const BEARER = /\bBearer\s+[\w.~+/=-]+/gi; @@ -19,8 +19,7 @@ function queryKey(key: string): string { } export function redactMessage(text: string): string { - return redactCredentials(text, '[redacted]') - .replace(JWT, '[redacted]') + return redactJwt(redactCredentials(text, '[redacted]')) .replace(BEARER, 'Bearer [redacted]') .replace(QUERY_PAIR, (whole, sep: string, key: string) => { const name = queryKey(key); diff --git a/packages/devtools/src/forms-privacy.ts b/packages/devtools/src/forms-privacy.ts index da1421fd..ad11323f 100644 --- a/packages/devtools/src/forms-privacy.ts +++ b/packages/devtools/src/forms-privacy.ts @@ -122,8 +122,40 @@ const SECRET_AUTOCOMPLETE = /password|one-time-code|cc-/i; /** Elements whose content never leaves the page (also blacked out in component screenshots). */ export const MASK_MARKERS = '.sentry-mask, .rr-mask, [data-private], [data-pangular="mask"]'; export const UNMASK_MARKER = '[data-pangular="unmask"]'; -/** A JWT, including an unsigned (`alg: none`) one with an empty signature. */ -export const JWT = /eyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]*/g; +function jwtCharacter(code: number): boolean { + return ( + (code >= 65 && code <= 90) || + (code >= 97 && code <= 122) || + (code >= 48 && code <= 57) || + code === 95 || + code === 45 + ); +} + +/** Masks JWTs, including unsigned ones, without retrying every prefix in a segment. */ +export function redactJwt(text: string): string { + let search = 0; + let last = 0; + const parts: string[] = []; + while (search < text.length) { + const start = text.indexOf('eyJ', search); + if (start < 0) break; + let headerEnd = start + 3; + while (jwtCharacter(text.charCodeAt(headerEnd))) headerEnd++; + // Later prefixes in this segment have the same end and a shorter header. + search = headerEnd; + if (headerEnd - start < 7 || text[headerEnd] !== '.') continue; + let payloadEnd = headerEnd + 1; + while (jwtCharacter(text.charCodeAt(payloadEnd))) payloadEnd++; + if (payloadEnd - headerEnd - 1 < 4 || text[payloadEnd] !== '.') continue; + let end = payloadEnd + 1; + while (jwtCharacter(text.charCodeAt(end))) end++; + parts.push(text.slice(last, start), REDACTED); + last = end; + search = end; + } + return parts.length ? parts.join('') + text.slice(last) : text; +} const BEARER = /\bBearer\s+[\w.~+/=-]+/gi; interface PrivacyConfig { @@ -300,9 +332,7 @@ export function longestFirst(secrets: Iterable): string[] { /** `redactMessage` with secrets `longestFirst` already prepared, for many texts in a row. */ export function redactMessageWith(text: string, ordered: readonly string[]): string { - let out = redactCredentials(text, REDACTED) - .replace(JWT, REDACTED) - .replace(BEARER, `Bearer ${REDACTED}`); + let out = redactJwt(redactCredentials(text, REDACTED)).replace(BEARER, `Bearer ${REDACTED}`); for (const secret of ordered) out = out.split(secret).join(REDACTED); return out; } diff --git a/packages/devtools/src/text-redact.ts b/packages/devtools/src/text-redact.ts index f8b09c63..b0737985 100644 --- a/packages/devtools/src/text-redact.ts +++ b/packages/devtools/src/text-redact.ts @@ -1,5 +1,5 @@ import { redactCredentials } from './credential-redact.ts'; -import { JWT, REDACTED, isRedactedKey, wordsOf } from './forms-privacy.ts'; +import { REDACTED, isRedactedKey, redactJwt, wordsOf } from './forms-privacy.ts'; /** * Masking for free text and URLs, shared by `serialize`, the router collector @@ -193,11 +193,11 @@ export function redactText( if (isSecretKey(decode(key))) return `${sep}${key}${eq}${REDACTED}`; return nested(match, `${sep}${key}${eq}`, value, secrets, mode, depth); }); - out = out.replace(JWT, REDACTED).replace(BEARER, `Bearer ${REDACTED}`); + out = redactJwt(out).replace(BEARER, `Bearer ${REDACTED}`); out = out.replace(LONG_TOKEN, (token) => (isOpaqueToken(token) ? REDACTED : token)); } else { // Bearer tokens and JWTs first, so `token: Bearer abc` can't keep `abc` once `Bearer` is hidden. - out = out.replace(JWT, REDACTED).replace(BEARER, `Bearer ${REDACTED}`); + out = redactJwt(out).replace(BEARER, `Bearer ${REDACTED}`); const pair = ( match: string, quote: string,