Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions packages/devtools/src/__tests__/jwt-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 2 additions & 3 deletions packages/devtools/src/analog-server-log.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
Expand Down
40 changes: 35 additions & 5 deletions packages/devtools/src/forms-privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -300,9 +332,7 @@ export function longestFirst(secrets: Iterable<string>): 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;
}
Expand Down
6 changes: 3 additions & 3 deletions packages/devtools/src/text-redact.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading