diff --git a/.changeset/calm-ravens-protect.md b/.changeset/calm-ravens-protect.md index 7f09e0b03f..79e28be813 100644 --- a/.changeset/calm-ravens-protect.md +++ b/.changeset/calm-ravens-protect.md @@ -3,11 +3,16 @@ "rrweb-snapshot": minor --- -Add an opt-in, versioned privacy policy with strict, balanced, custom, and -legacy presets. Policies consistently protect text, form values, sensitive -attributes, URLs, snapshots, and incremental mutations while preserving the -existing rrweb masking options as the backwards-compatible legacy path. The -vendor-neutral `data-privacy="exclude|mask|allow"` HTML binding works without -recorder-specific configuration. Add fail-closed canvas-region masking for -complex canvas applications, suppress unmasked full-snapshot canvas stills, -and provide coarse and callback-based final attribute masking escape hatches. +Add an opt-in, versioned `privacyPolicy` with `strict`, `balanced`, and +`legacy` presets. Compiled policies consistently protect text, form values, +sensitive attributes (`title`, `placeholder`, `aria-label`), and URLs across +full snapshots and incremental mutations, while the existing rrweb masking +options remain the backwards-compatible `legacy` default. CSS is never +masked, on any preset. Under `balanced`/`strict`, the vendor-neutral +`data-privacy="exclude|mask|allow"` HTML binding and common cross-vendor +masking class names are recognized directly in markup; selector-based policy +`rules` work under every preset, including `legacy`. Add fail-closed +`canvasMasking` region masking for complex canvas applications (configuring +it forces the FPS capture path and suppresses the unmasked `rr_dataURL` +full-snapshot still), plus coarse (`maskAllElementAttributes`) and +callback-based (`maskAttributeFn`) final attribute masking escape hatches. diff --git a/.changeset/khaki-hoops-smile.md b/.changeset/khaki-hoops-smile.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/khaki-hoops-smile.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/kind-pumas-detect.md b/.changeset/kind-pumas-detect.md index 324d54b127..b5beb36eae 100644 --- a/.changeset/kind-pumas-detect.md +++ b/.changeset/kind-pumas-detect.md @@ -5,7 +5,7 @@ "@rrweb/types": minor --- -Move Highlight-style heuristic PII auto-detection out of `balanced`/`strict` +Move heuristic PII auto-detection out of `balanced`/`strict` defaults and into an opt-in `@rrweb/rrweb-plugin-privacy-detectors` plugin. Presets still mask form values and honor policy rules; email/phone/card/SSN/IP text matching is enabled only by the plugin or `applyPrivacyDetectors`. diff --git a/.changeset/loud-lions-protect.md b/.changeset/loud-lions-protect.md deleted file mode 100644 index da76e192f2..0000000000 --- a/.changeset/loud-lions-protect.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"rrweb": patch -"rrweb-snapshot": patch ---- - -Harden privacy detector compilation against nested and high-quantifier ReDoS -patterns, lookaround, and named backreferences. Apply capture policies to CSS -text, `_cssText`, inline style, and stylesheet mutations, and keep custom -detector schema constraints aligned with runtime validation. diff --git a/.changeset/privacy-v2-simplification.md b/.changeset/privacy-v2-simplification.md new file mode 100644 index 0000000000..ea2e343034 --- /dev/null +++ b/.changeset/privacy-v2-simplification.md @@ -0,0 +1,46 @@ +--- +"rrweb-snapshot": minor +"rrweb": minor +"@rrweb/types": major +"@rrweb/rrweb-plugin-privacy-detectors": minor +"@rrweb/utils": minor +--- + +Privacy at Capture v2: policies now compile onto rrweb's existing masking +primitives; heuristic detectors are a fixed whole-value set (custom regex +patterns removed); CSS is never masked; canvas masking forces the FPS capture +path; selector and config errors fail closed. BREAKING (@rrweb/types): +`ImageBitmapDataURLWorkerParams` is a union; privacy rule `style`, +`classification`, custom detectors, and the `'custom'` preset are removed. + +Additional breaking/behavior notes: + +- `needMaskingText` (exported from `rrweb-snapshot`) gained parameters; old + positional callers break. +- `

secret

', + strict, + ); + expect(out).toMatch(/body\s*\{\s*color:\s*red/); + expect(out).not.toContain('secret'); + }); + + it('never masks the

secret

'; + const styleEl = document.querySelector('style') as HTMLStyleElement; + // with no CSSOM sheet (CSP, cross-origin) the CSS stays on the text node + // instead of moving to `_cssText`, which is what actually exercises + // serializeTextNode's `isStyle` exemption + Object.defineProperty(styleEl, 'sheet', { get: () => null }); + const out = JSON.stringify(snapshot(document, { privacyPolicy: strict })); + expect(out).toContain('body{color:red}'); + expect(out).not.toContain('secret'); + }); + + it('unmask selector wins for its subtree, nearest ancestor decides', () => { + const out = serialize( + '

visible

hidden

', + strict, + ); + expect(out).toContain('visible'); + expect(out).not.toContain('hidden'); + }); + + it("a user-supplied unmaskTextSelector escapes strict's mask-everything default", () => { + document.body.innerHTML = + '

visible

hidden

'; + const out = JSON.stringify( + snapshot(document, { + privacyPolicy: strict, + unmaskTextSelector: '.support-widget', + }), + ); + expect(out).toContain('visible'); + expect(out).not.toContain('hidden'); + }); + + it('detectors mask the whole text node under legacy when configured', () => { + const withDetectors: PrivacyPolicy = { + version: 1, + preset: 'legacy', + detectors: { paymentCard: true, phone: true }, + }; + const out = serialize( + '

call 5551234567 4111 1111 1111 1111 now

', + withDetectors, + ); + expect(out).not.toContain('4111 1111 1111 1111'); + }); + + it('detectors mask an input value at snapshot time under legacy', () => { + const withDetectors: PrivacyPolicy = { + version: 1, + preset: 'legacy', + detectors: { email: true }, + }; + const out = serialize( + '', + withDetectors, + ); + expect(out).not.toContain('bob@example.com'); + expect(out).toContain('*'.repeat('bob@example.com'.length)); + }); + + it('keeps masking inherited from an ancestor outside the shadow root', () => { + withShadowRoot( + '
', + '

secret

', + ); + const out = JSON.stringify( + snapshot(document, { + privacyPolicy: { version: 1, preset: 'balanced' }, + }), + ); + expect(out).not.toContain('secret'); + }); + + it('lets an unmask selector inside the shadow root escape a masked host', () => { + withShadowRoot( + '
', + '

visible

', + ); + const out = JSON.stringify( + snapshot(document, { + privacyPolicy: { version: 1, preset: 'balanced' }, + }), + ); + expect(out).toContain('visible'); + }); + + it('masks a text node parented directly by a shadow root under strict', () => { + document.body.innerHTML = '
'; + const host = document.querySelector('#host') as HTMLElement; + host + .attachShadow({ mode: 'open' }) + .appendChild(document.createTextNode('secret')); + const out = JSON.stringify(snapshot(document, { privacyPolicy: strict })); + expect(out).not.toContain('secret'); + }); + + it('legacy without detectors leaves text untouched', () => { + expect(serialize('

bob@example.com

', undefined)).toContain( + 'bob@example.com', + ); + }); +}); + +describe('maskInput v2', () => { + const balanced = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); + const legacy = compilePrivacyPolicy(undefined); + const input = (attrs = '') => { + document.body.innerHTML = ``; + return document.querySelector('input') as HTMLInputElement; + }; + it('balanced masks all inputs shape-free (stars, not digits)', () => { + const out = maskInput({ + element: input(), + tagName: 'input', + type: 'text', + value: '4111 1111 1111 1111', + maskInputOptions: {}, + privacy: balanced, + }); + expect(out).toBe('*'.repeat(19)); + }); + it('balanced + maskInputFn: fn controls length only, never content', () => { + const out = maskInput({ + element: input(), + tagName: 'input', + type: 'text', + value: 'secret', + maskInputOptions: {}, + maskInputFn: () => '[redacted]', + privacy: balanced, + }); + expect(out).toBe('*'.repeat('[redacted]'.length)); + }); + it('legacy + maskInputFn trusted verbatim when legacy options mask', () => { + const out = maskInput({ + element: input(), + tagName: 'input', + type: 'text', + value: 'secret', + maskInputOptions: { text: true }, + maskInputFn: () => '[redacted]', + privacy: legacy, + }); + expect(out).toBe('[redacted]'); + }); + it('legacy without options passes value through', () => { + expect( + maskInput({ + element: input(), + tagName: 'input', + type: 'text', + value: 'plain', + maskInputOptions: {}, + privacy: legacy, + }), + ).toBe('plain'); + }); + it('detectors mask the whole input value when nothing else would', () => { + const withDetectors = compilePrivacyPolicy({ + version: 1, + preset: 'legacy', + detectors: { email: true }, + }); + expect( + maskInput({ + element: input(), + tagName: 'input', + type: 'text', + value: 'bob@example.com', + maskInputOptions: {}, + privacy: withDetectors, + }), + ).toBe('*'.repeat('bob@example.com'.length)); + // a clean value passes through untouched + expect( + maskInput({ + element: input(), + tagName: 'input', + type: 'text', + value: 'plain', + maskInputOptions: {}, + privacy: withDetectors, + }), + ).toBe('plain'); + }); + it('detectors do not override a trusted legacy maskInputFn composition', () => { + // Mirrors the text-node hook: detectors only run on values that would + // otherwise leave unmasked. When legacy options already mask, the fn's + // output is trusted exactly as before the plugin loaded. + const withDetectors = compilePrivacyPolicy({ + version: 1, + preset: 'legacy', + detectors: { email: true }, + }); + expect( + maskInput({ + element: input(), + tagName: 'input', + type: 'text', + value: 'bob@example.com', + maskInputOptions: { text: true }, + maskInputFn: () => '[redacted]', + privacy: withDetectors, + }), + ).toBe('[redacted]'); + }); + it('protected inputs always mask, even legacy with no options', () => { + expect( + maskInput({ + element: input('type="password"'), + tagName: 'input', + type: 'password', + value: 'pw', + maskInputOptions: {}, + privacy: legacy, + }), + ).toBe('**'); + expect(isProtectedInput(input('autocomplete="cc-number"'))).toBe(true); + }); +}); + +describe('finalizeAttribute', () => { + const strict = compilePrivacyPolicy({ version: 1, preset: 'strict' }); + const balanced = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); + const legacy = compilePrivacyPolicy({ version: 1, preset: 'legacy' }); + + const el = ( + html = '', + selector = 'img', + ) => { + document.body.innerHTML = html; + return document.querySelector(selector) as Element; + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('never masks style, even under strict', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'style', + value: 'color:red', + privacy: strict, + }), + ).toBe('color:red'); + }); + + it('never masks _cssText, on any path', () => { + expect( + finalizeAttribute({ + element: el('', 'style'), + name: '_cssText', + value: 'body{color:red}', + privacy: strict, + maskAllElementAttributes: true, + }), + ).toBe('body{color:red}'); + }); + + it('masks listed attributes under strict/balanced', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: strict, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'placeholder', + value: 'Bob', + privacy: balanced, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'aria-label', + value: 'Bob', + privacy: legacy, + }), + ).toBe('Bob'); + }); + + it('strict nulls media sources; URLs sanitized elsewhere', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'src', + value: 'https://a.com/i.png', + privacy: strict, + }), + ).toBeNull(); + expect( + finalizeAttribute({ + element: el('', 'a'), + name: 'href', + value: 'https://u:p@a.com/x?token=t', + privacy: balanced, + }), + ).toBe('https://a.com/x?token=*'); + // non-media element keeps a sanitized src under strict + expect( + finalizeAttribute({ + element: el('
', 'div'), + name: 'src', + value: 'https://a.com/x?page=1', + privacy: strict, + }), + ).toBe('https://a.com/x?page=*'); + }); + + it('masks value on form tags under strict only', () => { + expect( + finalizeAttribute({ + element: el('', 'input'), + name: 'value', + value: 'abc', + privacy: strict, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el('
  • ', 'li'), + name: 'value', + value: '3', + privacy: strict, + }), + ).toBe('3'); + expect( + finalizeAttribute({ + element: el('', 'input'), + name: 'value', + value: 'abc', + privacy: balanced, + }), + ).toBe('abc'); + }); + + it('maskAllElementAttributes stars everything except generated', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: undefined, + maskAllElementAttributes: true, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'rr_open_mode', + value: 'modal', + privacy: undefined, + maskAllElementAttributes: true, + isGenerated: true, + }), + ).toBe('modal'); + }); + + it('generated attributes are exempt from maskAttributeFn and the policy', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'rr_width', + value: '100px', + privacy: strict, + maskAttributeFn: () => 'nope', + isGenerated: true, + }), + ).toBe('100px'); + }); + + // NOTE: must be the first test in this file that combines maskAll + fn -- + // the warning is one-time per module instance. + it('warns once when maskAttributeFn is ignored under maskAllElementAttributes', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const call = () => + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: undefined, + maskAllElementAttributes: true, + maskAttributeFn: () => 'from-fn', + }); + expect(call()).toBe('***'); + expect(call()).toBe('***'); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('maskAttributeFn throw fails closed to stars; fn ignored under maskAll', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: undefined, + maskAttributeFn: () => { + throw new Error('boom'); + }, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: strict, + maskAllElementAttributes: true, + maskAttributeFn: () => 'from-fn', + }), + ).toBe('***'); + }); + + it('feeds maskAttributeFn output into the policy, which is the final authority', () => { + // The callback is a pipeline stage, not an escape hatch: under balanced or + // strict the policy applies on top of whatever it returned. + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: strict, + maskAttributeFn: (name, value) => `[${name}:${value.length}]`, + }), + ).toBe('*'.repeat('[title:3]'.length)); + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: balanced, + maskAttributeFn: () => '[MASKED]', + }), + ).toBe('*'.repeat('[MASKED]'.length)); + // Under legacy the policy block is the identity, so the callback's output + // survives verbatim. + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: legacy, + maskAttributeFn: () => '[MASKED]', + }), + ).toBe('[MASKED]'); + // ...and an attribute the policy does not touch keeps the fn's output on + // every preset. + expect( + finalizeAttribute({ + element: el(), + name: 'data-x', + value: 'Bob', + privacy: strict, + maskAttributeFn: () => '[MASKED]', + }), + ).toBe('[MASKED]'); + }); + + it('drops a media source the fn emptied, rather than recording src=""', () => { + // '' must not short-circuit the policy: rebuild.ts treats null (attribute + // removed) and '' (setAttribute(name, '')) differently, so an emptied + // under strict has to come out null, not ''. + expect( + finalizeAttribute({ + element: el(), + name: 'src', + value: 'https://a.com/i.png', + privacy: strict, + maskAttributeFn: () => '', + }), + ).toBeNull(); + expect( + finalizeAttribute({ + element: el('', 'iframe'), + name: 'rr_src', + value: 'https://x.com/', + privacy: strict, + maskAttributeFn: () => '', + }), + ).toBeNull(); + // On the branches that do not drop it, an emptied value stays empty + // instead of being resolved into a path by sanitizeUrl. + expect( + finalizeAttribute({ + element: el('', 'a'), + name: 'href', + value: 'https://x.com/', + privacy: balanced, + maskAttributeFn: () => '', + }), + ).toBe(''); + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: 'Bob', + privacy: strict, + maskAttributeFn: () => '', + }), + ).toBe(''); + }); + + it('fails closed when maskAttributeFn returns a non-string', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'data-x', + value: 'Bob', + privacy: legacy, + maskAttributeFn: () => undefined as unknown as string, + }), + ).toBe('***'); + expect( + finalizeAttribute({ + element: el(), + name: 'data-x', + value: 'a longer value', + privacy: undefined, + maskAttributeFn: () => ({ nope: true } as unknown as string), + }), + ).toBe('*'.repeat('a longer value'.length)); + }); + + it('applies the strict media-source and URL rules to the renamed rr_src', () => { + const iframe = () => el('', 'iframe'); + expect( + finalizeAttribute({ + element: iframe(), + name: 'rr_src', + value: 'https://u:p@x.com/?token=t', + privacy: strict, + }), + ).toBeNull(); + expect( + finalizeAttribute({ + element: iframe(), + name: 'rr_src', + value: 'https://u:p@x.com/?token=t', + privacy: balanced, + }), + ).toBe('https://x.com/?token=*'); + }); + + it('passes through null/empty values and untouched attributes', () => { + expect( + finalizeAttribute({ + element: el(), + name: 'title', + value: null, + privacy: strict, + }), + ).toBeNull(); + expect( + finalizeAttribute({ + element: el(), + name: 'data-x', + value: 'plain', + privacy: strict, + }), + ).toBe('plain'); + }); +}); + +describe('attribute finalization through the serializer', () => { + /** + * A cross-origin ``; + // jsdom hands out a blank contentDocument for every iframe; a real + // cross-origin frame has none, which is what triggers the rr_src rename. + Object.defineProperty( + document.querySelector('iframe')!, + 'contentDocument', + { + value: null, + }, + ); + return JSON.stringify(snapshot(document, { privacyPolicy })); + } + + it('sanitizes the renamed rr_src of a cross-origin iframe under balanced', () => { + const out = serializeOpaqueIframe('https://u:p@x.com/?token=t', { + version: 1, + preset: 'balanced', + }); + expect(out).toContain('"rr_src":"https://x.com/?token=*"'); + expect(out).not.toContain('u:p@x.com'); + expect(out).not.toContain('token=t'); + }); + + it('drops the renamed rr_src of a cross-origin iframe under strict', () => { + const out = serializeOpaqueIframe('https://u:p@x.com/?token=t', { + version: 1, + preset: 'strict', + }); + expect(out).toContain('"rr_src":null'); + expect(out).not.toContain('x.com'); + }); +}); diff --git a/packages/rrweb-snapshot/test/privacy-perf.test.ts b/packages/rrweb-snapshot/test/privacy-perf.test.ts new file mode 100644 index 0000000000..5e9d90f1fd --- /dev/null +++ b/packages/rrweb-snapshot/test/privacy-perf.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import snapshot from '../src/snapshot'; +import { compilePrivacyPolicy } from '../src/privacy'; + +/** + * Guards the "legacy is sacred" performance contract: a recorder that never + * opted into the v2 privacy policy must pay zero cost for it. If any of + * these selectors show up in an `Element.prototype.matches` call during a + * legacy snapshot, `compilePrivacyPolicy`/`mergeBlockSelectors`/ + * `needMaskingText` have started doing privacy-selector work even when the + * caller never asked for it. + */ +const PRIVACY_SELECTOR_FRAGMENTS = [ + 'data-privacy', + 'rr-mask', + 'rr-unmask', + 'rr-block', + 'mp-mask', + 'mp-block', + 'fs-mask', + 'fs-exclude', + 'amp-mask', + 'amp-unmask', + 'amp-block', + 'ph-mask', + 'ph-no-capture', + 'sentry-mask', + 'sentry-unmask', + 'sentry-block', + 'data-sentry-mask', + 'data-sentry-unmask', +]; + +function buildDeepDom() { + document.body.innerHTML = + '
    '.repeat(200) + 'deep text' + '
    '.repeat(200); +} + +function privacyAttributableCalls(spy: ReturnType) { + return spy.mock.calls.filter( + ([sel]) => + typeof sel === 'string' && + PRIVACY_SELECTOR_FRAGMENTS.some((fragment) => sel.includes(fragment)), + ); +} + +describe('privacy v2 perf smoke', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('legacy snapshot performs no privacy selector matching', () => { + // Sanity-check the compiled shape a caller gets when it never opts into + // v2 privacy: every selector is null/empty, so nothing should ever + // reach `.matches()` with a privacy-attributable selector. + const compiled = compilePrivacyPolicy(undefined); + expect(compiled.preset).toBe('legacy'); + expect(compiled.maskTextSelector).toBeNull(); + expect(compiled.blockSelector).toBeNull(); + + const spy = vi.spyOn(Element.prototype, 'matches'); + buildDeepDom(); + + // No `privacyPolicy` passed in the options object at all -- this is the + // shape every pre-v2 caller uses. + snapshot(document, {}); + + const privacyCalls = privacyAttributableCalls(spy); + expect(privacyCalls).toEqual([]); + spy.mockRestore(); + }); + + it('snapshot with no privacy argument at all behaves identically (legacy sacred)', () => { + const spy = vi.spyOn(Element.prototype, 'matches'); + buildDeepDom(); + + snapshot(document); + + const privacyCalls = privacyAttributableCalls(spy); + expect(privacyCalls).toEqual([]); + spy.mockRestore(); + }); +}); + +/** + * Every non-legacy preset compiles a non-null `unmaskTextSelector` (strict + * and balanced both always include `[data-privacy="allow"]` and the + * cross-vendor unmask classes, rule-configured or not). That forces + * `serializeNodeWithId`'s `checkAncestors` branch permanently on, which + * re-walks ancestors with `Element.prototype.matches` for every single node + * instead of trusting the inherited masking decision -- O(nodes * depth) on + * a page that has no unmask target anywhere. `resolveUnmaskTextSelector` + * probes once per snapshot/flush and passes `null` downward when nothing + * matches, restoring the cheap short-circuit. + */ +describe('privacy v2 unmask-selector short-circuit perf', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + function buildDeepWideDom(depth: number, leaves: number) { + document.body.innerHTML = + '
    '.repeat(depth) + + '

    leaf text

    '.repeat(leaves) + + '
    '.repeat(depth); + } + + it('strict snapshot with no unmask target anywhere avoids per-node ancestor walks', () => { + buildDeepWideDom(40, 40); + const spy = vi.spyOn(Element.prototype, 'matches'); + + snapshot(document, { privacyPolicy: { version: 1, preset: 'strict' } }); + + // Without the short-circuit this scales with leaves * depth (thousands + // of calls for 40x40); with it, the one-time presence probe means the + // per-node walk never engages, so the call count stays close to a + // single linear pass over the tree. A loose bound catches a regression + // to the old O(nodes * depth) behavior without pinning an exact count. + expect(spy.mock.calls.length).toBeLessThan(200); + spy.mockRestore(); + }); + + it('strict snapshot still finds and honors an .rr-unmask target when one exists', () => { + document.body.innerHTML = + '

    visible

    hidden

    '; + + const out = JSON.stringify( + snapshot(document, { privacyPolicy: { version: 1, preset: 'strict' } }), + ); + + expect(out).toContain('visible'); + expect(out).not.toContain('hidden'); + }); +}); diff --git a/packages/rrweb-snapshot/test/privacy.test.ts b/packages/rrweb-snapshot/test/privacy.test.ts index 3498873c92..18421a6efd 100644 --- a/packages/rrweb-snapshot/test/privacy.test.ts +++ b/packages/rrweb-snapshot/test/privacy.test.ts @@ -1,709 +1,249 @@ /** * @vitest-environment jsdom */ -import { beforeEach, describe, expect, it } from 'vitest'; -import snapshot from '../src/snapshot'; +import { describe, it, expect, vi } from 'vitest'; import { - applyPrivacyDetectors, compilePrivacyPolicy, - detectSensitiveText, - getPrivacyAction, - maskInputWithPrivacy, - maskTextWithPrivacy, - passesLuhn, + validateSelector, + mergeBlockSelectors, + detectSensitiveValue, + buildDetectors, sanitizeUrl, } from '../src/privacy'; -const balanced = () => - compilePrivacyPolicy( - applyPrivacyDetectors({ version: 1, preset: 'balanced' }), - )!; - -describe('privacy policy', () => { - beforeEach(() => { - document.documentElement.innerHTML = ''; - }); - - it('detects cards with Luhn instead of masking every long number', () => { - const privacy = balanced(); - const value = 'valid 4111 1111 1111 1111 invalid 4111 1111 1111 1112'; - const matches = detectSensitiveText(value, privacy); - - expect(passesLuhn('4111 1111 1111 1111')).toBe(true); - expect(passesLuhn('4111 1111 1111 1112')).toBe(false); - expect( - matches.filter((match) => match.detector === 'payment-card'), - ).toEqual([ - expect.objectContaining({ - start: value.indexOf('4111 1111 1111 1111'), - end: value.indexOf('4111 1111 1111 1111') + 19, - }), - ]); - }); - - it('masks only detected ranges in balanced text', () => { - const element = document.createElement('p'); - expect( - maskTextWithPrivacy( - 'Contact person@example.com about order 12345', - element, - balanced(), - false, - ), - ).toBe('Contact xxxxxx@xxxxxxx.xxx about order 12345'); - }); - - it('does not enable heuristic detectors from the balanced preset alone', () => { - const element = document.createElement('p'); - const privacy = compilePrivacyPolicy({ +describe('compilePrivacyPolicy v2', () => { + it('legacy preset compiles to inert options', () => { + const c = compilePrivacyPolicy(undefined); + expect(c.preset).toBe('legacy'); + expect(c.maskTextSelector).toBeNull(); + expect(c.blockSelector).toBeNull(); + expect(c.maskAllInputs).toBe(false); + expect(c.maskedAttributes).toEqual([]); + expect(c.sanitizeUrls).toBe(false); + expect(c.detectors).toEqual([]); + }); + it('balanced masks inputs, attributes and URLs but not text', () => { + const c = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); + expect(c.maskAllInputs).toBe(true); + expect(c.maskedAttributes).toEqual(['title', 'placeholder', 'aria-label']); + expect(c.sanitizeUrls).toBe(true); + expect(c.maskTextSelector).not.toContain('*'); + expect(c.maskTextSelector).toContain('[data-privacy="mask"]'); + expect(c.maskTextSelector).toContain('.ph-mask'); // cross-vendor classes + expect(c.blockSelector).toContain('[data-privacy="exclude"]'); + }); + it('strict masks all text and blocks media', () => { + const c = compilePrivacyPolicy({ version: 1, preset: 'strict' }); + expect(c.maskTextSelector).toBe('*'); + expect(c.blockMedia).toBe(true); + }); + it('compiles rules into selector lists, unmask as alias of allow', () => { + const c = compilePrivacyPolicy({ version: 1, preset: 'balanced', + rules: [ + { target: { type: 'selector', selector: '.pii' }, action: 'mask' }, + { target: { type: 'selector', selector: '.safe' }, action: 'unmask' }, + { target: { type: 'selector', selector: '.gone' }, action: 'exclude' }, + ], }); - expect( - maskTextWithPrivacy( - 'Contact person@example.com about order 12345', - element, - privacy, - false, - ), - ).toBe('Contact person@example.com about order 12345'); - expect(privacy.detectors).toEqual([]); + expect(c.maskTextSelector).toContain('.pii'); + expect(c.unmaskTextSelector).toContain('.safe'); + expect(c.blockSelector).toContain('.gone'); }); - - it('lets applyPrivacyDetectors opt into heuristic matching', () => { - expect( - applyPrivacyDetectors( + it('drops invalid selectors individually with a warning, keeps the rest', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const c = compilePrivacyPolicy({ + version: 1, + preset: 'balanced', + rules: [ { - version: 1, - preset: 'balanced', - detectors: { email: false }, + target: { type: 'selector', selector: ':::garbage' }, + action: 'exclude', }, - { email: true }, - ).detectors, - ).toMatchObject({ - email: false, - phone: true, - paymentCard: true, - }); - }); - - it('runs configured detectors in custom policies', () => { - const element = document.createElement('p'); - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [{ name: 'account-id', pattern: 'acct_[0-9]+' }], - }, + { target: { type: 'selector', selector: '.valid' }, action: 'exclude' }, + ], }); - - expect( - maskTextWithPrivacy( - 'Account acct_12345 is active', - element, - privacy, - false, - ), - ).toBe('Account xxxx_00000 is active'); + expect(c.blockSelector).toContain('.valid'); + expect(c.blockSelector).not.toContain(':::garbage'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining(':::garbage')); + warn.mockRestore(); }); - - it('rejects unsafe or unbounded custom detector configurations', () => { - const policyWith = (pattern: string, flags?: string) => () => - compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [{ name: 'unsafe', pattern, flags }], - }, - }); - - expect(policyWith('(a+)+$')).toThrow('ambiguous nested repetition'); - expect(policyWith('(a|aa)+$')).toThrow('ambiguous nested repetition'); - expect(policyWith('(a+){1,20}')).toThrow('ambiguous nested repetition'); - expect(policyWith('(a{1,10})+')).toThrow('ambiguous nested repetition'); - expect(policyWith('(?:a+)+')).toThrow('ambiguous nested repetition'); - expect(policyWith('(a)\\1')).toThrow('backreferences'); - expect(policyWith('(?a)\\k')).toThrow('backreferences'); - expect(policyWith('(?=secret)')).toThrow('lookaround'); - expect(policyWith('(?!secret)')).toThrow('lookaround'); - expect(policyWith('a*')).toThrow('cannot match empty text'); - expect(policyWith('account', 'y')).toThrow('unsupported regex flags'); - expect(policyWith('a'.repeat(257))).toThrow('must be 1-256 characters'); - expect(policyWith('a?'.repeat(13))).toThrow('too many quantifiers'); + it('throws on bad version/preset/empty selector', () => { expect(() => - compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [ - { - name: 'too-wide', - pattern: 'account_[0-9]+', - maximumMatchLength: 1_025, - }, - ], - }, - }), - ).toThrow('maximumMatchLength'); + compilePrivacyPolicy({ version: 2 as never, preset: 'legacy' }), + ).toThrow(); expect(() => - compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [ - { - name: 'inverted', - pattern: 'account_[0-9]+', - minimumLength: 32, - maximumMatchLength: 8, - }, - ], - }, - }), - ).toThrow('minimumLength cannot exceed maximumMatchLength'); - + compilePrivacyPolicy({ version: 1, preset: 'custom' as never }), + ).toThrow(); expect(() => compilePrivacyPolicy({ version: 1, - preset: 'custom', - detectors: { - custom: [ - { name: 'account-id', pattern: 'acct_[0-9]+' }, - { name: 'optional-colour', pattern: 'colou?r' }, - { name: 'grouped', pattern: '(?:acct_)[0-9]{4,12}' }, - { name: 'repeated-atom', pattern: '(foo)+' }, - ], - }, + preset: 'balanced', + rules: [{ target: { type: 'selector', selector: '' }, action: 'mask' }], }), - ).not.toThrow(); + ).toThrow(); }); - - it('uses detector length fast paths and finds matches across scan chunks', () => { - const custom = compilePrivacyPolicy({ + it('precomputes lowercased query parameter sets', () => { + const c = compilePrivacyPolicy({ version: 1, - preset: 'custom', - detectors: { - custom: [ - { - name: 'account-id', - pattern: 'acct_[0-9]+', - minimumLength: 12, - maximumMatchLength: 32, - }, - ], - }, + preset: 'strict', + url: { blockedQueryParameters: ['SessionID'] }, }); - expect(detectSensitiveText('acct_1', custom)).toEqual([]); - const customBoundaryValue = `${'x'.repeat(508)}acct_12345`; - expect(detectSensitiveText(customBoundaryValue, custom)).toEqual([ - expect.objectContaining({ start: 508, end: customBoundaryValue.length }), - ]); - - const value = `${'x'.repeat(8_187)}4111 1111 1111 1111`; - expect( - detectSensitiveText(value, balanced()).some( - (match) => match.detector === 'payment-card' && match.start === 8_187, - ), - ).toBe(true); + expect(c.blockedQueryParameters.has('sessionid')).toBe(true); + expect(c.blockedQueryParameters.has('token')).toBe(true); // default list }); - - it('fails closed when a value produces too many detector matches', () => { - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [ - { - name: 'digit', - pattern: '[0-9]', - maximumMatchLength: 1, - }, - ], - }, - }); - const value = '1'.repeat(1_500); - expect(detectSensitiveText(value, privacy)).toEqual([ - expect.objectContaining({ start: 0, end: value.length }), - ]); - - const oversize = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - detectors: { - custom: [ - { - name: 'bounded-digits', - pattern: '[0-9]+', - maximumMatchLength: 4, - }, - ], - }, - }); - const oversizeValue = 'public 12345 trailing text'; - expect(detectSensitiveText(oversizeValue, oversize)).toEqual([ - expect.objectContaining({ start: 0, end: oversizeValue.length }), - ]); +}); +describe('validateSelector', () => { + it('accepts valid, rejects invalid', () => { + expect(validateSelector('.a > [data-x="1"]')).toBe(true); + expect(validateSelector(':::nope')).toBe(false); }); +}); +describe('mergeBlockSelectors', () => { + it('joins legacy selector with compiled blockSelector', () => { + const c = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); + expect(mergeBlockSelectors('.legacy', c)).toContain('.legacy'); + expect(mergeBlockSelectors('.legacy', c)).toContain( + '[data-privacy="exclude"]', + ); + }); +}); - it('masks style text under policy while keeping script placeholders', () => { - const style = document.createElement('style'); - const script = document.createElement('script'); - expect( - maskTextWithPrivacy( - '.person@example.com { color: red }', - style, - balanced(), - false, - ), - ).toBe('.xxxxxx@xxxxxxx.xxx { color: red }'); - expect( - maskTextWithPrivacy( - '.person@example.com { color: red }', - style, - compilePrivacyPolicy({ version: 1, preset: 'strict' }), - false, - ), - ).toBe('.xxxxxx@xxxxxxx.xxx { xxxxx: xxx }'); - expect( - maskTextWithPrivacy( - 'window.secret = "person@example.com"', - script, - balanced(), - false, - ), - ).toBe('SCRIPT_PLACEHOLDER'); +describe('detectSensitiveValue', () => { + const withDetectors = compilePrivacyPolicy({ + version: 1, + preset: 'legacy', + detectors: { + email: true, + phone: true, + paymentCard: true, + ssn: true, + ipAddress: true, + }, }); - it('detects emails with more than four domain labels', () => { - const value = 'Contact first.last@sub.mail.company.co.uk today'; + it('detects a Luhn-valid card adjacent to other digits (review regression)', () => { expect( - detectSensitiveText(value, balanced()).some( - (match) => - match.detector === 'email' && - value.slice(match.start, match.end) === - 'first.last@sub.mail.company.co.uk', + detectSensitiveValue( + 'call 5551234567 4111 1111 1111 1111 now', + withDetectors, ), ).toBe(true); }); - it('uses nearest explicit rules and safer action for ties', () => { - document.body.innerHTML = ` -
    -
    secret
    -
    `; - const target = document.querySelector('#target')!; - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - rules: [ - { - target: { type: 'selector', selector: '.allow' }, - action: 'allow', - }, - { - target: { type: 'selector', selector: '.mask' }, - action: 'mask', - }, - ], - }); + it('detects email, ssn, ip; passes clean prose', () => { + expect(detectSensitiveValue('contact bob@example.com', withDetectors)).toBe( + true, + ); + expect(detectSensitiveValue('ssn 123-45-6789', withDetectors)).toBe(true); + expect(detectSensitiveValue('host 192.168.0.1', withDetectors)).toBe(true); + expect(detectSensitiveValue('the quick brown fox', withDetectors)).toBe( + false, + ); + }); - expect(getPrivacyAction(target, privacy)).toBe('allow'); + it('rejects UUIDs and version strings as cards/ssns (false-positive guard)', () => { expect( - getPrivacyAction( - document.querySelector('main'), - compilePrivacyPolicy({ - version: 1, - preset: 'custom', - rules: [ - { - target: { type: 'selector', selector: '.allow' }, - action: 'allow', - }, - { - target: { type: 'selector', selector: '.mask' }, - action: 'mask', - }, - ], - }), + detectSensitiveValue( + 'id 550e8400-e29b-41d4-a716-446655440000', + withDetectors, ), - ).toBe('mask'); - }); - - it('recognizes data-privacy without recorder configuration', () => { - document.body.innerHTML = ` -
    -

    Private customer Public label

    - - -
    -
    Excluded account 12345
    `; - - const payload = JSON.stringify(snapshot(document)); - - expect(payload).not.toContain('Private title'); - expect(payload).not.toContain('Private customer'); - expect(payload).not.toContain('Private input'); - expect(payload).not.toContain('secret-password'); - expect(payload).not.toContain('Excluded account 12345'); - expect(payload).toContain('Public label'); + ).toBe(false); + expect(detectSensitiveValue('v1.2.3.4000 build', withDetectors)).toBe( + false, + ); }); - it('inherits past invalid data-privacy values and resolves ties safely', () => { - document.body.innerHTML = ` -
    - Private - Private -
    `; - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - rules: [ - { - target: { type: 'selector', selector: '.policy-mask' }, - action: 'mask', - }, - ], - }); - - expect(getPrivacyAction(document.querySelector('#invalid'), privacy)).toBe( - 'mask', - ); - expect(getPrivacyAction(document.querySelector('#tie'), privacy)).toBe( - 'mask', + it('detects regardless of preset (works under legacy)', () => { + expect(withDetectors.preset).toBe('legacy'); + expect(detectSensitiveValue('4111 1111 1111 1111', withDetectors)).toBe( + true, ); }); - it('maps exclude policy rules to rrweb blocking', () => { - document.body.innerHTML = - '
    Excluded by policy
    '; - const privacyPolicy = { - version: 1 as const, - preset: 'custom' as const, - rules: [ - { - target: { type: 'selector' as const, selector: '.private' }, - action: 'exclude' as const, - }, - ], - }; - const privacy = compilePrivacyPolicy(privacyPolicy); - const payload = JSON.stringify(snapshot(document, { privacyPolicy })); - - expect(privacy.blockSelector).toContain('.private'); - expect(payload).not.toContain('Excluded by policy'); + it('no detectors configured -> never detects', () => { + const none = compilePrivacyPolicy({ version: 1, preset: 'strict' }); + expect(detectSensitiveValue('bob@example.com', none)).toBe(false); }); - it('preserves structural attributes unless they are explicitly targeted', () => { - document.body.innerHTML = ` - `; - const payload = JSON.stringify( - snapshot(document, { - privacyPolicy: { - version: 1, - preset: 'custom', - rules: [ - { - target: { type: 'selector', selector: '.private' }, - action: 'mask', - }, - { - target: { - type: 'selector', - selector: '.private', - attributes: ['data-secret'], - }, - action: 'mask', - }, - ], - }, - }), - ); - - expect(payload).toContain('private layout'); - expect(payload).toContain('"type":"text"'); - expect(payload).not.toContain('account-123'); - expect(payload).not.toContain('Private value'); + it('fails closed on absurdly long input instead of scanning it', () => { + const clean = 'a'.repeat(10_001); + expect(detectSensitiveValue(clean, withDetectors)).toBe(true); }); - it('does not allow protected inputs to be unmasked', () => { - const input = document.createElement('input'); - input.type = 'password'; - input.className = 'record'; - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'custom', - rules: [ - { - target: { type: 'selector', selector: '.record' }, - action: 'allow', - }, - ], + it('per-detector toggles work', () => { + const emailOff = buildDetectors({ + email: false, + phone: false, + paymentCard: true, + ssn: false, + ipAddress: false, }); - - expect( - maskInputWithPrivacy('secret', input, privacy, false, () => 'secret'), - ).toBe('xxxxxx'); - - input.type = 'text'; - input.setAttribute('data-rr-is-password', 'true'); - expect( - maskInputWithPrivacy('visible', input, privacy, false, () => 'visible'), - ).toBe('xxxxxxx'); - - input.type = 'password'; - input.removeAttribute('data-rr-is-password'); - input.setAttribute('data-privacy', 'allow'); - expect( - maskInputWithPrivacy( - 'protected', - input, - compilePrivacyPolicy(undefined), - false, - () => 'protected', - ), - ).toBe('xxxxxxxxx'); + expect(emailOff.some((d) => d.name === 'email')).toBe(false); + expect(emailOff.some((d) => d.name === 'payment-card')).toBe(true); }); - it('removes sensitive URL values while retaining routing context', () => { - expect( - sanitizeUrl( - 'https://example.com/account?tab=billing&token=secret#profile', - balanced(), - ), - ).toBe('https://example.com/account?tab=billing&token=*'); - - expect( - sanitizeUrl( - 'https://example.com/account?tab=billing', - compilePrivacyPolicy({ version: 1, preset: 'strict' }), - ), - ).toBe('https://example.com/account?tab=*'); + it('detects spaced phone format (fix regression)', () => { + expect(detectSensitiveValue('call 555 123 4567 now', withDetectors)).toBe( + true, + ); }); - it('inherits rules across a shadow-root boundary', () => { - const host = document.createElement('div'); - host.className = 'private'; - const shadow = host.attachShadow({ mode: 'open' }); - const child = document.createElement('span'); - shadow.appendChild(child); - document.body.appendChild(host); - - expect( - getPrivacyAction( - child, - compilePrivacyPolicy({ - version: 1, - preset: 'custom', - rules: [ - { - target: { type: 'selector', selector: '.private' }, - action: 'mask', - }, - ], - }), - ), - ).toBe('mask'); + it('detects dashed phone format (fix regression)', () => { + expect(detectSensitiveValue('555-123-4567', withDetectors)).toBe(true); }); - it('masks CSS text, inline style, and stylesheet snapshots', () => { - document.body.innerHTML = ` - -
    `; - - const balancedPayload = JSON.stringify( - snapshot(document, { - privacyPolicy: applyPrivacyDetectors({ - version: 1, - preset: 'balanced', - }), - }), - ); - expect(balancedPayload).not.toContain('person@example.com'); - expect(balancedPayload).toContain('xxxxxx@xxxxxxx.xxx'); - - const strictPayload = JSON.stringify( - snapshot(document, { - privacyPolicy: { version: 1, preset: 'strict' }, - }), - ); - expect(strictPayload).not.toContain('person@example.com'); + it('detects parenthesized area code format (fix regression)', () => { + expect(detectSensitiveValue('(555) 123-4567', withDetectors)).toBe(true); }); - it('applies policy before a snapshot is serialized', () => { - document.body.innerHTML = ` -

    Contact person@example.com

    - - - Account`; - - const serialized = snapshot(document, { - privacyPolicy: applyPrivacyDetectors({ - version: 1, - preset: 'balanced', - rules: [ - { - target: { type: 'selector', selector: '.record' }, - action: 'allow', - }, - ], - }), - }); - const payload = JSON.stringify(serialized); - - expect(payload).not.toContain('person@example.com'); - expect(payload).not.toContain('private input'); - expect(payload).not.toContain('secret-password'); - expect(payload).not.toContain('token=secret'); - expect(payload).toContain('Contact xxxxxx@xxxxxxx.xxx'); - expect(payload).toContain('token=*'); + it('detects parenthesized area code with country code (fix regression)', () => { + expect(detectSensitiveValue('+1 (555) 123-4567', withDetectors)).toBe(true); }); +}); - it('masks every form value attribute in strict mode', () => { - document.body.innerHTML = ` - - `; - const payload = JSON.stringify( - snapshot(document, { - privacyPolicy: { version: 1, preset: 'strict' }, - }), - ); - - expect(payload).not.toContain('private-radio-value'); - expect(payload).not.toContain('private-option-value'); - expect(payload).not.toContain('Private option'); +describe('sanitizeUrl v2', () => { + const strict = compilePrivacyPolicy({ version: 1, preset: 'strict' }); + const balanced = compilePrivacyPolicy({ version: 1, preset: 'balanced' }); + const legacy = compilePrivacyPolicy(undefined); + it('strips userinfo credentials', () => { + expect( + sanitizeUrl('https://alice:hunter2@api.example.com/x', balanced), + ).toBe('https://api.example.com/x'); }); - - it('supports coarse masking of final source attributes', () => { - document.body.innerHTML = ` -
    - `; - const payload = JSON.stringify( - snapshot(document, { maskAllElementAttributes: true }), + it('masks blocked query parameters, case-insensitively', () => { + expect(sanitizeUrl('https://a.com/?Token=abc&ok=1', balanced)).toBe( + 'https://a.com/?Token=*&ok=1', ); - - expect(payload).not.toContain('customer-name'); - expect(payload).not.toContain('person@example.com'); - expect(payload).not.toContain('private synthesized value'); }); - - it('lets a callback mask final attributes but never override policy', () => { - document.body.innerHTML = ` -
    `; - const payload = JSON.stringify( - snapshot(document, { - privacyPolicy: applyPrivacyDetectors({ - version: 1, - preset: 'balanced', - }), - maskAttributeFn: (name, value) => - name === 'data-owner' ? '[OWNER]' : value, - }), + it('strict masks all params unless allowlisted', () => { + const allow = compilePrivacyPolicy({ + version: 1, + preset: 'strict', + url: { allowedQueryParameters: ['page'] }, + }); + expect(sanitizeUrl('https://a.com/?page=2&q=x', strict)).toBe( + 'https://a.com/?page=*&q=*', ); - - expect(payload).toContain('[OWNER]'); - expect(payload).not.toContain('person@example.com'); - expect(payload).toContain('xxxxxx@xxxxxxx.xxx'); - }); - - it('fails closed when an attribute callback throws', () => { - document.body.innerHTML = '
    '; - const payload = JSON.stringify( - snapshot(document, { - maskAttributeFn: () => { - throw new Error('boom'); - }, - }), + expect(sanitizeUrl('https://a.com/?page=2&q=x', allow)).toBe( + 'https://a.com/?page=2&q=*', ); - - expect(payload).not.toContain('private-title'); }); - - it('suppresses full-snapshot canvas pixels while region masking is configured', () => { - const canvas = document.createElement('canvas'); - (canvas as HTMLCanvasElement & { __context?: string }).__context = '2d'; - canvas.getContext = (() => ({ - getImageData: () => ({ data: new Uint8ClampedArray([255, 0, 0, 255]) }), - })) as unknown as typeof canvas.getContext; - canvas.toDataURL = () => 'data:image/webp;base64,unmasked-pixels'; - document.body.appendChild(canvas); - - const unprotected = JSON.stringify( - snapshot(document, { - recordCanvas: true, - canvasMaskingConfigured: () => false, - }), + it('removes hash unless disabled; legacy passes through untouched', () => { + expect(sanitizeUrl('https://a.com/x#frag', balanced)).toBe( + 'https://a.com/x', ); - const protectedSnapshot = JSON.stringify( - snapshot(document, { - recordCanvas: true, - canvasMaskingConfigured: () => true, - }), + expect(sanitizeUrl('https://alice:pw@a.com/?token=x#f', legacy)).toBe( + 'https://alice:pw@a.com/?token=x#f', ); - - expect(unprotected).toContain('rr_dataURL'); - expect(protectedSnapshot).not.toContain('rr_dataURL'); }); - - it('does not throw when a form control shadows HTMLFormElement.tagName', () => { - const form = document.createElement('form'); - const input = document.createElement('input'); - input.setAttribute('name', 'tagName'); - const text = document.createTextNode('visible email person@example.com'); - form.appendChild(input); - form.appendChild(text); - document.body.appendChild(form); - - expect(() => - maskTextWithPrivacy( - 'visible email person@example.com', - form, - balanced(), - false, - ), - ).not.toThrow(); - expect(() => snapshot(document)).not.toThrow(); - }); - - it('walks ancestors when getRootNode has been monkey-patched', () => { - const originalGetRootNode = Node.prototype.getRootNode; - Node.prototype.getRootNode = function () { - throw new Error('getRootNode was hijacked by framework'); - }; - try { - document.body.innerHTML = - '
    secret
    '; - const target = document.querySelector('#target')!; - const privacy = compilePrivacyPolicy({ - version: 1, - preset: 'balanced', - rules: [ - { - target: { type: 'selector', selector: '[data-privacy="mask"]' }, - action: 'mask', - }, - ], - }); - expect(() => getPrivacyAction(target, privacy)).not.toThrow(); - expect(getPrivacyAction(target, privacy)).toBe('mask'); - expect(() => snapshot(document)).not.toThrow(); - } finally { - Node.prototype.getRootNode = originalGetRootNode; - } + it('unparseable value under non-legacy fails closed to empty string', () => { + expect(sanitizeUrl('http://[broken', balanced)).toBe(''); }); - - it('keeps the legacy path unchanged when no policy is supplied', () => { - document.body.innerHTML = - '

    Visible text

    '; - const payload = JSON.stringify(snapshot(document)); - expect(payload).toContain('Visible text'); - expect(payload).toContain('legacy-hidden-value'); - expect( - maskInputWithPrivacy( - 'legacy value', - document.createElement('input'), - undefined, - true, - ), - ).toBe('************'); + it('empty in, empty out -- never resolved into a path', () => { + expect(sanitizeUrl('', balanced)).toBe(''); + expect(sanitizeUrl('', strict)).toBe(''); + expect(sanitizeUrl('', legacy)).toBe(''); }); }); diff --git a/packages/rrweb-snapshot/test/utils.test.ts b/packages/rrweb-snapshot/test/utils.test.ts index f4b68245cc..9894e67dca 100644 --- a/packages/rrweb-snapshot/test/utils.test.ts +++ b/packages/rrweb-snapshot/test/utils.test.ts @@ -11,6 +11,11 @@ import { } from '../src/utils'; import { NodeType } from '@rrweb/types'; import type { serializedNode, serializedNodeWithId } from '@rrweb/types'; +// `@rrweb/utils` has no test infrastructure of its own (no vitest config / +// test dir), so `untaintedTagName` -- shared, package-level shadowing +// hardening added for Task 9 -- is covered here instead, importing it the +// same way rrweb-snapshot's own source does. +import { untaintedTagName } from '@rrweb/utils'; describe('utils', () => { describe('isNodeMetaEqual()', () => { @@ -326,4 +331,35 @@ describe('utils', () => { ); }); }); + + describe('untaintedTagName()', () => { + it('returns the real tag name even when a named form control shadows `tagName`', () => { + // Real browsers make named form controls reachable as own properties + // on their
    (so `` makes + // `form.tagName` resolve to the , not the string `'FORM'`). + // jsdom doesn't implement that quirk, so we reproduce the same shape + // of shadowing directly: an own `tagName` property that hides the + // inherited `Element.prototype` getter. + document.body.innerHTML = '
    '; + const form = document.querySelector('form')!; + const input = document.querySelector('input')!; + Object.defineProperty(form, 'tagName', { + value: input, + configurable: true, + }); + // sanity check: the shadowing actually took effect + expect(typeof form.tagName).not.toBe('string'); + expect(untaintedTagName(form)).toBe('FORM'); + }); + + it('returns an uppercased tag name for an ordinary element', () => { + const div = document.createElement('div'); + expect(untaintedTagName(div)).toBe('DIV'); + }); + + it('returns an empty string for null/undefined', () => { + expect(untaintedTagName(null)).toBe(''); + expect(untaintedTagName(undefined)).toBe(''); + }); + }); }); diff --git a/packages/rrweb/src/index.ts b/packages/rrweb/src/index.ts index 3ae4ae05aa..0ce40746c8 100644 --- a/packages/rrweb/src/index.ts +++ b/packages/rrweb/src/index.ts @@ -26,13 +26,11 @@ export type { recordOptions, ReplayPlugin } from './types'; export type { PrivacyAction, PrivacyDetectorOptions, - PrivacyMaskStyle, PrivacyPolicy, PrivacyPreset, PrivacyRule, PrivacyTarget, PrivacyUrlOptions, - SensitiveDataKind, } from 'rrweb-snapshot'; const { addCustomEvent } = record; diff --git a/packages/rrweb/src/record/canvas-sampling.ts b/packages/rrweb/src/record/canvas-sampling.ts new file mode 100644 index 0000000000..b62b5de036 --- /dev/null +++ b/packages/rrweb/src/record/canvas-sampling.ts @@ -0,0 +1,23 @@ +/** + * Canvas privacy masking only redacts pixels on the FPS/OffscreenCanvas + * capture path (`sampling.canvas` as a number): that path renders full + * frames through `computeFrameMaskRegions` before they ever reach the + * encoding worker. The mutation-mode command stream (`sampling.canvas` as + * `'all'` or `undefined`) replays raw canvas API calls verbatim and has no + * way to redact anything. + * + * If canvas masking is configured but sampling stays in mutation mode, the + * masking is silently bypassed. To make that impossible, canvas masking + * being configured always forces numeric FPS sampling. + */ +export function resolveCanvasSampling( + requestedSampling: number | 'all' | undefined, + canvasMaskingConfigured: boolean, +): number | 'all' | undefined { + if (!canvasMaskingConfigured) return requestedSampling; + if (typeof requestedSampling === 'number') return requestedSampling; + console.warn( + '[rrweb] canvasMasking requires FPS canvas capture; forcing sampling.canvas = 4', + ); + return 4; +} diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index f62862244f..8e0f890386 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -4,7 +4,10 @@ import { type MaskInputOptions, createMirror, compilePrivacyPolicy, + type CompiledPrivacyPolicy, mergeBlockSelectors, + mergeMaskTextSelectors, + mergeUnmaskTextSelectors, sanitizeUrl, } from 'rrweb-snapshot'; import { initObservers, mutationBuffers } from './observer'; @@ -36,6 +39,9 @@ import { IframeManager } from './iframe-manager'; import { ShadowDomManager } from './shadow-dom-manager'; import { CanvasManager } from './observers/canvas/canvas-manager'; import { isCanvasMaskingConfigured } from './observers/canvas/canvas-mask'; +import { resolveCanvasSampling } from './canvas-sampling'; + +export { resolveCanvasSampling } from './canvas-sampling'; import { StylesheetManager } from './stylesheet-manager'; import ProcessedNodeManager from './processed-node-manager'; import { @@ -78,7 +84,8 @@ function record( ignoreClass = 'rr-ignore', ignoreSelector = null, maskTextClass = 'rr-mask', - maskTextSelector = null, + maskTextSelector: legacyMaskTextSelector = null, + unmaskTextSelector: legacyUnmaskTextSelector = null, inlineStylesheet = true, maskAllInputs, maskInputOptions: _maskInputOptions, @@ -116,16 +123,62 @@ function record( : policy, privacyPolicy, ); - const privacy = compilePrivacyPolicy(portablePrivacyPolicy); + // What actually gets used from here on -- both to compile `privacy` below + // and to hand to `snapshot()` later (which independently compiles its own + // `privacyPolicy` option). Defaults to the plugin-transformed policy, but + // falls back to the user's own policy if the transform produced something + // uncompilable, so both compile calls stay in agreement. + let effectivePrivacyPolicy = portablePrivacyPolicy; + let privacy: CompiledPrivacyPolicy; + try { + privacy = compilePrivacyPolicy(portablePrivacyPolicy); + } catch (error) { + // A plugin's `applyPrivacyPolicy` transform produced something + // `compilePrivacyPolicy` can't compile. That's the plugin's bug, not the + // user's -- fall back to the user's own (untransformed) policy so a + // broken plugin can't take recording down entirely. If the user's own + // policy is itself invalid, that's a programmer error and should still + // throw. + if (portablePrivacyPolicy !== privacyPolicy) { + console.error( + '[rrweb] plugin-transformed privacy policy failed to compile; using the user policy', + error, + ); + effectivePrivacyPolicy = privacyPolicy; + privacy = compilePrivacyPolicy(privacyPolicy); + } else { + throw error; + } + } const blockSelector = mergeBlockSelectors(legacyBlockSelector, privacy); + const maskTextSelector = mergeMaskTextSelectors( + legacyMaskTextSelector, + privacy, + ); + const unmaskTextSelector = mergeUnmaskTextSelectors( + legacyUnmaskTextSelector, + privacy, + ); // Strict remains fail-closed for the whole canvas. Region providers are - // available to balanced/custom/legacy policies, where the application owns + // available to balanced/legacy policies, where the application owns // the completeness of those regions. const recordCanvas = requestedRecordCanvas && privacy?.policy.preset !== 'strict'; const canvasMaskingConfigured = canvasMasking ? () => isCanvasMaskingConfigured(canvasMasking) : undefined; + // A canvas can only be masked on the FPS/OffscreenCanvas capture path, + // which renders full frames through the masking provider before they + // reach the encoding worker. The mutation-mode command stream + // (`sampling.canvas === 'all'`) replays raw canvas API calls verbatim and + // cannot be masked at all. Whenever canvasMasking is configured at all + // (structurally, regardless of any dynamic `isConfigured()` toggle - + // sampling mode can't be switched mid-session), force numeric FPS + // sampling so the unmasked command stream can never run alongside it. + sampling.canvas = resolveCanvasSampling( + sampling.canvas, + Boolean(canvasMasking), + ); registerErrorHandler(errorHandler); @@ -298,7 +351,6 @@ function record( const stylesheetManager = new StylesheetManager({ mutationCb: wrappedMutationEmit, adoptedStyleSheetCb: wrappedAdoptedStyleSheetEmit, - privacy, }); const iframeManager = new IframeManager({ @@ -344,6 +396,7 @@ function record( blockSelector, maskTextClass, maskTextSelector, + unmaskTextSelector, inlineStylesheet, maskInputOptions, dataURLOptions, @@ -394,6 +447,7 @@ function record( blockSelector, maskTextClass, maskTextSelector, + unmaskTextSelector, inlineStylesheet, maskAllInputs: maskInputOptions, maskTextFn, @@ -405,7 +459,7 @@ function record( recordCanvas, canvasMaskingConfigured, inlineImages, - privacyPolicy: portablePrivacyPolicy, + privacyPolicy: effectivePrivacyPolicy, onSerialize: (n) => { if (isSerializedIframe(n, mirror)) { iframeManager.addIframe(n as HTMLIFrameElement); @@ -549,6 +603,7 @@ function record( ignoreSelector, maskTextClass, maskTextSelector, + unmaskTextSelector, maskInputOptions, maskAllElementAttributes, maskAttributeFn, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 4aa072069c..c0b92dbeb5 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -5,12 +5,11 @@ import { ignoreAttribute, isShadowRoot, needMaskingText, - maskAttributeWithPrivacy, - protectSerializedAttribute, - getPrivacyAction, - maskInputWithPrivacy, - maskInputValue, - maskTextWithPrivacy, + maskInput, + detectSensitiveValue, + finalizeAttribute, + resolveUnmaskTextSelector, + FORM_VALUE_TAGS, Mirror, isNativeShadowDom, getInputType, @@ -39,6 +38,15 @@ import { } from '../utils'; import dom from '@rrweb/utils'; +/** + * `attributeCursor` plus the names this recorder generated itself (currently + * only `rr_open_mode`), so the finalization sweep can exempt them without a + * side-table keyed by node. + */ +type attributeCursorWithGenerated = attributeCursor & { + generatedAttributes?: Set; +}; + type DoubleLinkedListNode = { previous: DoubleLinkedListNode | null; next: DoubleLinkedListNode | null; @@ -147,9 +155,8 @@ export default class MutationBuffer { private locked = false; private texts: textCursor[] = []; - private attributes: attributeCursor[] = []; - private attributeMap = new WeakMap(); - private generatedAttributes = new WeakMap>(); + private attributes: attributeCursorWithGenerated[] = []; + private attributeMap = new WeakMap(); private removes: removedNodeMutation[] = []; private mapRemoves: Node[] = []; @@ -182,6 +189,14 @@ export default class MutationBuffer { private blockSelector: observerParam['blockSelector']; private maskTextClass: observerParam['maskTextClass']; private maskTextSelector: observerParam['maskTextSelector']; + private unmaskTextSelector: observerParam['unmaskTextSelector']; + /** + * `unmaskTextSelector` re-resolved once per mutation flush (see + * `resolveUnmaskTextSelector`): null when nothing in the live document + * currently matches it, so the per-node ancestor walk it otherwise forces + * in `needMaskingText`/`serializeNodeWithId` can short-circuit again. + */ + private effectiveUnmaskTextSelector: string | null = null; private inlineStylesheet: observerParam['inlineStylesheet']; private maskInputOptions: observerParam['maskInputOptions']; private maskTextFn: observerParam['maskTextFn']; @@ -212,6 +227,7 @@ export default class MutationBuffer { 'blockSelector', 'maskTextClass', 'maskTextSelector', + 'unmaskTextSelector', 'inlineStylesheet', 'maskInputOptions', 'maskTextFn', @@ -271,6 +287,14 @@ export default class MutationBuffer { } public processMutations = (mutations: mutationRecord[]) => { + // Re-probe once per flush rather than per node -- see + // `resolveUnmaskTextSelector`. Cheap when `unmaskTextSelector` is unset + // (returns null immediately); only walks the live tree when a preset + // actually configured one. + this.effectiveUnmaskTextSelector = resolveUnmaskTextSelector( + this.doc, + this.unmaskTextSelector, + ); mutations.forEach(this.processMutation); // adds mutations to the buffer this.emit(); // clears buffer if not locked/frozen }; @@ -307,7 +331,9 @@ export default class MutationBuffer { } let cssCaptured = false; if (n.nodeType === Node.TEXT_NODE) { - const parentTag = (parent as Element).tagName; + // `parent` can be an arbitrary element (e.g. a
    ), whose + // `tagName` may be shadowed by a same-named descendant control. + const parentTag = dom.untaintedTagName(parent as Element | null); if (parentTag === 'TEXTAREA') { // genTextAreaValueMutation already called via parent return; @@ -333,12 +359,15 @@ export default class MutationBuffer { blockSelector: this.blockSelector, maskTextClass: this.maskTextClass, maskTextSelector: this.maskTextSelector, + unmaskTextSelector: this.effectiveUnmaskTextSelector, skipChild: true, newlyAddedElement: true, inlineStylesheet: this.inlineStylesheet, maskInputOptions: this.maskInputOptions, maskTextFn: this.maskTextFn, maskInputFn: this.maskInputFn, + maskAllElementAttributes: this.maskAllElementAttributes, + maskAttributeFn: this.maskAttributeFn, privacy: this.privacy, slimDOMOptions: this.slimDOMOptions, dataURLOptions: this.dataURLOptions, @@ -469,7 +498,7 @@ export default class MutationBuffer { .map((text) => { const n = text.node; const parent = dom.parentNode(n); - if (parent && (parent as Element).tagName === 'TEXTAREA') { + if (dom.untaintedTagName(parent as Element | null) === 'TEXTAREA') { // the node is being ignored as it isn't in the mirror, so shift mutation to attributes on parent textarea this.genTextAreaValueMutation(parent as HTMLTextAreaElement); } @@ -485,19 +514,9 @@ export default class MutationBuffer { attributes: this.attributes .map((attribute) => { const { attributes } = attribute; - const styleAction = getPrivacyAction( - attribute.node as Element, - this.privacy, - 'style', - true, - ); - if ( - !this.maskAllElementAttributes && - !this.maskAttributeFn && - styleAction !== 'mask' && - styleAction !== 'exclude' && - typeof attributes.style === 'string' - ) { + // `style` is never masked by any privacy path, so the compact style + // mutation can always be used when it is shorter. + if (typeof attributes.style === 'string') { const diffAsStr = JSON.stringify(attribute.styleDiff); const unchangedAsStr = JSON.stringify(attribute._unchangedStyles); // check if the style diff is actually shorter than the regular string based mutation @@ -513,22 +532,20 @@ export default class MutationBuffer { } } } - if (this.maskAllElementAttributes || this.maskAttributeFn) { - for (const [name, value] of Object.entries(attributes)) { - if (typeof value === 'string' || value === null) { - attributes[name] = protectSerializedAttribute({ - element: attribute.node as Element, - name, - value, - privacy: this.privacy, - maskAllElementAttributes: this.maskAllElementAttributes, - maskAttributeFn: this.maskAttributeFn, - isGenerated: this.generatedAttributes - .get(attribute.node) - ?.has(name), - }); - } - } + // The single finalization sweep for the mutation path, mirroring + // serializeElementNode's: every attribute about to be emitted goes + // through `finalizeAttribute` exactly once. + for (const [name, value] of Object.entries(attributes)) { + if (typeof value !== 'string' && value !== null) continue; + attributes[name] = finalizeAttribute({ + element: attribute.node as Element, + name, + value, + privacy: this.privacy, + maskAllElementAttributes: this.maskAllElementAttributes, + maskAttributeFn: this.maskAttributeFn, + isGenerated: attribute.generatedAttributes?.has(name), + }); } return { id: this.mirror.getId(attribute.node), @@ -555,8 +572,7 @@ export default class MutationBuffer { // reset this.texts = []; this.attributes = []; - this.attributeMap = new WeakMap(); - this.generatedAttributes = new WeakMap>(); + this.attributeMap = new WeakMap(); this.removes = []; this.addedSet = new Set(); this.movedSet = new Set(); @@ -584,29 +600,21 @@ export default class MutationBuffer { (cn) => dom.textContent(cn) || '', ).join(''); const type = getInputType(textarea); - if (this.privacy) { - const legacyMask = Boolean( - this.maskInputOptions.textarea || - (type && - this.maskInputOptions[type as keyof typeof this.maskInputOptions]), - ); - item.attributes.value = maskInputWithPrivacy( - value, - textarea, - this.privacy, - legacyMask, - this.maskInputFn, - ); - } else { - item.attributes.value = maskInputValue({ - element: textarea, - maskInputOptions: this.maskInputOptions, - tagName: textarea.tagName, - type, - value, - maskInputFn: this.maskInputFn, - }); - } + // `textarea.tagName` is safe unshadowed here: every caller only reaches + // this method after already comparing that same object's `.tagName` to + // the string literal `'TEXTAREA'` (see the childList/pushAdd/text-mutation + // call sites above, all now routed through `untaintedTagName`). A + // shadowed `tagName` fails that string comparison, so nothing shadowed + // ever reaches this call with a stale/wrong reference. + item.attributes.value = maskInput({ + element: textarea, + maskInputOptions: this.maskInputOptions, + tagName: textarea.tagName, + type, + value, + maskInputFn: this.maskInputFn, + privacy: this.privacy, + }); }; private processMutation = (m: mutationRecord) => { @@ -621,31 +629,42 @@ export default class MutationBuffer { !isBlocked(m.target, this.blockClass, this.blockSelector, false) && value !== m.oldValue ) { + // CSS is never masked, on any path: a starred stylesheet corrupts + // the replay. Mirrors serializeTextNode's `isStyle` exemption. + // `untaintedTagName` defeats a shadowed `tagName` (e.g. a form + // control named "tagName") instead of failing closed to "not style". + const parent = dom.parentNode(m.target); + const parentTagName = dom.untaintedTagName(parent as Element | null); + const isStyle = parentTagName === 'STYLE'; + const isScript = parentTagName === 'SCRIPT'; + let emittedValue = value; + if ( + !isStyle && + value && + needMaskingText( + m.target, + this.maskTextClass, + this.maskTextSelector, + this.effectiveUnmaskTextSelector, + true, // checkAncestors + ) + ) { + emittedValue = this.maskTextFn + ? this.maskTextFn(value, closestElementOfNode(m.target)) + : value.replace(/[\S]/g, '*'); + } else if ( + !isStyle && + !isScript && + value && + this.privacy && + detectSensitiveValue(value, this.privacy) + ) { + // Detectors mask the whole updated text node, same as the + // serializeTextNode hook does at snapshot time. + emittedValue = value.replace(/[\S]/g, '*'); + } this.texts.push({ - value: - value && this.privacy - ? maskTextWithPrivacy( - value, - closestElementOfNode(m.target), - this.privacy, - needMaskingText( - m.target, - this.maskTextClass, - this.maskTextSelector, - true, // checkAncestors - ), - this.maskTextFn, - ) - : needMaskingText( - m.target, - this.maskTextClass, - this.maskTextSelector, - true, // checkAncestors - ) && value - ? this.maskTextFn - ? this.maskTextFn(value, closestElementOfNode(m.target)) - : value.replace(/[\S]/g, '*') - : value, + value: emittedValue, node: m.target, }); } @@ -656,36 +675,24 @@ export default class MutationBuffer { let attributeName = m.attributeName as string; let value = (m.target as HTMLElement).getAttribute(attributeName); - if (attributeName === 'value') { + // `value` only means "input value" on form controls; on e.g. `
  • ` or + // `` it is an ordinary attribute and belongs to the normal + // `finalizeAttribute` path instead. `untaintedTagName` reads the real + // tag name even when a named form control (e.g. ) + // shadows the `tagName` property on `target`. + const targetTagName = dom.untaintedTagName(target); + if (attributeName === 'value' && FORM_VALUE_TAGS.has(targetTagName)) { const type = getInputType(target); - if (this.privacy) { - const legacyMask = Boolean( - this.maskInputOptions[ - target.tagName.toLowerCase() as keyof typeof this.maskInputOptions - ] || - (type && - this.maskInputOptions[ - type as keyof typeof this.maskInputOptions - ]), - ); - value = maskInputWithPrivacy( - value || '', - target, - this.privacy, - legacyMask, - this.maskInputFn, - ); - } else { - value = maskInputValue({ - element: target, - maskInputOptions: this.maskInputOptions, - tagName: target.tagName, - type, - value, - maskInputFn: this.maskInputFn, - }); - } + value = maskInput({ + element: target, + maskInputOptions: this.maskInputOptions, + tagName: targetTagName, + type, + value: value || '', + maskInputFn: this.maskInputFn, + privacy: this.privacy, + }); } if ( isBlocked(m.target, this.blockClass, this.blockSelector, false) || @@ -696,7 +703,7 @@ export default class MutationBuffer { let item = this.attributeMap.get(m.target); if ( - target.tagName === 'IFRAME' && + targetTagName === 'IFRAME' && attributeName === 'src' && !this.keepIframeSrcFn(value as string) ) { @@ -723,28 +730,20 @@ export default class MutationBuffer { // This is used to ensure we do not unmask value when using e.g. a "Show password" type button if ( attributeName === 'type' && - target.tagName === 'INPUT' && + targetTagName === 'INPUT' && (m.oldValue || '').toLowerCase() === 'password' ) { target.setAttribute('data-rr-is-password', 'true'); } - if (!ignoreAttribute(target.tagName, attributeName, value)) { + if (!ignoreAttribute(targetTagName, attributeName, value)) { // overwrite attribute if the mutations was triggered in same time - const transformed = transformAttribute( + item.attributes[attributeName] = transformAttribute( this.doc, - toLowerCase(target.tagName), + toLowerCase(targetTagName), toLowerCase(attributeName), value, ); - item.attributes[attributeName] = this.privacy - ? maskAttributeWithPrivacy( - target, - attributeName, - transformed, - this.privacy, - ) - : transformed; if (attributeName === 'style') { if (!this.unattachedDoc) { try { @@ -768,21 +767,9 @@ export default class MutationBuffer { newPriority !== old.style.getPropertyPriority(pname) ) { if (newPriority === '') { - item.styleDiff[pname] = this.privacy - ? maskTextWithPrivacy(newValue, target, this.privacy, false) - : newValue; + item.styleDiff[pname] = newValue; } else { - item.styleDiff[pname] = [ - this.privacy - ? maskTextWithPrivacy( - newValue, - target, - this.privacy, - false, - ) - : newValue, - newPriority, - ]; + item.styleDiff[pname] = [newValue, newPriority]; } } else { // for checking @@ -795,18 +782,14 @@ export default class MutationBuffer { item.styleDiff[pname] = false; // delete } } - } else if (attributeName === 'open' && target.tagName === 'DIALOG') { + } else if (attributeName === 'open' && targetTagName === 'DIALOG') { if (target.matches('dialog:modal')) { item.attributes['rr_open_mode'] = 'modal'; } else { item.attributes['rr_open_mode'] = 'non-modal'; } - let generated = this.generatedAttributes.get(m.target); - if (!generated) { - generated = new Set(); - this.generatedAttributes.set(m.target, generated); - } - generated.add('rr_open_mode'); + // recorder-generated, never page data: exempt from masking. + (item.generatedAttributes ||= new Set()).add('rr_open_mode'); } } break; @@ -818,7 +801,7 @@ export default class MutationBuffer { if (isBlocked(m.target, this.blockClass, this.blockSelector, true)) return; - if ((m.target as Element).tagName === 'TEXTAREA') { + if (dom.untaintedTagName(m.target as Element) === 'TEXTAREA') { // children would be ignored in genAdds as they aren't in the mirror this.genTextAreaValueMutation(m.target as HTMLTextAreaElement); return; // any removedNodes won't have been in mirror either diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 99e476b1ee..df31d92639 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -1,12 +1,4 @@ -import { - type MaskInputOptions, - maskInputWithPrivacy, - maskTextWithPrivacy, - shouldMaskInputWithPrivacy, - Mirror, - getInputType, - toLowerCase, -} from 'rrweb-snapshot'; +import { maskInput, Mirror, getInputType, toLowerCase } from 'rrweb-snapshot'; import type { FontFaceSet } from 'css-font-loading-module'; import { throttle, @@ -391,9 +383,9 @@ function initInputObserver({ ignoreSelector, maskInputOptions, maskInputFn, - privacy, sampling, userTriggeredOnInput, + privacy, }: observerParam): listenerHandler { function eventHandler(event: Event) { let target = getEventTarget(event) as HTMLElement | null; @@ -429,19 +421,15 @@ function initInputObserver({ if (type === 'radio' || type === 'checkbox') { isChecked = (target as HTMLInputElement).checked; } else { - const legacyMask = Boolean( - maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || - maskInputOptions[type as keyof MaskInputOptions], - ); - if (shouldMaskInputWithPrivacy(target, privacy, legacyMask)) { - text = maskInputWithPrivacy( - text, - target, - privacy, - legacyMask, - maskInputFn, - ); - } + text = maskInput({ + element: target, + maskInputOptions, + tagName, + type, + value: text, + maskInputFn, + privacy, + }); } cbWithDedup( target, @@ -594,29 +582,8 @@ function getIdAndStyleId( }; } -function stylesheetOwnerElement( - sheet: CSSStyleSheet | null | undefined, -): HTMLElement | null { - const owner = sheet?.ownerNode; - return owner instanceof Element ? (owner as HTMLElement) : null; -} - -function maskCssForRecord( - value: string, - sheet: CSSStyleSheet | null | undefined, - privacy: observerParam['privacy'], -): string { - if (!value || !privacy) return value; - return maskTextWithPrivacy( - value, - stylesheetOwnerElement(sheet), - privacy, - false, - ); -} - function initStyleSheetObserver( - { styleSheetRuleCb, mirror, stylesheetManager, privacy }: observerParam, + { styleSheetRuleCb, mirror, stylesheetManager }: observerParam, { win }: { win: IWindow }, ): listenerHandler { if (!win.CSSStyleSheet || !win.CSSStyleSheet.prototype) { @@ -647,7 +614,7 @@ function initStyleSheetObserver( styleSheetRuleCb({ id, styleId, - adds: [{ rule: maskCssForRecord(rule, thisArg, privacy), index }], + adds: [{ rule, index }], }); } return target.apply(thisArg, argumentsList); @@ -727,7 +694,7 @@ function initStyleSheetObserver( styleSheetRuleCb({ id, styleId, - replace: maskCssForRecord(text, thisArg, privacy), + replace: text, }); } return target.apply(thisArg, argumentsList); @@ -759,7 +726,7 @@ function initStyleSheetObserver( styleSheetRuleCb({ id, styleId, - replaceSync: maskCssForRecord(text, thisArg, privacy), + replaceSync: text, }); } return target.apply(thisArg, argumentsList); @@ -827,11 +794,7 @@ function initStyleSheetObserver( styleId, adds: [ { - rule: maskCssForRecord( - rule, - thisArg.parentStyleSheet, - privacy, - ), + rule, index: [ ...getNestedCSSRulePositions(thisArg), index || 0, // defaults to 0 @@ -962,7 +925,6 @@ function initStyleDeclarationObserver( mirror, ignoreCSSAttributes, stylesheetManager, - privacy, }: observerParam, { win }: { win: IWindow }, ): listenerHandler { @@ -992,11 +954,7 @@ function initStyleDeclarationObserver( styleId, set: { property, - value: maskCssForRecord( - value, - thisArg.parentRule?.parentStyleSheet, - privacy, - ), + value, priority, }, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts index 94e149aa8c..59ab3b1120 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas-manager.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas-manager.ts @@ -18,7 +18,11 @@ import initCanvasContextObserver from './canvas'; import initCanvasWebGLMutationObserver from './webgl'; import ImageBitmapDataURLWorker from '../../workers/image-bitmap-data-url-worker?worker&inline'; import type { ImageBitmapDataURLRequestWorker } from '../../workers/image-bitmap-data-url-worker'; -import { computeFrameMaskRegions, SKIP_FRAME } from './canvas-mask'; +import { + computeFrameMaskRegions, + getCanvasContentBoxSize, + SKIP_FRAME, +} from './canvas-mask'; export type RafStamps = { latestId: number; invokeId: number | null }; @@ -38,12 +42,31 @@ export class CanvasManager { private locked = false; private resetFrameDedup?: () => void; + /** + * Shadow roots the shadow-DOM manager is actively observing. FPS canvas + * discovery searches these directly instead of recursively re-walking + * `querySelectorAll('*')` for shadow hosts on every animation frame - + * nested shadow roots are tracked in turn as the shadow-DOM manager + * starts observing them, so no recursion is needed here. + */ + private trackedShadowRoots = new Set(); + public reset() { this.pendingCanvasMutations.clear(); this.resetObservers && this.resetObservers(); this.resetFrameDedup = undefined; } + /** Called by the shadow-DOM manager when it starts observing a shadow root. */ + public addShadowRoot(shadowRoot: ShadowRoot) { + this.trackedShadowRoots.add(shadowRoot); + } + + /** Called by the shadow-DOM manager when it stops observing a shadow root. */ + public removeShadowRoot(shadowRoot: ShadowRoot) { + this.trackedShadowRoots.delete(shadowRoot); + } + /** Start a new canvas frame epoch after a DOM full snapshot. */ public onFullSnapshot() { this.resetFrameDedup?.(); @@ -190,21 +213,19 @@ export class CanvasManager { let lastSnapshotTime = 0; const getCanvas = (): HTMLCanvasElement[] => { const matchedCanvas: HTMLCanvasElement[] = []; - const search = (root: ParentNode) => { + const collect = (root: ParentNode) => { try { root.querySelectorAll('canvas').forEach((canvas) => { if (!isBlocked(canvas, blockClass, blockSelector, true)) { matchedCanvas.push(canvas); } }); - root.querySelectorAll('*').forEach((element) => { - if (element.shadowRoot) search(element.shadowRoot); - }); } catch { // A broken custom DOM implementation must not cancel future frames. } }; - search(win.document); + collect(win.document); + this.trackedShadowRoots.forEach((root) => collect(root)); return matchedCanvas; }; @@ -257,8 +278,24 @@ export class CanvasManager { context.clear(context.COLOR_BUFFER_BIT); } } - const displayWidth = canvas.clientWidth || canvas.width; - const displayHeight = canvas.clientHeight || canvas.height; + let displayWidth = canvas.clientWidth || canvas.width; + let displayHeight = canvas.clientHeight || canvas.height; + if (options.canvasMasking) { + // The backing store maps onto the content box, not the + // border box that `clientWidth`/`clientHeight` report - + // measure it precisely whenever masking is configured so + // mask regions never get scaled against the wrong box. A + // canvas whose content box can't be measured (or has zero + // area) fails closed: skip the frame rather than fall back + // to a potentially wrong scale. + const contentBox = getCanvasContentBoxSize(canvas); + if (!contentBox) { + snapshotInProgressMap.set(id, false); + return; + } + displayWidth = contentBox.width; + displayHeight = contentBox.height; + } const maskRegions = computeFrameMaskRegions( options.canvasMasking, canvas, diff --git a/packages/rrweb/src/record/observers/canvas/canvas-mask.ts b/packages/rrweb/src/record/observers/canvas/canvas-mask.ts index 5c000c1c6b..55713781cf 100644 --- a/packages/rrweb/src/record/observers/canvas/canvas-mask.ts +++ b/packages/rrweb/src/record/observers/canvas/canvas-mask.ts @@ -51,6 +51,59 @@ export function computeFrameMaskRegions( }); } +/** + * The canvas backing store maps onto the element's content box (the CSS + * width/height it is drawn at), not its border box. `clientWidth` includes + * padding, so a padded canvas would otherwise skew the scale factor used to + * translate application-provided mask regions into backing-store pixels. + * + * Returns `null` (never a fallback size) when the content box cannot be + * measured or has zero area, so callers fail closed instead of silently + * reinterpreting CSS-pixel regions as backing-store pixels. + */ +export function getCanvasContentBoxSize( + canvas: HTMLCanvasElement, +): { width: number; height: number } | null { + let rect: { width: number; height: number }; + try { + rect = canvas.getBoundingClientRect(); + } catch { + return null; + } + if (!rect || !Number.isFinite(rect.width) || !Number.isFinite(rect.height)) + return null; + + let style: CSSStyleDeclaration; + try { + style = getComputedStyle(canvas); + } catch { + return null; + } + if (!style) return null; + + const px = (value: string): number => { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; + }; + + const width = + rect.width - + px(style.paddingLeft) - + px(style.paddingRight) - + px(style.borderLeftWidth) - + px(style.borderRightWidth); + const height = + rect.height - + px(style.paddingTop) - + px(style.paddingBottom) - + px(style.borderTopWidth) - + px(style.borderBottomWidth); + + if (!(width > 0) || !(height > 0)) return null; + + return { width, height }; +} + export function isCanvasMaskingConfigured( masking: CanvasMasking | undefined, ): boolean { diff --git a/packages/rrweb/src/record/shadow-dom-manager.ts b/packages/rrweb/src/record/shadow-dom-manager.ts index 6ffd9941c6..5af18df41c 100644 --- a/packages/rrweb/src/record/shadow-dom-manager.ts +++ b/packages/rrweb/src/record/shadow-dom-manager.ts @@ -28,6 +28,10 @@ export class ShadowDomManager { private bypassOptions: BypassOptions; private mirror: Mirror; private restoreHandlers: (() => void)[] = []; + // `shadowDoms` is a WeakSet purely for dedup and isn't iterable, so a + // separate iterable set tracks which shadow roots the canvas manager + // currently knows about, to be told when observation of them stops. + private canvasTrackedShadowRoots = new Set(); constructor(options: { mutationCb: mutationCallBack; @@ -53,6 +57,8 @@ export class ShadowDomManager { if (!isNativeShadowDom(shadowRoot)) return; if (this.shadowDoms.has(shadowRoot)) return; this.shadowDoms.add(shadowRoot); + this.canvasTrackedShadowRoots.add(shadowRoot); + this.bypassOptions.canvasManager.addShadowRoot(shadowRoot); const [observer] = initMutationObserver( { ...this.bypassOptions, @@ -153,5 +159,9 @@ export class ShadowDomManager { }); this.restoreHandlers = []; this.shadowDoms = new WeakSet(); + this.canvasTrackedShadowRoots.forEach((shadowRoot) => { + this.bypassOptions.canvasManager.removeShadowRoot(shadowRoot); + }); + this.canvasTrackedShadowRoots = new Set(); } } diff --git a/packages/rrweb/src/record/stylesheet-manager.ts b/packages/rrweb/src/record/stylesheet-manager.ts index 258c16548e..796fec8064 100644 --- a/packages/rrweb/src/record/stylesheet-manager.ts +++ b/packages/rrweb/src/record/stylesheet-manager.ts @@ -1,8 +1,4 @@ -import { - maskTextWithPrivacy, - stringifyRule, - type CompiledPrivacyPolicy, -} from 'rrweb-snapshot'; +import { stringifyRule } from 'rrweb-snapshot'; import type { elementNode, serializedNodeWithId, @@ -17,17 +13,14 @@ export class StylesheetManager { private trackedLinkElements: WeakSet = new WeakSet(); private mutationCb: mutationCallBack; private adoptedStyleSheetCb: adoptedStyleSheetCallback; - private privacy: CompiledPrivacyPolicy | undefined; public styleMirror = new StyleSheetMirror(); constructor(options: { mutationCb: mutationCallBack; adoptedStyleSheetCb: adoptedStyleSheetCallback; - privacy?: CompiledPrivacyPolicy; }) { this.mutationCb = options.mutationCb; this.adoptedStyleSheetCb = options.adoptedStyleSheetCb; - this.privacy = options.privacy; } public attachLinkElement( @@ -77,7 +70,7 @@ export class StylesheetManager { rules: Array.from( sheet.cssRules || sheet.rules || [], (r, index) => ({ - rule: this.maskAdoptedRule(stringifyRule(r, sheet.href), sheet), + rule: stringifyRule(r, sheet.href), index, }), ), @@ -94,17 +87,6 @@ export class StylesheetManager { this.trackedLinkElements = new WeakSet(); } - private maskAdoptedRule(rule: string, sheet: CSSStyleSheet): string { - if (!rule || !this.privacy) return rule; - const owner = sheet.ownerNode; - return maskTextWithPrivacy( - rule, - owner instanceof Element ? (owner as HTMLElement) : null, - this.privacy, - false, - ); - } - // TODO: take snapshot on stylesheet reload by applying event listener private trackStylesheetInLinkElement(_linkEl: HTMLLinkElement) { // linkEl.addEventListener('load', () => { diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 2bd58ed581..7794753d59 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -55,6 +55,14 @@ export type recordOptions = { ignoreSelector?: string; maskTextClass?: maskTextClass; maskTextSelector?: string; + /** + * A CSS selector whose matched elements (and their descendants) are never + * text-masked, even under `strict`'s mask-everything default or a policy + * `mask` rule. Merged with any `privacyPolicy` `unmask`/`allow` rule + * selectors. Only affects text masking -- it does not unmask input values, + * `title`/`placeholder`/`aria-label` attributes, or sanitized URLs. + */ + unmaskTextSelector?: string | null; maskAllInputs?: boolean; maskInputOptions?: MaskInputOptions; maskInputFn?: MaskInputFn; @@ -117,6 +125,7 @@ export type observerParam = { ignoreSelector: string | null; maskTextClass: maskTextClass; maskTextSelector: string | null; + unmaskTextSelector: string | null; maskInputOptions: MaskInputOptions; maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; @@ -165,6 +174,7 @@ export type MutationBufferParam = Pick< | 'blockSelector' | 'maskTextClass' | 'maskTextSelector' + | 'unmaskTextSelector' | 'inlineStylesheet' | 'maskInputOptions' | 'maskTextFn' diff --git a/packages/rrweb/test/record.test.ts b/packages/rrweb/test/record.test.ts index 4c243d31ef..dda00ce984 100644 --- a/packages/rrweb/test/record.test.ts +++ b/packages/rrweb/test/record.test.ts @@ -11,6 +11,7 @@ import { IncrementalSource, styleSheetRuleData, selectionData, + attributes, } from '@rrweb/types'; import { assertSnapshot, @@ -126,11 +127,16 @@ describe('record', function (this: ISuite) {

    initial@example.com

    +

    excluded text

    +

    visible text

    `); await ctx.page.evaluate(() => { const { record } = (window as unknown as IWindow).rrweb; record({ emit: (window as unknown as IWindow).emit, + // `data-privacy` selectors are part of the v2 presets; a recording + // with no policy at all stays on legacy semantics by design. + privacyPolicy: { version: 1, preset: 'balanced' }, }); const contact = document.querySelector('#contact')!; @@ -149,7 +155,10 @@ describe('record', function (this: ISuite) { expect(payload).not.toContain('Initial Name'); expect(payload).not.toContain('Changed Name'); expect(payload).not.toContain('secret'); - expect(payload).toContain('xxxxxxx@xxxxxxx.xxx'); + expect(payload).not.toContain('excluded text'); + expect(payload).toContain('visible text'); + // v2 masking is shape-free: stars only, no `xxxx@xxxx.xxx` shape mask. + expect(payload).toContain('*'.repeat('changed@example.com'.length)); }); it('applies detector plugins to the initial full snapshot', async () => { @@ -190,9 +199,10 @@ describe('record', function (this: ISuite) { const payload = JSON.stringify(ctx.events); expect(payload).not.toContain('person@example.com'); - expect(payload).toContain('xxxxxx@xxxxxxx.xxx'); + // v2: masking is shape-free, star-only (no digit/letter-preserving mask). + expect(payload).toContain('*'.repeat('person@example.com'.length)); expect(payload).not.toContain('Visible Name'); - expect(payload).toContain('xxxxxxx xxxx'); + expect(payload).toContain('*'.repeat('Visible Name'.length)); }); it('applies final attribute masking to snapshots and mutations', async () => { @@ -219,8 +229,60 @@ describe('record', function (this: ISuite) { const payload = JSON.stringify(ctx.events); expect(payload).not.toContain('initial@example.com'); expect(payload).not.toContain('changed@example.com'); - expect(payload).not.toContain('background-color'); expect(payload).toContain('[MASKED]'); + // CSS carried as an attribute is exempt from every masking path, so + // `maskAttributeFn` is never consulted for `style`. + expect(payload).toContain('background-color'); + }); + + it('masks attributes of nodes added after recording starts', async () => { + await ctx.page.setContent(`
    `); + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + emit: (window as unknown as IWindow).emit, + maskAllElementAttributes: true, + }); + + const added = document.createElement('div'); + added.setAttribute('data-user', 'bob@x.com'); + document.querySelector('#root')!.appendChild(added); + }); + await waitForRAF(ctx.page); + + const payload = JSON.stringify(ctx.events); + expect(payload).not.toContain('bob@x.com'); + expect(payload).toContain('*'.repeat('bob@x.com'.length)); + }); + + it('leaves the value attribute of non-form elements unmasked', async () => { + await ctx.page.setContent(`
    1. three
    `); + await ctx.page.evaluate(() => { + const { record } = (window as unknown as IWindow).rrweb; + record({ + emit: (window as unknown as IWindow).emit, + privacyPolicy: { version: 1, preset: 'balanced' }, + }); + + document.querySelector('#item')!.setAttribute('value', '7'); + }); + await waitForRAF(ctx.page); + + const attributeMutations = ctx.events + .filter( + (e) => + e.type === EventType.IncrementalSnapshot && + (e.data as { source: IncrementalSource }).source === + IncrementalSource.Mutation, + ) + .flatMap( + (e) => + (e.data as unknown as { attributes: { attributes: attributes }[] }) + .attributes, + ); + expect(attributeMutations.some((m) => m.attributes.value === '7')).toBe( + true, + ); }); it('can checkout full snapshot by count', async () => { diff --git a/packages/rrweb/test/record/canvas-mask.test.ts b/packages/rrweb/test/record/canvas-mask.test.ts index 23dbf1cb51..d792d20f3e 100644 --- a/packages/rrweb/test/record/canvas-mask.test.ts +++ b/packages/rrweb/test/record/canvas-mask.test.ts @@ -1,7 +1,11 @@ +/** + * @vitest-environment jsdom + */ import { describe, expect, it } from 'vitest'; import type { CanvasMaskRegion, CanvasMasking } from '@rrweb/types'; import { computeFrameMaskRegions, + getCanvasContentBoxSize, isCanvasMaskingConfigured, SKIP_FRAME, } from '../../src/record/observers/canvas/canvas-mask'; @@ -114,3 +118,53 @@ describe('canvas privacy masking', () => { ).toBe(true); }); }); + +describe('canvas content-box region scaling', () => { + it('uses the content box, not clientWidth, so padding does not skew the scale', () => { + const canvasEl = document.createElement('canvas'); + canvasEl.width = 100; + canvasEl.height = 100; + canvasEl.style.padding = '20px'; + document.body.appendChild(canvasEl); + + // jsdom performs no layout, so getBoundingClientRect/clientWidth are + // stubbed to reflect what a real browser would report: a 100x100 + // backing store padded by 20px on every side renders in a 140x140 + // border box, while clientWidth (border box minus border) is also 140. + canvasEl.getBoundingClientRect = () => + ({ width: 140, height: 140 } as DOMRect); + Object.defineProperty(canvasEl, 'clientWidth', { value: 140 }); + Object.defineProperty(canvasEl, 'clientHeight', { value: 140 }); + + const contentBox = getCanvasContentBoxSize(canvasEl); + expect(contentBox).toEqual({ width: 100, height: 100 }); + + // Regions expressed in content-box CSS pixels must come back unscaled + // (scale 1) once the content box is used, even though naively using + // clientWidth (140) would have shrunk every coordinate incorrectly. + expect( + computeFrameMaskRegions( + { maskRegions: () => [region] }, + canvasEl, + canvasEl.width, + canvasEl.height, + contentBox!.width, + contentBox!.height, + ), + ).toEqual([region]); + + document.body.removeChild(canvasEl); + }); + + it('reports no content box (fail closed) when the content box has zero area', () => { + const canvasEl = document.createElement('canvas'); + canvasEl.width = 100; + canvasEl.height = 100; + canvasEl.getBoundingClientRect = () => ({ width: 0, height: 0 } as DOMRect); + document.body.appendChild(canvasEl); + + expect(getCanvasContentBoxSize(canvasEl)).toBeNull(); + + document.body.removeChild(canvasEl); + }); +}); diff --git a/packages/rrweb/test/record/canvas-sampling.test.ts b/packages/rrweb/test/record/canvas-sampling.test.ts new file mode 100644 index 0000000000..8cf069c527 --- /dev/null +++ b/packages/rrweb/test/record/canvas-sampling.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; +import { resolveCanvasSampling } from '../../src/record'; + +describe('canvas fail-closed', () => { + it('forces numeric sampling when masking configured', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(resolveCanvasSampling('all', true)).toBe(4); + expect(resolveCanvasSampling(undefined, true)).toBe(4); + expect(resolveCanvasSampling(15, true)).toBe(15); + expect(resolveCanvasSampling('all', false)).toBe('all'); + warn.mockRestore(); + }); + + it('warns once per forced resolution so silent bypass is never possible', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + resolveCanvasSampling('all', true); + expect(warn).toHaveBeenCalledWith( + '[rrweb] canvasMasking requires FPS canvas capture; forcing sampling.canvas = 4', + ); + warn.mockRestore(); + }); + + it('does not warn when a numeric sampling is already provided', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + resolveCanvasSampling(30, true); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); diff --git a/packages/rrweb/test/record/privacy.test.ts b/packages/rrweb/test/record/privacy.test.ts new file mode 100644 index 0000000000..9425aad8c4 --- /dev/null +++ b/packages/rrweb/test/record/privacy.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest'; +import record from '../../src/record'; +import { + EventType, + IncrementalSource, + type RecordPlugin, + type eventWithTime, +} from '@rrweb/types'; + +describe('record() privacy policy plugin fallback', () => { + it('a plugin returning a malformed policy falls back to the user policy instead of throwing', () => { + const badPlugin: RecordPlugin = { + name: 'bad@1', + applyPrivacyPolicy: () => ({ nonsense: true } as never), + }; + + let stop: (() => void) | undefined; + expect(() => { + stop = record({ emit: () => {}, plugins: [badPlugin] }); + }).not.toThrow(); + stop?.(); + }); + + it('still throws when the user supplies their own invalid policy directly (no plugin involved)', () => { + expect(() => { + record({ + emit: () => {}, + // @ts-expect-error intentionally invalid for this test + privacyPolicy: { nonsense: true }, + }); + }).toThrow(); + }); +}); + +describe('record() and a whose tagName is shadowed', () => { + it('records an attribute mutation on the form without throwing', async () => { + // Real browsers make a named form control reachable as an own property + // on its (`` makes `form.tagName` + // resolve to the , not the string `'FORM'`). jsdom doesn't + // implement that quirk, so the shadowing is reproduced directly here, + // the same way packages/rrweb-snapshot/test/utils.test.ts does for + // `untaintedTagName` itself. + document.body.innerHTML = ''; + const form = document.querySelector('form')!; + const input = document.querySelector('input')!; + Object.defineProperty(form, 'tagName', { + value: input, + configurable: true, + }); + // sanity check: the shadowing actually took effect + expect(typeof form.tagName).not.toBe('string'); + + const events: eventWithTime[] = []; + let uncaught: unknown; + const onError = (e: ErrorEvent) => { + uncaught = e.error ?? e.message; + }; + window.addEventListener('error', onError); + + const stop = record({ emit: (event) => events.push(event) }); + try { + form.setAttribute('data-x', 'mutated'); + // MutationObserver callbacks run as a microtask; give it a couple of + // ticks (a macrotask is enough to also drain any queued microtasks). + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + stop?.(); + window.removeEventListener('error', onError); + } + + expect(uncaught).toBeUndefined(); + const mutationEmitted = events.some( + (event) => + event.type === EventType.IncrementalSnapshot && + event.data.source === IncrementalSource.Mutation && + event.data.attributes.some( + (a) => + 'data-x' in a.attributes && a.attributes['data-x'] === 'mutated', + ), + ); + expect(mutationEmitted).toBe(true); + }); +}); + +describe('record() privacy detectors on live updates', () => { + const withEmailDetector = { + version: 1, + preset: 'legacy', + detectors: { email: true }, + } as const; + + it('masks a characterData mutation whose new text trips a detector', async () => { + document.body.innerHTML = '

    hello

    '; + const textNode = document.querySelector('p')!.firstChild as Text; + + const events: eventWithTime[] = []; + const stop = record({ + emit: (event) => events.push(event), + privacyPolicy: withEmailDetector, + }); + try { + textNode.data = 'contact bob@example.com'; + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + stop?.(); + } + + const textMutations = events.flatMap((event) => + event.type === EventType.IncrementalSnapshot && + event.data.source === IncrementalSource.Mutation + ? event.data.texts + : [], + ); + expect(textMutations.length).toBeGreaterThan(0); + expect(JSON.stringify(textMutations)).not.toContain('bob@example.com'); + }); + + it('leaves a characterData mutation with clean text untouched under legacy', async () => { + document.body.innerHTML = '

    hello

    '; + const textNode = document.querySelector('p')!.firstChild as Text; + + const events: eventWithTime[] = []; + const stop = record({ + emit: (event) => events.push(event), + privacyPolicy: withEmailDetector, + }); + try { + textNode.data = 'still plain text'; + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + stop?.(); + } + + const textMutations = events.flatMap((event) => + event.type === EventType.IncrementalSnapshot && + event.data.source === IncrementalSource.Mutation + ? event.data.texts + : [], + ); + expect(JSON.stringify(textMutations)).toContain('still plain text'); + }); + + it('never scans '; + const textNode = document.querySelector('style')!.firstChild as Text; + + const events: eventWithTime[] = []; + const stop = record({ + emit: (event) => events.push(event), + privacyPolicy: withEmailDetector, + }); + try { + // an email-shaped token inside CSS content must never star the sheet + textNode.data = '/* bob@example.com */ body{color:blue}'; + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + stop?.(); + } + + const textMutations = events.flatMap((event) => + event.type === EventType.IncrementalSnapshot && + event.data.source === IncrementalSource.Mutation + ? event.data.texts + : [], + ); + expect(JSON.stringify(textMutations)).toContain('body{color:blue}'); + }); + + it('masks a live input event whose value trips a detector', async () => { + document.body.innerHTML = ''; + const input = document.querySelector('input')!; + + const events: eventWithTime[] = []; + const stop = record({ + emit: (event) => events.push(event), + privacyPolicy: withEmailDetector, + }); + try { + input.value = 'bob@example.com'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + stop?.(); + } + + const inputEvents = events.filter( + (event) => + event.type === EventType.IncrementalSnapshot && + event.data.source === IncrementalSource.Input, + ); + expect(inputEvents.length).toBeGreaterThan(0); + expect(JSON.stringify(inputEvents)).not.toContain('bob@example.com'); + }); +}); diff --git a/packages/rrweb/test/record/style-mutation.test.ts b/packages/rrweb/test/record/style-mutation.test.ts new file mode 100644 index 0000000000..3e34d5780c --- /dev/null +++ b/packages/rrweb/test/record/style-mutation.test.ts @@ -0,0 +1,78 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import type * as puppeteer from 'puppeteer'; +import { vi } from 'vitest'; +import type { eventWithTime, listenerHandler } from '@rrweb/types'; +import type { recordOptions } from '../../src/types'; +import { launchPuppeteer, waitForRAF } from '../utils'; + +interface IWindow extends Window { + rrweb: { + record: ( + options: recordOptions, + ) => listenerHandler | undefined; + }; + emit: (e: eventWithTime) => undefined; +} + +describe('style text mutations', () => { + vi.setConfig({ testTimeout: 10_000 }); + + let browser: puppeteer.Browser; + let page: puppeteer.Page; + let code: string; + let events: eventWithTime[] = []; + + beforeAll(async () => { + browser = await launchPuppeteer(); + code = fs.readFileSync( + path.resolve(__dirname, '../../dist/rrweb.umd.cjs'), + 'utf8', + ); + }); + + beforeEach(async () => { + page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + '

    secret

    ', + ); + await page.evaluate(code); + events = []; + await page.exposeFunction('emit', (e: eventWithTime) => { + events.push(e); + }); + }); + + afterEach(async () => { + await page.close(); + }); + + afterAll(async () => { + await browser.close(); + }); + + it('records