Skip to content

Commit a4b38d5

Browse files
fix: prevent excessive scanning during JWT redaction
<!-- coderabbit-ai-generated:v1 -->
1 parent e4ff5c8 commit a4b38d5

4 files changed

Lines changed: 99 additions & 11 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { redactMessage as redactLogMessage } from '../analog-server-log.ts';
3+
import { redactJwt, redactMessage } from '../forms-privacy.ts';
4+
import { redactText } from '../text-redact.ts';
5+
import { toPluginsPage } from '../rpc/plugins-register.ts';
6+
import { resolvePangularConfig } from '../config.ts';
7+
8+
const token = 'eyJabcd.payload.signature';
9+
10+
describe('JWT redaction', () => {
11+
it('keeps the original matching rules for signed, unsigned and adjacent tokens', () => {
12+
const samples = [
13+
`before ${token} after`,
14+
'eyJabcd.efgh.',
15+
`${token},eyJ1234.5678.-_9`,
16+
'eyJabc.efgh.signature eyJabcd.efg.signature',
17+
`eyJbad.${token}`,
18+
`prefix${token}!`,
19+
'eyJabcd.efgh.é',
20+
'eyJeyJeyJeyJ.nope',
21+
];
22+
for (const sample of samples) {
23+
expect(redactJwt(sample)).toBe(
24+
sample.replace(/eyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]*/g, '[redacted]'),
25+
);
26+
}
27+
for (const redact of [redactMessage, redactText, redactLogMessage]) {
28+
expect(redact(`before ${token} after`)).toBe('before [redacted] after');
29+
expect(redact('eyJabcd.efgh.')).toBe('[redacted]');
30+
}
31+
});
32+
33+
it('handles many overlapping candidates and still masks a later token', () => {
34+
const input = `${'eyJ'.repeat(100_000)}!${token}`;
35+
expect(redactJwt(input)).toBe(`${'eyJ'.repeat(100_000)}![redacted]`);
36+
});
37+
38+
it('processes an adversarial plugin view and masks its token before clipping', () => {
39+
const page = toPluginsPage(
40+
{
41+
pageId: 'tab1',
42+
plugins: [
43+
{
44+
id: 'test',
45+
view: {
46+
sections: [{ kind: 'text', title: 'Test', text: `${token}!${'eyJ'.repeat(20_000)}` }],
47+
},
48+
},
49+
],
50+
},
51+
resolvePangularConfig(),
52+
);
53+
expect(page?.plugins[0].view?.sections).toHaveLength(1);
54+
expect(page?.plugins[0].view?.sections[0]).toMatchObject({
55+
text: expect.stringMatching(/^\[redacted\]!/),
56+
});
57+
expect(JSON.stringify(page)).not.toContain(token);
58+
});
59+
});

‎packages/devtools/src/analog-server-log.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { IncomingMessage, ServerResponse } from 'node:http';
22
import { redactCredentials } from './credential-redact.ts';
3-
import { JWT, isRedactedKey } from './forms-privacy.ts';
3+
import { isRedactedKey, redactJwt } from './forms-privacy.ts';
44
import { redactJsonText } from './json-text-redact.ts';
55

66
const BEARER = /\bBearer\s+[\w.~+/=-]+/gi;
@@ -19,8 +19,7 @@ function queryKey(key: string): string {
1919
}
2020

2121
export function redactMessage(text: string): string {
22-
return redactCredentials(text, '[redacted]')
23-
.replace(JWT, '[redacted]')
22+
return redactJwt(redactCredentials(text, '[redacted]'))
2423
.replace(BEARER, 'Bearer [redacted]')
2524
.replace(QUERY_PAIR, (whole, sep: string, key: string) => {
2625
const name = queryKey(key);

‎packages/devtools/src/forms-privacy.ts‎

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,40 @@ const SECRET_AUTOCOMPLETE = /password|one-time-code|cc-/i;
122122
/** Elements whose content never leaves the page (also blacked out in component screenshots). */
123123
export const MASK_MARKERS = '.sentry-mask, .rr-mask, [data-private], [data-pangular="mask"]';
124124
export const UNMASK_MARKER = '[data-pangular="unmask"]';
125-
/** A JWT, including an unsigned (`alg: none`) one with an empty signature. */
126-
export const JWT = /eyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]*/g;
125+
function jwtCharacter(code: number): boolean {
126+
return (
127+
(code >= 65 && code <= 90) ||
128+
(code >= 97 && code <= 122) ||
129+
(code >= 48 && code <= 57) ||
130+
code === 95 ||
131+
code === 45
132+
);
133+
}
134+
135+
/** Masks JWTs, including unsigned ones, without retrying every prefix in a segment. */
136+
export function redactJwt(text: string): string {
137+
let search = 0;
138+
let last = 0;
139+
const parts: string[] = [];
140+
while (search < text.length) {
141+
const start = text.indexOf('eyJ', search);
142+
if (start < 0) break;
143+
let headerEnd = start + 3;
144+
while (jwtCharacter(text.charCodeAt(headerEnd))) headerEnd++;
145+
// Later prefixes in this segment have the same end and a shorter header.
146+
search = headerEnd;
147+
if (headerEnd - start < 7 || text[headerEnd] !== '.') continue;
148+
let payloadEnd = headerEnd + 1;
149+
while (jwtCharacter(text.charCodeAt(payloadEnd))) payloadEnd++;
150+
if (payloadEnd - headerEnd - 1 < 4 || text[payloadEnd] !== '.') continue;
151+
let end = payloadEnd + 1;
152+
while (jwtCharacter(text.charCodeAt(end))) end++;
153+
parts.push(text.slice(last, start), REDACTED);
154+
last = end;
155+
search = end;
156+
}
157+
return parts.length ? parts.join('') + text.slice(last) : text;
158+
}
127159
const BEARER = /\bBearer\s+[\w.~+/=-]+/gi;
128160

129161
interface PrivacyConfig {
@@ -300,9 +332,7 @@ export function longestFirst(secrets: Iterable<string>): string[] {
300332

301333
/** `redactMessage` with secrets `longestFirst` already prepared, for many texts in a row. */
302334
export function redactMessageWith(text: string, ordered: readonly string[]): string {
303-
let out = redactCredentials(text, REDACTED)
304-
.replace(JWT, REDACTED)
305-
.replace(BEARER, `Bearer ${REDACTED}`);
335+
let out = redactJwt(redactCredentials(text, REDACTED)).replace(BEARER, `Bearer ${REDACTED}`);
306336
for (const secret of ordered) out = out.split(secret).join(REDACTED);
307337
return out;
308338
}

‎packages/devtools/src/text-redact.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { redactCredentials } from './credential-redact.ts';
2-
import { JWT, REDACTED, isRedactedKey, wordsOf } from './forms-privacy.ts';
2+
import { REDACTED, isRedactedKey, redactJwt, wordsOf } from './forms-privacy.ts';
33

44
/**
55
* Masking for free text and URLs, shared by `serialize`, the router collector
@@ -193,11 +193,11 @@ export function redactText(
193193
if (isSecretKey(decode(key))) return `${sep}${key}${eq}${REDACTED}`;
194194
return nested(match, `${sep}${key}${eq}`, value, secrets, mode, depth);
195195
});
196-
out = out.replace(JWT, REDACTED).replace(BEARER, `Bearer ${REDACTED}`);
196+
out = redactJwt(out).replace(BEARER, `Bearer ${REDACTED}`);
197197
out = out.replace(LONG_TOKEN, (token) => (isOpaqueToken(token) ? REDACTED : token));
198198
} else {
199199
// Bearer tokens and JWTs first, so `token: Bearer abc` can't keep `abc` once `Bearer` is hidden.
200-
out = out.replace(JWT, REDACTED).replace(BEARER, `Bearer ${REDACTED}`);
200+
out = redactJwt(out).replace(BEARER, `Bearer ${REDACTED}`);
201201
const pair = (
202202
match: string,
203203
quote: string,

0 commit comments

Comments
 (0)