diff --git a/packages/devtools/src/__tests__/http-payload-redaction.test.ts b/packages/devtools/src/__tests__/http-payload-redaction.test.ts new file mode 100644 index 00000000..6bbaac1d --- /dev/null +++ b/packages/devtools/src/__tests__/http-payload-redaction.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { setRedaction } from '../forms-privacy.ts'; +import { decodePayload, sanitizePayload } from '../http-payload.ts'; +import { redactPreview } from '../http-redact.ts'; +import { redactText } from '../router.ts'; + +afterEach(() => setRedaction()); + +const unsigned = 'eyJhbGciOiJub25lIn0.eyJzdWIiOiIxMjM0NTY3In0.'; + +function stateDoc(state: Record): Document { + document.body.innerHTML = ''; + const script = document.createElement('script'); + script.id = 'ng-state'; + script.type = 'application/json'; + script.textContent = JSON.stringify(state); + document.body.append(script); + return document; +} + +describe('TransferState payload redaction', () => { + it('masks secret keys in an entry the page clipped to a string', () => { + const doc = stateDoc({ + big: { password: 'hunter2', apiKey: 'sk-live-ABC', filler: 'x'.repeat(21_000) }, + }); + const text = JSON.stringify(sanitizePayload(decodePayload(doc))); + expect(text).not.toContain('hunter2'); + expect(text).not.toContain('sk-live-ABC'); + }); + + it('masks secret keys in a text body that holds JSON', () => { + const out = sanitizePayload({ + found: true, + size: 1, + entries: [{ key: 'k', size: 1, value: '{"password":"hunter2","name":"ada"}' }], + }); + const text = JSON.stringify(out); + expect(text).not.toContain('hunter2'); + expect(text).toContain('ada'); + }); + + it('applies redaction.secretNames to keys inside text bodies', () => { + setRedaction({ secretNames: ['licenseNumber'] }); + const out = sanitizePayload({ + found: true, + size: 1, + entries: [{ key: 'k', size: 1, value: { body: '{"licenseNumber":"D1234567"}' } }], + }); + expect(JSON.stringify(out)).not.toContain('D1234567'); + }); +}); + +describe('unsigned JWTs', () => { + it('masks a token with an empty signature in text and previews', () => { + expect(redactText(`token ${unsigned} end`)).not.toContain('eyJ'); + expect(redactPreview(`{"x":"${unsigned}"}`)).not.toContain('eyJ'); + }); +}); diff --git a/packages/devtools/src/__tests__/overlay-nativescript-session.test.ts b/packages/devtools/src/__tests__/overlay-nativescript-session.test.ts index dad27e10..00693625 100644 --- a/packages/devtools/src/__tests__/overlay-nativescript-session.test.ts +++ b/packages/devtools/src/__tests__/overlay-nativescript-session.test.ts @@ -8,6 +8,7 @@ const clients: Client[] = []; const restoreHook = vi.fn(); let hookGate: Promise = Promise.resolve(); let hookInstalls = 0; +let connectionMeta: { configs?: object } = {}; vi.mock('@nativescript/core', () => ({ Application: { getRootView: () => null }, @@ -19,6 +20,7 @@ vi.mock('devframe/client', () => ({ clients.push({ options }); return { status: 'connected', + connectionMeta, close: vi.fn(), scope: () => ({ rpc: { call: vi.fn(async () => undefined), register: vi.fn() } }), }; @@ -53,12 +55,25 @@ describe('NativeScript overlay sessions', () => { vi.spyOn(console, 'log').mockImplementation(() => undefined); }); - afterEach(() => { + afterEach(async () => { + connectionMeta = {}; + (await import('../forms-privacy.ts')).setRedaction(); vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + it('applies the redaction config from the connection', async () => { + connectionMeta = { configs: { pangular: { redaction: { secretNames: ['licenseNumber'] } } } }; + const { isSecretName } = await import('../serialize.ts'); + const { initNativeScriptOverlay } = await import('../overlay-nativescript.ts'); + expect(isSecretName('licenseNumber')).toBe(false); + const dispose = initNativeScriptOverlay({ baseURL: 'http://localhost:9999/', retryMs: 100 }); + await flush(); + expect(isSecretName('licenseNumber')).toBe(true); + dispose(); + }); + it('tears down a session whose socket closed while it was still starting', async () => { let release!: () => void; hookGate = new Promise((resolve) => (release = resolve)); diff --git a/packages/devtools/src/analog-server-log.ts b/packages/devtools/src/analog-server-log.ts index dcc84f75..08ae9a5b 100644 --- a/packages/devtools/src/analog-server-log.ts +++ b/packages/devtools/src/analog-server-log.ts @@ -1,8 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import { isRedactedKey } from './forms-privacy.ts'; +import { JWT, isRedactedKey } from './forms-privacy.ts'; import { redactJsonText } from './json-text-redact.ts'; -const JWT = /\beyJ[\w-]{5,}\.[\w-]{5,}\.[\w-]{5,}/g; const BEARER = /\bBearer\s+[\w.~+/=-]+/gi; const QUERY_PAIR = /([?&])([^=&#\s"'\\]*)=([^&#\s"'\\]*)/g; const SECRET_QUERY_KEY = diff --git a/packages/devtools/src/forms-privacy.ts b/packages/devtools/src/forms-privacy.ts index 1b07e0d2..45e56032 100644 --- a/packages/devtools/src/forms-privacy.ts +++ b/packages/devtools/src/forms-privacy.ts @@ -60,7 +60,8 @@ const SECRET_PAIRS = new Set([ const SECRET_AUTOCOMPLETE = /password|one-time-code|cc-/i; const MASK_MARKERS = '.sentry-mask, .rr-mask, [data-private], [data-pangular="mask"]'; const UNMASK_MARKER = '[data-pangular="unmask"]'; -const JWT = /\beyJ[\w-]{5,}\.[\w-]{5,}\.[\w-]{5,}/g; +/** A JWT, including an unsigned (`alg: none`) one with an empty signature. */ +export const JWT = /eyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]*/g; const BEARER = /\bBearer\s+[\w.~+/=-]+/gi; interface PrivacyConfig { diff --git a/packages/devtools/src/http-payload.ts b/packages/devtools/src/http-payload.ts index d3b205d6..bca83365 100644 --- a/packages/devtools/src/http-payload.ts +++ b/packages/devtools/src/http-payload.ts @@ -1,5 +1,7 @@ +import { isRedactedKey } from './forms-privacy.ts'; import type { HydrationMismatch } from './http-hydration.ts'; import { redactStrings } from './http-redact.ts'; +import { redactJsonText } from './json-text-redact.ts'; import { redactText, redactUrl } from './router.ts'; import { serialize } from './serialize.ts'; import type { HydrationStats } from './types.ts'; @@ -28,8 +30,19 @@ const MAX_VALUE_CHARS = 20_000; const VALUE_LIMITS = { depth: 12, keys: 200, items: 200, text: MAX_VALUE_CHARS, budget: 50_000 }; +/** + * Masks the value of an entry. Text bodies and entries the page clipped to a + * string can hold JSON, so their `"key": value` pairs are masked by key too. + */ function safe(value: unknown): unknown { - return clip(redactStrings(serialize(value, VALUE_LIMITS))); + return clip(redactStrings(maskJsonText(serialize(value, VALUE_LIMITS)))); +} + +function maskJsonText(value: unknown): unknown { + if (typeof value === 'string') return redactJsonText(value, isRedactedKey); + if (Array.isArray(value)) return value.map(maskJsonText); + if (value === null || typeof value !== 'object') return value; + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, maskJsonText(item)])); } function clip(value: unknown): unknown { diff --git a/packages/devtools/src/overlay-nativescript.ts b/packages/devtools/src/overlay-nativescript.ts index 4fdfb398..cad39c23 100644 --- a/packages/devtools/src/overlay-nativescript.ts +++ b/packages/devtools/src/overlay-nativescript.ts @@ -10,7 +10,9 @@ import { Application, isAndroid } from '@nativescript/core'; import { connectDevframe } from 'devframe/client'; import { keepaliveDue } from './change-detection.ts'; import { collectComponentTree, type ComponentDebugNg } from './component-tree.ts'; +import { configFromConnection } from './config.ts'; import { elementById } from './element-id.ts'; +import { setRedaction } from './forms-privacy.ts'; import { hostBySelector } from './host-tree.ts'; import { collectInjectorTree } from './injector-tree.ts'; import { attachNgrx } from './ngrx-overlay.ts'; @@ -156,6 +158,7 @@ async function startSession( intervalMs: number, ): Promise<() => void> { const my = rpc.scope('pangular'); + setRedaction(configFromConnection(rpc.connectionMeta).redaction); const pageId = Math.random().toString(36).slice(2, 6); const tree = nativeScriptTree(angularDebugApi, () => Application.getRootView() as NativeView); diff --git a/packages/devtools/src/router.ts b/packages/devtools/src/router.ts index 25ed8996..ede37bf1 100644 --- a/packages/devtools/src/router.ts +++ b/packages/devtools/src/router.ts @@ -1,3 +1,4 @@ +import { JWT } from './forms-privacy.ts'; import { REDACTED, isSensitive, serializeFormValue } from './forms.ts'; import { clip } from './text.ts'; @@ -114,7 +115,6 @@ const CANCEL_CODES = [ 'Aborted', ]; const SKIP_CODES = ['IgnoredSameUrlNavigation', 'IgnoredByUrlHandlingStrategy']; -const JWT = /eyJ[\w-]{4,}\.[\w-]{4,}\.[\w-]{4,}/g; const BEARER = /Bearer\s+[\w.~+/-]+=*/gi; const LONG_TOKEN = /(?<=[/=])[A-Za-z0-9_-]{32,}(?=[/?#&;]|$)/g;