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__/http-payload-redaction.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const clients: Client[] = [];
const restoreHook = vi.fn();
let hookGate: Promise<void> = Promise.resolve();
let hookInstalls = 0;
let connectionMeta: { configs?: object } = {};

vi.mock('@nativescript/core', () => ({
Application: { getRootView: () => null },
Expand All @@ -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() } }),
};
Expand Down Expand Up @@ -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<void>((resolve) => (release = resolve));
Expand Down
3 changes: 1 addition & 2 deletions packages/devtools/src/analog-server-log.ts
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down
3 changes: 2 additions & 1 deletion packages/devtools/src/forms-privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
15 changes: 14 additions & 1 deletion packages/devtools/src/http-payload.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions packages/devtools/src/overlay-nativescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/devtools/src/router.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { JWT } from './forms-privacy.ts';
import { REDACTED, isSensitive, serializeFormValue } from './forms.ts';
import { clip } from './text.ts';

Expand Down Expand Up @@ -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;

Expand Down
Loading