From fa028fdc99d896b9aa8eda5b9ad56722c780522d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 21:34:45 +0000 Subject: [PATCH] fix: mask secrets in TransferState text and unsigned JWTs TransferState entries the page clipped to a string, and text bodies that hold JSON, only had token-shaped text masked, so keys like password or apiKey reached the panel in clear. Mask "key": value pairs in those strings by key, including redaction.secretNames. The NativeScript overlay never applied the redaction config, so custom secretNames and unmask were ignored for its component and signal values. Apply it when the session starts, as the other overlays do. JWTs with an empty signature (alg: none) were not matched by any of the three JWT patterns. Share one pattern that also matches them. --- .../__tests__/http-payload-redaction.test.ts | 59 +++++++++++++++++++ .../overlay-nativescript-session.test.ts | 17 +++++- packages/devtools/src/analog-server-log.ts | 3 +- packages/devtools/src/forms-privacy.ts | 3 +- packages/devtools/src/http-payload.ts | 15 ++++- packages/devtools/src/overlay-nativescript.ts | 3 + packages/devtools/src/router.ts | 2 +- 7 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 packages/devtools/src/__tests__/http-payload-redaction.test.ts 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;