diff --git a/apps/docs/src/content/inspectors/ssr-http.md b/apps/docs/src/content/inspectors/ssr-http.md index 34096a12..f0089a31 100644 --- a/apps/docs/src/content/inspectors/ssr-http.md +++ b/apps/docs/src/content/inspectors/ssr-http.md @@ -163,8 +163,8 @@ Two router tools cover related ground: ## Limits and gotchas - - Request URLs, page URLs and error messages are redacted like router URLs. Response previews and TransferState values reach the devtools server as they are. Don't expose the dev server beyond localhost. See Security. + + Request URLs, page URLs, error messages, response previews and TransferState entries are redacted on the devtools server: secret-looking keys, JWTs, bearer tokens and secret query values. Other values are shown as they are, so don't expose the dev server beyond localhost. See Security. ### Prerendered routes make no requests diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index 6ae381b9..72ea3cba 100644 --- a/apps/docs/src/content/security.md +++ b/apps/docs/src/content/security.md @@ -12,7 +12,7 @@ description: Who can reach the devtools, and which values are redacted before th The devtools read your running app and send what they find to a server on your machine. This page covers who can reach that server, and what is redacted on the way. - Don't expose the dev server beyond localhost. Some values, such as HTTP response previews, are sent as they are. + Don't expose the dev server beyond localhost. Values that no rule recognizes as secret are sent as they are. ## At a glance @@ -193,15 +193,13 @@ Component inputs, signal values, NgRx state, and pipe inputs, outputs and async ### Analog -Server call previews and URLs are redacted: keys in JSON bodies that the forms rules treat as secret, secret query parameters, JWTs and bearer tokens. This covers form action validation errors and redirect targets too. Only JSON and plain text responses get a preview, and it is cut at 1000 characters. The devtools keep the first 16 KB of a body, and a cut JSON body still has its secret-looking keys redacted. The `load()` data preview on the open page redacts the same keys. Keys are matched by whole words, so `sessionId` and `apiKey` are redacted while `author` and `passengers` stay visible. JSON nested deeper than the preview reads is shown as `[Truncated]`. +Server call previews and URLs are redacted: keys in JSON bodies that the forms rules treat as secret, secret query parameters, JWTs and bearer tokens. This covers form action validation errors and redirect targets too. Only JSON and plain text responses get a preview, and it is cut at 1000 characters. The devtools keep the first 16 KB of a body, and a cut JSON body still has its secret-looking keys redacted. The `load()` data preview on the open page redacts the same keys, and JWTs and bearer tokens inside its string values. The page report's URL, `load()` preview and hydration errors are redacted again on the devtools server. Keys are matched by whole words, so `sessionId` and `apiKey` are redacted while `author` and `passengers` stay visible. JSON nested deeper than the preview reads is shown as `[Truncated]`. ### SSR & HTTP Request URLs, page URLs and error messages in the [SSR & HTTP tab](./inspectors/ssr-http.md) are redacted like router URLs, in the page and again on the devtools server. This covers SSR and client calls, and `devframe_state_read`. -### Not redacted - -Response previews and TransferState values in the [SSR & HTTP tab](./inspectors/ssr-http.md) are not redacted. They reach the devtools server unchanged, so don't expose the dev server beyond localhost. +Response previews are redacted on the devtools server: keys that the forms rules treat as secret, JWTs, bearer tokens and secret query pairs. The same holds for TransferState entries: their values, keys and request URLs are redacted before they reach the panel or an agent. Large payload values are still cut, so a long value can end in `[Truncated]`. ## Checklist diff --git a/packages/devtools/src/__tests__/analog-mcp.test.ts b/packages/devtools/src/__tests__/analog-mcp.test.ts index 1ec47594..886b3333 100644 --- a/packages/devtools/src/__tests__/analog-mcp.test.ts +++ b/packages/devtools/src/__tests__/analog-mcp.test.ts @@ -136,6 +136,30 @@ describe('Analog MCP tools', () => { expect(text).toContain('restart the dev server'); }); + it('analog-current-page masks tokens in the url, load preview and hydration errors', async () => { + const { call, push } = await boot(makeProject(BASE_FILES)); + await push('push-analog', { + ...report, + url: '/auth/callback?code=abc999&token=eyJhbGciOi.eyJzdWIi.sigsig', + load: { preview: '{"header":"Bearer xyz.789"}', bytes: 10, keys: ['header'] }, + hydrationErrors: ['NG0500 at /cb?token=qrs456 Bearer lmn.012'], + }); + const text = await call('analog-current-page'); + expect(text).not.toMatch(/abc999|eyJhbGci|xyz\.789|qrs456|lmn\.012/); + expect(text).toContain('/auth/callback'); + }); + + it('analog-current-page masks secret query values in load keys', async () => { + const { call, push } = await boot(makeProject(BASE_FILES)); + await push('push-analog', { + ...report, + load: { preview: '{}', bytes: 2, keys: ['/callback?token=abc123', 'id'] }, + }); + const text = await call('analog-current-page'); + expect(text).not.toContain('abc123'); + expect(text).toContain('id'); + }); + it('analog-server-calls lists calls and flags loads fetched twice', async () => { const { call } = await boot(makeProject(BASE_FILES)); expect(await call('analog-server-calls')).toContain('No server calls recorded yet'); diff --git a/packages/devtools/src/__tests__/analog-runtime.test.ts b/packages/devtools/src/__tests__/analog-runtime.test.ts index 2c17d6b0..8b48bb50 100644 --- a/packages/devtools/src/__tests__/analog-runtime.test.ts +++ b/packages/devtools/src/__tests__/analog-runtime.test.ts @@ -27,6 +27,16 @@ function analogRoute(path: string, endpointKey: string, extra: Record { + it('masks JWTs and bearer tokens held in string values of the load preview', () => { + const summary = loadSummary({ + header: 'Bearer abc.def123', + note: 'eyJhbGciOiJI.eyJzdWIiOiIx.SflKxwRJSMeK', + password: 'x', + })!; + expect(summary.preview).not.toMatch(/abc\.def123|eyJhbGci/); + expect(loadSummary('Bearer abc.def123')!.preview).not.toContain('abc.def123'); + }); + it('finds the hidden route metadata and maps it to files', () => { const route = analogRoute('', '/src/app/pages/products/[id].server.ts'); expect(analogMetaOf(route)?.endpointKey).toBe('/src/app/pages/products/[id].server.ts'); diff --git a/packages/devtools/src/__tests__/http-server.test.ts b/packages/devtools/src/__tests__/http-server.test.ts index 5430cba9..a6db3579 100644 --- a/packages/devtools/src/__tests__/http-server.test.ts +++ b/packages/devtools/src/__tests__/http-server.test.ts @@ -205,6 +205,88 @@ describe('http redaction', () => { expect(page.calls[0].error).not.toContain('s3cr3t'); }); + it('redacts response previews on client and SSR calls', async () => { + vi.useFakeTimers(); + const { push, state } = await boot({ redaction: { secretNames: ['tenant'] } }); + const preview = JSON.stringify({ + access_token: 'eyJhbGciOi.eyJzdWIi.sigsig', + password: 'hunter2', + note: 'Bearer abc.def123', + tenant: 'acme', + ok: 1, + }); + const payload = { found: false, size: 0, entries: [] }; + await push('push-http', report('a', { payload, calls: [call('/api/login', { preview })] })); + httpRegistry().record?.(call('http://api.local/me', { side: 'server', preview }) as never); + await vi.advanceTimersByTimeAsync(150); + const current = await state(); + for (const text of [current.pages[0].calls[0].preview!, current.serverCalls[0].preview!]) { + expect(text).not.toMatch(/eyJhbGci|hunter2|abc\.def123|acme/); + expect(text).toContain('"ok":1'); + } + const clipped = `{"password":"hunter2","items":[${'1,'.repeat(1200)}`; + await push( + 'push-http', + report('b', { payload, calls: [call('/api/x', { preview: clipped })] }), + ); + const b = (await state()).pages.find((p) => p.pageId === 'b')!; + expect(b.calls[0].preview).not.toContain('hunter2'); + }); + + it('redacts secret query pairs inside value strings and clipped escaped keys', async () => { + vi.useFakeTimers(); + const { push, state, payloads } = await boot(); + const payload = { + found: true, + size: 1, + entries: [{ key: 'k', size: 1, value: { next: '/callback?token=abc123&tab=1' } }], + }; + const preview = JSON.stringify({ next: '/cb?code=qrs456&tab=1' }); + const clipped = `{"pass\\u0077ord":"hunter2","items":[${'1,'.repeat(1200)}`; + await push( + 'push-http', + report('a', { + payload, + calls: [call('/a', { preview }), call('/b', { id: 'c2', preview: clipped })], + }), + ); + expect(JSON.stringify((await payloads()).pages['a'])).not.toContain('abc123'); + const [first, second] = (await state()).pages[0].calls; + expect(first.preview).not.toContain('qrs456'); + expect(first.preview).toContain('tab=1'); + expect(second.preview).not.toContain('hunter2'); + }); + + it('redacts TransferState payload values, URLs and keys', async () => { + const { push, payloads } = await boot({ redaction: { secretNames: ['tenant'] } }); + await push( + 'push-http', + report('a', { + payload: { + found: true, + size: 10, + entries: [ + { + key: 'GET./api/me?access_token=abc123', + size: 1, + value: { access_token: 'eyJhbGciOi.eyJzdWIi.sigsig', tenant: 'acme', name: 'ada' }, + http: { url: '/api?access_token=abc123', status: 200 }, + }, + ], + }, + }), + ); + const [entry] = (await payloads()).pages['a'].entries; + const text = JSON.stringify(entry); + expect(text).not.toMatch(/eyJhbGci|acme|abc123/); + expect(entry.value).toMatchObject({ + access_token: '[redacted]', + tenant: '[redacted]', + name: 'ada', + }); + expect(entry.http?.url).toBe('/api?access_token=[redacted]'); + }); + it('redacts SSR calls before they reach the timeline', async () => { vi.useFakeTimers(); const { state } = await boot(); diff --git a/packages/devtools/src/__tests__/ngrx-collector.test.ts b/packages/devtools/src/__tests__/ngrx-collector.test.ts index acc6ead5..78959364 100644 --- a/packages/devtools/src/__tests__/ngrx-collector.test.ts +++ b/packages/devtools/src/__tests__/ngrx-collector.test.ts @@ -247,6 +247,18 @@ describe('ngrx collector', () => { }); }); + it('keeps a redacted top-level state key out of the change log', () => { + const { store, collector } = setup(); + (store as any)[STATE_SOURCE].apiKey = writable('hunter2'); + collector.collect(); + (store as any)[STATE_SOURCE].apiKey.set('swordfish'); + return Promise.resolve().then(() => { + const log = JSON.stringify(collector.logSince(0)); + expect(log).toContain('apiKey'); + expect(log).not.toMatch(/hunter2|swordfish/); + }); + }); + it('only rescans the page when asked to rediscover stores', () => { const { store, collector, ng } = setup(); const spy = vi.spyOn(ng, 'getComponent'); @@ -964,6 +976,16 @@ describe('ngrx collector with a classic store', () => { ]); }); + it('keeps a redacted top-level key out of the logged diff', () => { + const { store, collector } = fakeClassic({ apiKey: 'hunter2' }, (state, action) => + action.type === 'rotate' ? { apiKey: 'swordfish' } : state, + ); + store.dispatch({ type: 'rotate' }); + const log = JSON.stringify(collector.logSince(0)); + expect(log).toContain('apiKey'); + expect(log).not.toMatch(/hunter2|swordfish/); + }); + it('tags each action with where it came from', () => { const { store, collector } = fakeClassic({ n: 0 }, (s) => ({ n: s.n + 1 })); store.dispatch({ type: 'from component' }); diff --git a/packages/devtools/src/__tests__/router-tools.test.ts b/packages/devtools/src/__tests__/router-tools.test.ts index 8b1d49f3..8365d1c9 100644 --- a/packages/devtools/src/__tests__/router-tools.test.ts +++ b/packages/devtools/src/__tests__/router-tools.test.ts @@ -173,6 +173,17 @@ describe('router events', () => { }); describe('router reports', () => { + it('accepts a recorded request URL up to the length the collector keeps', () => { + const url = `https://x.test/?${'a=1&'.repeat(100)}`; + const withRequests = (u: string) => ({ + pageId: 'a', + snapshot: null, + navigations: [{ ...nav(1), requests: { count: 1, urls: [u] } }], + }); + expect(isRouterReport(withRequests(url))).toBe(true); + expect(isRouterReport(withRequests('x'.repeat(2001)))).toBe(false); + }); + it('accepts what the overlay sends and rejects what the tools cannot read', () => { const valid: RouterReport = { pageId: 'a', snapshot, navigations: [nav(1)] }; expect(isRouterReport(valid)).toBe(true); diff --git a/packages/devtools/src/analog-redact.ts b/packages/devtools/src/analog-redact.ts new file mode 100644 index 00000000..0507239c --- /dev/null +++ b/packages/devtools/src/analog-redact.ts @@ -0,0 +1,23 @@ +import type { AnalogRuntimeReport } from './analog-runtime.ts'; +import { redactPreview } from './http-redact.ts'; +import { redactText, redactUrl } from './router.ts'; +import { clip } from './text.ts'; + +/** + * Hides secret query values, JWTs, bearer tokens and `redaction.secretNames` + * in the text fields of a page report before it is stored or served. + */ +export function redactAnalogReport(report: AnalogRuntimeReport): AnalogRuntimeReport { + return { + ...report, + url: redactUrl(report.url), + ...(report.load && { + load: { + ...report.load, + preview: redactPreview(report.load.preview), + keys: report.load.keys.map((key) => redactUrl(key)), + }, + }), + hydrationErrors: report.hydrationErrors.map((error) => clip(redactText(error), 300)), + }; +} diff --git a/packages/devtools/src/analog-runtime.ts b/packages/devtools/src/analog-runtime.ts index 515b795d..4f0f2a50 100644 --- a/packages/devtools/src/analog-runtime.ts +++ b/packages/devtools/src/analog-runtime.ts @@ -1,6 +1,6 @@ import { keepaliveDue } from './change-detection.ts'; import { hasStateScript, scanHydration } from './http-hydration.ts'; -import { isRedactedKey } from './forms-privacy.ts'; +import { isRedactedKey, redactMessage } from './forms-privacy.ts'; import { createHydrationScanner } from './http-overlay.ts'; import { httpRegistry } from './http-rules.ts'; import { findRouters, type RouterDebugApi } from './router.ts'; @@ -65,6 +65,7 @@ export function fileOfEndpoint(endpointKey: string | undefined): { } function redact(value: unknown, depth = 0): unknown { + if (typeof value === 'string') return redactMessage(value); if (value === null || typeof value !== 'object') return value; if (depth > 5) return '[Truncated]'; if (Array.isArray(value)) return value.slice(0, 20).map((item) => redact(item, depth + 1)); @@ -144,7 +145,7 @@ export function loadSummary(data: unknown): AnalogRuntimeReport['load'] | undefi try { text = JSON.stringify(redact(data)) ?? 'undefined'; } catch { - text = String(data); + text = redactMessage(String(data)); } const bytes = read(() => (JSON.stringify(data) ?? '').length, text.length); return { diff --git a/packages/devtools/src/http-payload.ts b/packages/devtools/src/http-payload.ts index f730b170..582b6fe9 100644 --- a/packages/devtools/src/http-payload.ts +++ b/packages/devtools/src/http-payload.ts @@ -1,4 +1,7 @@ import type { HydrationMismatch } from './http-hydration.ts'; +import { redactStrings } from './http-redact.ts'; +import { redactUrl } from './router.ts'; +import { serialize } from './serialize.ts'; import type { HydrationStats } from './types.ts'; export type PayloadSource = 'http' | 'analog' | 'hydration'; @@ -23,11 +26,19 @@ export interface PayloadSummary { const MAX_VALUE_CHARS = 20_000; +const VALUE_LIMITS = { depth: 12, keys: 200, items: 200, text: MAX_VALUE_CHARS, budget: 50_000 }; + +function safe(value: unknown): unknown { + return clip(redactStrings(serialize(value, VALUE_LIMITS))); +} + function clip(value: unknown): unknown { const text = JSON.stringify(value) ?? ''; return text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS)}…` : value; } +const redactOptional = (url: string | undefined) => (url === undefined ? url : redactUrl(url)); + const MAX_ENTRIES = 500; const MAX_LIST = 200; @@ -51,8 +62,9 @@ export function sanitizePayload(input: unknown): PayloadSummary { for (const raw of Array.isArray(p.entries) ? p.entries.slice(0, MAX_ENTRIES) : []) { if (!raw || typeof raw !== 'object') continue; const e = raw as { [K in keyof PayloadEntry]?: unknown }; - const key = str(e.key, 500); - if (key === undefined) continue; + const rawKey = str(e.key, 500); + if (rawKey === undefined) continue; + const key = redactUrl(rawKey).slice(0, 500); const h = e.http && typeof e.http === 'object' ? (e.http as Record) : null; const source = e.source === 'http' || e.source === 'analog' || e.source === 'hydration' @@ -63,12 +75,12 @@ export function sanitizePayload(input: unknown): PayloadSummary { entries.push({ key, size: num(e.size) ?? 0, - value: clip(e.value), + value: safe(e.value), ...(source && { source }), ...(fnId && /^[0-9a-f]{16}$/.test(fnId) && { fn: { id: fnId } }), ...(h && { http: { - url: str(h['url'], 2000), + url: redactOptional(str(h['url'], 2000)), status: num(h['status']), statusText: str(h['statusText'], 200), responseType: str(h['responseType'], 20), diff --git a/packages/devtools/src/http-redact.ts b/packages/devtools/src/http-redact.ts index 75ad7401..01723ff9 100644 --- a/packages/devtools/src/http-redact.ts +++ b/packages/devtools/src/http-redact.ts @@ -1,10 +1,57 @@ +import { isRedactedKey } from './forms-privacy.ts'; import type { HttpCall } from './http-rules.ts'; import { redactText, redactUrl } from './router.ts'; +import { serialize } from './serialize.ts'; import { clip } from './text.ts'; +const PREVIEW_MAX = 2001; +const JSON_PAIR = /("((?:[^"\\]|\\.){1,100})"\s*:\s*)("(?:[^"\\]|\\.)*"?|[^,}\]\s]*)/g; + +export function redactStrings(value: unknown): unknown { + if (typeof value === 'string') return redactText(value); + if (Array.isArray(value)) return value.map(redactStrings); + if (value === null || typeof value !== 'object') return value; + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactStrings(item)])); +} + +function decodeKey(key: string): string { + try { + return JSON.parse(`"${key}"`) as string; + } catch { + return key; + } +} + +/** + * Hides secrets in a recorded body preview. A complete JSON preview is walked + * by key and value; a clipped one falls back to masking `"key": value` pairs + * and token-shaped text. + */ +export function redactPreview(preview: string): string { + try { + const parsed: unknown = JSON.parse(preview); + if (parsed !== null && typeof parsed === 'object') { + const safe = serialize(parsed, { + depth: 8, + keys: 100, + items: 100, + text: PREVIEW_MAX, + budget: 5000, + }); + return clip(JSON.stringify(redactStrings(safe)) ?? '', PREVIEW_MAX); + } + } catch { + /* clipped or not JSON: fall through to text masking */ + } + const masked = preview.replace(JSON_PAIR, (match, head: string, key: string) => + isRedactedKey(decodeKey(key)) ? `${head}"[redacted]"` : match, + ); + return clip(redactText(masked), PREVIEW_MAX); +} + /** * Hides secret query values, tokens and `redaction.secretNames` in a call's - * URLs and error, the same way the router redacts its URLs. + * URLs, error and response preview, the same way the router redacts its URLs. */ export function redactCall(call: HttpCall): HttpCall { return { @@ -12,5 +59,6 @@ export function redactCall(call: HttpCall): HttpCall { url: redactUrl(call.url), ...(call.pageUrl === undefined ? {} : { pageUrl: redactUrl(call.pageUrl) }), ...(call.error === undefined ? {} : { error: clip(redactText(call.error), 500) }), + ...(call.preview === undefined ? {} : { preview: redactPreview(call.preview) }), }; } diff --git a/packages/devtools/src/ngrx-collector.ts b/packages/devtools/src/ngrx-collector.ts index e0a6ddca..f9b5694f 100644 --- a/packages/devtools/src/ngrx-collector.ts +++ b/packages/devtools/src/ngrx-collector.ts @@ -1,4 +1,5 @@ import { untracked } from '@angular/core'; +import { isRedactedKey, REDACTED } from './forms-privacy.ts'; import { documentTree, type HostTree } from './host-tree.ts'; import { className, tokenName } from './injector-tree.ts'; import { @@ -175,6 +176,10 @@ function snapshotDiff(before: Snapshot, after: Snapshot, keys: PropertyKey[]): N if (out.length >= MAX_DIFF) break; if (before[key] === after[key]) continue; const root = String(key); + if (typeof key === 'string' && isRedactedKey(key)) { + out.push({ path: root, op: 'change', before: REDACTED, after: REDACTED }); + continue; + } const found = diff( serializeSlice(key, before[key]), serializeSlice(key, after[key]), diff --git a/packages/devtools/src/rpc/analog-register.ts b/packages/devtools/src/rpc/analog-register.ts index fd116e9a..d9e129d8 100644 --- a/packages/devtools/src/rpc/analog-register.ts +++ b/packages/devtools/src/rpc/analog-register.ts @@ -8,6 +8,7 @@ import { refetchedServerFns, type AnalogCall, } from '../analog-server-log.ts'; +import { redactAnalogReport } from '../analog-redact.ts'; import type { PayloadSummary } from '../http-payload.ts'; import { explainUrl, @@ -207,7 +208,9 @@ export async function registerAnalog( handler: (report: unknown) => { if (!isAnalogReport(report)) return; seenAt.set(report.pageId, Date.now()); - apply(mergeAnalogReport(current(), resolveAnalogReport(project(), report))); + apply( + mergeAnalogReport(current(), resolveAnalogReport(project(), redactAnalogReport(report))), + ); }, }); my.rpc.register({ diff --git a/packages/devtools/src/rpc/router-tools.ts b/packages/devtools/src/rpc/router-tools.ts index fc29dae2..fb56e64a 100644 --- a/packages/devtools/src/rpc/router-tools.ts +++ b/packages/devtools/src/rpc/router-tools.ts @@ -106,8 +106,8 @@ function optional(value: unknown, check: (v: unknown) => boolean): boolean { return value === undefined || check(value); } -function isNames(value: unknown, max = 100): boolean { - return Array.isArray(value) && value.length <= max && value.every((v) => isText(v, 300)); +function isNames(value: unknown, max = 100, length = 300): boolean { + return Array.isArray(value) && value.length <= max && value.every((v) => isText(v, length)); } function isNumber(value: unknown): boolean { @@ -242,7 +242,7 @@ function isNavigation(value: unknown): boolean { (v) => isRecord(v) && isNumber((v as { count?: unknown }).count) && - isNames((v as { urls?: unknown }).urls, 10), + isNames((v as { urls?: unknown }).urls, 10, 2000), ) && optional(nav.runs, (v) => Array.isArray(v) && v.length <= 40 && v.every(isRun)) && optional(