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 thesecret
'; + 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
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
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
+ //