From 54eeb21dd88414b2d6be2108fd9636341d28730d Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 24 Jan 2025 19:31:11 +0000 Subject: [PATCH 1/3] mask full snapshot --- packages/rrweb-snapshot/src/snapshot.ts | 18 +- packages/rrweb-snapshot/src/types.ts | 1 + packages/rrweb-snapshot/test/snapshot.test.ts | 437 ++++++++++-------- .../__snapshots__/integration.test.ts.snap | 247 ++++++++++ packages/rrweb/test/html/mask-attribute.html | 17 + packages/rrweb/test/integration.test.ts | 29 ++ 6 files changed, 555 insertions(+), 194 deletions(-) create mode 100644 packages/rrweb/test/html/mask-attribute.html diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 18df0aeb..8f7e2fa7 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -5,8 +5,8 @@ import type { MaskInputFn, KeepIframeSrcFn, ICanvas, - DialogAttributes, -} from './types'; + DialogAttributes, MaskAttributeFn, +} from "./types"; import { NodeType } from '@posthog-internal/rrweb-types'; import type { serializedNode, @@ -399,6 +399,7 @@ function serializeNode( maskInputOptions: MaskInputOptions; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; + maskAttributeFn: MaskAttributeFn | undefined; dataURLOptions?: DataURLOptions; inlineImages: boolean; recordCanvas: boolean; @@ -420,6 +421,7 @@ function serializeNode( maskInputOptions = {}, maskTextFn, maskInputFn, + maskAttributeFn, dataURLOptions = {}, inlineImages, recordCanvas, @@ -465,6 +467,7 @@ function serializeNode( keepIframeSrcFn, newlyAddedElement, rootId, + maskAttributeFn, }); case n.TEXT_NODE: return serializeTextNode(n as Text, { @@ -574,6 +577,7 @@ function serializeElementNode( */ newlyAddedElement?: boolean; rootId: number | undefined; + maskAttributeFn: MaskAttributeFn | undefined; }, ): serializedNode | false { const { @@ -589,6 +593,8 @@ function serializeElementNode( keepIframeSrcFn, newlyAddedElement = false, rootId, + // by default, we can just pass the attribute through + maskAttributeFn = (_name, value, _element) => value } = options; const needBlock = _isBlockedElement(n, blockClass, blockSelector); const tagName = getValidTagName(n); @@ -597,12 +603,12 @@ function serializeElementNode( for (let i = 0; i < len; i++) { const attr = n.attributes[i]; if (!ignoreAttribute(tagName, attr.name, attr.value)) { - attributes[attr.name] = transformAttribute( + attributes[attr.name] = maskAttributeFn(attr.name, transformAttribute( doc, tagName, toLowerCase(attr.name), attr.value, - ); + ), n); } } // remote css @@ -963,6 +969,7 @@ export function serializeNodeWithId( ) => unknown; stylesheetLoadTimeout?: number; cssCaptured?: boolean; + maskAttributeFn?: MaskAttributeFn; }, ): serializedNodeWithId | null { const { @@ -989,6 +996,7 @@ export function serializeNodeWithId( keepIframeSrcFn = () => false, newlyAddedElement = false, cssCaptured = false, + maskAttributeFn, } = options; let { needsMask } = options; let { preserveWhiteSpace = true } = options; @@ -1020,6 +1028,7 @@ export function serializeNodeWithId( keepIframeSrcFn, newlyAddedElement, cssCaptured, + maskAttributeFn, }); if (!_serializedNode) { // TODO: dev only @@ -1100,6 +1109,7 @@ export function serializeNodeWithId( stylesheetLoadTimeout, keepIframeSrcFn, cssCaptured: false, + maskAttributeFn, }; if ( diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index 8fe564c0..41eb4520 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -68,6 +68,7 @@ export type SlimDOMOptions = Partial<{ export type MaskTextFn = (text: string, element: HTMLElement | null) => string; export type MaskInputFn = (text: string, element: HTMLElement) => string; +export type MaskAttributeFn = (name: string, value: string | null, element: HTMLElement) => string | null; export type KeepIframeSrcFn = (src: string) => boolean; diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index 5778eb0a..88418b0f 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -8,10 +8,10 @@ import snapshot, { _isBlockedElement, serializeNodeWithId, } from '../src/snapshot'; -import { elementNode, serializedNodeWithId } from '../src/types'; +import { elementNode, serializedNodeWithId } from '@posthog-internal/rrweb-types'; import { Mirror, absolutifyURLs } from '../src/utils'; -const serializeNode = (node: Node): serializedNodeWithId | null => { +const serializeNode = (node: Node, options: Partial[1]> = {}): serializedNodeWithId | null => { return serializeNodeWithId(node, { doc: document, mirror: new Mirror(), @@ -23,233 +23,290 @@ const serializeNode = (node: Node): serializedNodeWithId | null => { inlineStylesheet: true, maskTextFn: undefined, maskInputFn: undefined, + maskAttributeFn: undefined, slimDOMOptions: {}, + ...options, }); }; -describe('absolute url to stylesheet', () => { - const href = 'http://localhost/css/style.css'; +describe('snapshot', () => { + describe('absolute url to stylesheet', () => { + const href = 'http://localhost/css/style.css'; - it('can handle relative path', () => { - expect(absolutifyURLs('url(a.jpg)', href)).toEqual( - `url(http://localhost/css/a.jpg)`, - ); - }); + it('can handle relative path', () => { + expect(absolutifyURLs('url(a.jpg)', href)).toEqual( + `url(http://localhost/css/a.jpg)`, + ); + }); - it('can handle same level path', () => { - expect(absolutifyURLs('url("./a.jpg")', href)).toEqual( - `url("http://localhost/css/a.jpg")`, - ); - }); + it('can handle same level path', () => { + expect(absolutifyURLs('url("./a.jpg")', href)).toEqual( + `url("http://localhost/css/a.jpg")`, + ); + }); - it('can handle parent level path', () => { - expect(absolutifyURLs('url("../a.jpg")', href)).toEqual( - `url("http://localhost/a.jpg")`, - ); - }); + it('can handle parent level path', () => { + expect(absolutifyURLs('url("../a.jpg")', href)).toEqual( + `url("http://localhost/a.jpg")`, + ); + }); - it('can handle absolute path', () => { - expect(absolutifyURLs('url("/a.jpg")', href)).toEqual( - `url("http://localhost/a.jpg")`, - ); - }); + it('can handle absolute path', () => { + expect(absolutifyURLs('url("/a.jpg")', href)).toEqual( + `url("http://localhost/a.jpg")`, + ); + }); - it('can handle external path', () => { - expect(absolutifyURLs('url("http://localhost/a.jpg")', href)).toEqual( - `url("http://localhost/a.jpg")`, - ); - }); + it('can handle external path', () => { + expect(absolutifyURLs('url("http://localhost/a.jpg")', href)).toEqual( + `url("http://localhost/a.jpg")`, + ); + }); - it('can handle single quote path', () => { - expect(absolutifyURLs(`url('./a.jpg')`, href)).toEqual( - `url('http://localhost/css/a.jpg')`, - ); - }); + it('can handle single quote path', () => { + expect(absolutifyURLs(`url('./a.jpg')`, href)).toEqual( + `url('http://localhost/css/a.jpg')`, + ); + }); - it('can handle no quote path', () => { - expect(absolutifyURLs('url(./a.jpg)', href)).toEqual( - `url(http://localhost/css/a.jpg)`, - ); - }); + it('can handle no quote path', () => { + expect(absolutifyURLs('url(./a.jpg)', href)).toEqual( + `url(http://localhost/css/a.jpg)`, + ); + }); - it('can handle multiple no quote paths', () => { - expect( - absolutifyURLs( - 'background-image: url(images/b.jpg);background: #aabbcc url(images/a.jpg) 50% 50% repeat;', - href, - ), - ).toEqual( - `background-image: url(http://localhost/css/images/b.jpg);` + + it('can handle multiple no quote paths', () => { + expect( + absolutifyURLs( + 'background-image: url(images/b.jpg);background: #aabbcc url(images/a.jpg) 50% 50% repeat;', + href, + ), + ).toEqual( + `background-image: url(http://localhost/css/images/b.jpg);` + `background: #aabbcc url(http://localhost/css/images/a.jpg) 50% 50% repeat;`, - ); - }); + ); + }); - it('can handle data url image', () => { - expect(absolutifyURLs('url(data:image/gif;base64,ABC)', href)).toEqual( - 'url(data:image/gif;base64,ABC)', - ); - expect( - absolutifyURLs( - 'url(data:application/font-woff;base64,d09GMgABAAAAAAm)', - href, - ), - ).toEqual('url(data:application/font-woff;base64,d09GMgABAAAAAAm)'); - }); + it('can handle data url image', () => { + expect(absolutifyURLs('url(data:image/gif;base64,ABC)', href)).toEqual( + 'url(data:image/gif;base64,ABC)', + ); + expect( + absolutifyURLs( + 'url(data:application/font-woff;base64,d09GMgABAAAAAAm)', + href, + ), + ).toEqual('url(data:application/font-woff;base64,d09GMgABAAAAAAm)'); + }); - it('preserves quotes around inline svgs with spaces', () => { - expect( - absolutifyURLs( + it('preserves quotes around inline svgs with spaces', () => { + expect( + absolutifyURLs( + "url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%2328a745' d='M3'/%3E%3C/svg%3E\")", + href, + ), + ).toEqual( "url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%2328a745' d='M3'/%3E%3C/svg%3E\")", - href, - ), - ).toEqual( - "url(\"data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%2328a745' d='M3'/%3E%3C/svg%3E\")", - ); - expect( - absolutifyURLs( + ); + expect( + absolutifyURLs( + 'url(\'data:image/svg+xml;utf8,\')', + href, + ), + ).toEqual( 'url(\'data:image/svg+xml;utf8,\')', - href, - ), - ).toEqual( - 'url(\'data:image/svg+xml;utf8,\')', - ); - expect( - absolutifyURLs( + ); + expect( + absolutifyURLs( + 'url("data:image/svg+xml;utf8,")', + href, + ), + ).toEqual( 'url("data:image/svg+xml;utf8,")', - href, - ), - ).toEqual( - 'url("data:image/svg+xml;utf8,")', - ); - }); - it('can handle empty path', () => { - expect(absolutifyURLs(`url('')`, href)).toEqual(`url('')`); + ); + }); + it('can handle empty path', () => { + expect(absolutifyURLs(`url('')`, href)).toEqual(`url('')`); + }); }); -}); -describe('isBlockedElement()', () => { - const subject = (html: string, opt: any = {}) => - _isBlockedElement(render(html), 'rr-block', opt.blockSelector); + describe('isBlockedElement()', () => { + const subject = (html: string, opt: any = {}) => + _isBlockedElement(render(html), 'rr-block', opt.blockSelector); - const render = (html: string): HTMLElement => - JSDOM.fragment(html).querySelector('div')!; + const render = (html: string): HTMLElement => + JSDOM.fragment(html).querySelector('div')!; - it('can handle empty elements', () => { - expect(subject('
')).toEqual(false); - }); + it('can handle empty elements', () => { + expect(subject('
')).toEqual(false); + }); - it('blocks prohibited className', () => { - expect(subject('
')).toEqual(true); - }); + it('blocks prohibited className', () => { + expect(subject('
')).toEqual(true); + }); - it('does not block random data selector', () => { - expect(subject('
')).toEqual(false); - }); + it('does not block random data selector', () => { + expect(subject('
')).toEqual(false); + }); - it('blocks blocked selector', () => { - expect( - subject('
', { blockSelector: '[data-rr-block]' }), - ).toEqual(true); - }); -}); - -describe('style elements', () => { - const render = (html: string): HTMLStyleElement => { - document.write(html); - return document.querySelector('style')!; - }; - - it('should serialize all rules of stylesheet when the sheet has a single child node', () => { - const styleEl = render(``); - styleEl.sheet?.insertRule('section { color: blue; }'); - expect(serializeNode(styleEl)).toMatchObject({ - rootId: undefined, - attributes: { - _cssText: 'section {color: blue;}body {color: red;}', - }, - type: 2, + it('blocks blocked selector', () => { + expect( + subject('
', { blockSelector: '[data-rr-block]' }), + ).toEqual(true); }); }); - it('should serialize all rules on stylesheets with mix of insertion type', () => { - const styleEl = render(``); - styleEl.sheet?.insertRule('section.lost { color: unseeable; }'); // browser throws this away after append - styleEl.append(document.createTextNode('section { color: blue; }')); - styleEl.sheet?.insertRule('section.working { color: pink; }'); - expect(serializeNode(styleEl)).toMatchObject({ - rootId: undefined, - attributes: { - _cssText: - 'section.working {color: pink;}body {color: red;}/* rr_split */section {color: blue;}', - }, - type: 2, + describe('style elements', () => { + const render = (html: string): HTMLStyleElement => { + document.write(html); + return document.querySelector('style')!; + }; + + it('should serialize all rules of stylesheet when the sheet has a single child node', () => { + const styleEl = render(``); + styleEl.sheet?.insertRule('section { color: blue; }'); + expect(serializeNode(styleEl)).toMatchObject({ + rootId: undefined, + attributes: { + _cssText: 'section {color: blue;}body {color: red;}', + }, + type: 2, + }); + }); + + it('should serialize all rules on stylesheets with mix of insertion type', () => { + const styleEl = render(``); + styleEl.sheet?.insertRule('section.lost { color: unseeable; }'); // browser throws this away after append + styleEl.append(document.createTextNode('section { color: blue; }')); + styleEl.sheet?.insertRule('section.working { color: pink; }'); + expect(serializeNode(styleEl)).toMatchObject({ + rootId: undefined, + attributes: { + _cssText: + 'section.working {color: pink;}body {color: red;}/* rr_split */section {color: blue;}', + }, + type: 2, + }); }); }); -}); -describe('scrollTop/scrollLeft', () => { - const render = (html: string): HTMLDivElement => { - document.write(html); - return document.querySelector('div')!; - }; + describe('scrollTop/scrollLeft', () => { + const render = (html: string): HTMLDivElement => { + document.write(html); + return document.querySelector('div')!; + }; - it('should serialize scroll positions', () => { - const el = render(`
+ it('should serialize scroll positions', () => { + const el = render(`
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
`); - el.scrollTop = 10; - el.scrollLeft = 20; - expect(serializeNode(el)).toMatchObject({ - attributes: { - rr_scrollTop: 10, - rr_scrollLeft: 20, - }, + el.scrollTop = 10; + el.scrollLeft = 20; + expect(serializeNode(el)).toMatchObject({ + attributes: { + rr_scrollTop: 10, + rr_scrollLeft: 20, + }, + }); }); }); -}); - -describe('form', () => { - const render = (html: string): HTMLTextAreaElement => { - document.write(html); - return document.querySelector('textarea')!; - }; - - it('should record textarea values once', () => { - const el = render(``); - const sel = serializeNode(el) as elementNode; - - // we serialize according to where the DOM stores the value, not how - // the HTML stores it (this is so that maskInputValue can work over - // inputs/textareas/selects in a uniform way) - expect(sel).toMatchObject({ - attributes: { - value: 'Lorem ipsum', - }, - }); - expect(sel?.childNodes).toEqual([]); // shouldn't be stored in childNodes while in transit - }); -}); -describe('jsdom snapshot', () => { - const render = (html: string): Document => { - document.write(html); - return document; - }; + describe('form', () => { + const render = (html: string): HTMLTextAreaElement => { + document.write(html); + return document.querySelector('textarea')!; + }; - it("doesn't rely on global browser objects", () => { - // this test is incomplete in terms of coverage, - // but the idea being that we are checking that all features use the - // passed-in `doc` object rather than the global `document` - // (which is only present in browsers) - // in any case, supporting jsdom is not a primary goal + it('should record textarea values once', () => { + const el = render(``); + const sel = serializeNode(el) as elementNode; - const doc = render(`

Hello world

`); - const sn = snapshot(doc, { - // JSDOM Error: Not implemented: HTMLCanvasElement.prototype.toDataURL (without installing the canvas npm package) - //recordCanvas: true, + // we serialize according to where the DOM stores the value, not how + // the HTML stores it (this is so that maskInputValue can work over + // inputs/textareas/selects in a uniform way) + expect(sel).toMatchObject({ + attributes: { + value: 'Lorem ipsum', + }, + }); + expect(sel?.childNodes).toEqual([]); // shouldn't be stored in childNodes while in transit }); - expect(sn).toMatchObject({ - type: 0, + }); + + describe('jsdom snapshot', () => { + const render = (html: string): Document => { + document.write(html); + return document; + }; + + it("doesn't rely on global browser objects", () => { + // this test is incomplete in terms of coverage, + // but the idea being that we are checking that all features use the + // passed-in `doc` object rather than the global `document` + // (which is only present in browsers) + // in any case, supporting jsdom is not a primary goal + + const doc = render(`

Hello world

`); + const sn = snapshot(doc, { + // JSDOM Error: Not implemented: HTMLCanvasElement.prototype.toDataURL (without installing the canvas npm package) + //recordCanvas: true, + }); + expect(sn).toMatchObject({ + type: 0, + }); }); }); -}); + + + describe('masking', () => { + const render = (html: string): Document => { + document.write(html); + return document; + }; + ;[{ + name: 'mask based on attribute name', + el: `
content
`, + expected: { 'data-attr-pii': '**********' }, + }, + { + name: 'mask by using the attribute value argument', + el: `mask1`, + expected: { "src": "REDACTED DATA URL" }, + },{ + name: 'mask by using the element argument', + el: `
should hide me
`, + expected: { 'data-attr-wat': 'REDACTED' }, + }].forEach(({ el, expected }) => { + it('attribute masking: %s', () => { + const rendered = render(el); + + const sel = serializeNode(rendered, { + maskAttributeFn: (n: string, v: string | null, el: HTMLElement): string | null => { + // a function that will mask + // any data url + if (v && v.startsWith('data:')) { + return 'REDACTED DATA URL'; + } + // any attribute that contains pii in the name + if (n && n.includes('pii')) { + return "*".repeat(v?.length || 0).substring(0, 100); + } + // any element that has hide me in its content + if (el.innerHTML?.includes('hide me')) { + return 'REDACTED'; + } + // if no match, return the original value + return v + } + }) as elementNode; + + let html = sel.childNodes[0] as elementNode; + let body = html.childNodes[1] as elementNode; + expect(body.childNodes[0]).toMatchObject({ + attributes: expected, + }); + }) + }) + }) +}) + diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index 4fa6c2f3..cd7259ea 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -594,6 +594,253 @@ exports[`record integration tests > can freeze mutations 1`] = ` ]" `; +exports[`record integration tests > can mask attributes 1`] = ` +"[ + { + \\"type\\": 0, + \\"data\\": {} + }, + { + \\"type\\": 1, + \\"data\\": {} + }, + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 1, + \\"name\\": \\"html\\", + \\"publicId\\": \\"\\", + \\"systemId\\": \\"\\", + \\"id\\": 2 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": { + \\"lang\\": \\"en\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"charset\\": \\"UTF-8\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 7 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"name\\": \\"viewport\\", + \\"content\\": \\"width=device-width, initial-scale=1.0\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 9 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"meta\\", + \\"attributes\\": { + \\"http-equiv\\": \\"X-UA-Compatible\\", + \\"content\\": \\"ie=edge\\" + }, + \\"childNodes\\": [], + \\"id\\": 10 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 11 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"title\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"Mask text\\", + \\"id\\": 13 + } + ], + \\"id\\": 12 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 14 + } + ], + \\"id\\": 4 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 15 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 17 + }, + { + \\"type\\": 5, + \\"textContent\\": \\" so we can mask by using the attribute name \\", + \\"id\\": 18 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 19 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": { + \\"data-attr-pii\\": \\"my address\\" + }, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"content\\", + \\"id\\": 21 + } + ], + \\"id\\": 20 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 22 + }, + { + \\"type\\": 5, + \\"textContent\\": \\" so we can mask by using the attribute value argument \\", + \\"id\\": 23 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 24 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"img\\", + \\"attributes\\": { + \\"src\\": \\"data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==\\", + \\"alt\\": \\"mask1\\" + }, + \\"childNodes\\": [], + \\"id\\": 25 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 26 + }, + { + \\"type\\": 5, + \\"textContent\\": \\" so we can mask by using the element argument \\", + \\"id\\": 27 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 28 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"div\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"should hide me\\", + \\"id\\": 30 + } + ], + \\"id\\": 29 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\", + \\"id\\": 31 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"script\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"SCRIPT_PLACEHOLDER\\", + \\"id\\": 33 + } + ], + \\"id\\": 32 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\", + \\"id\\": 34 + } + ], + \\"id\\": 16 + } + ], + \\"id\\": 3 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + } +]" +`; + exports[`record integration tests > can mask character data mutations 1`] = ` "[ { diff --git a/packages/rrweb/test/html/mask-attribute.html b/packages/rrweb/test/html/mask-attribute.html new file mode 100644 index 00000000..aae4560d --- /dev/null +++ b/packages/rrweb/test/html/mask-attribute.html @@ -0,0 +1,17 @@ + + + + + + + Mask text + + + +
content
+ + mask1 + +
should hide me
+ + diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index e6b6c069..2b1e4b10 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -1549,4 +1549,33 @@ describe('record integration tests', function (this: ISuite) { expect(changedColors).toEqual([Color, Color]); await page.close(); }); + + it('can mask attributes', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'mask-attribute.html', { + maskAttributeFn: (n: string, v: string | null, el: HTMLElement) => { + // a function that will mask + // any data url + if (v && v.startsWith('data:')) { + return 'REDACTED DATA URL'; + } + // any attribute that contains pii in the name + if (n && n.includes('pii')) { + return "*".repeat(v?.length || 0).substring(0, 100); + } + // any element that has hide me in its content + if (el.innerText.includes('hide me')) { + return 'REDACTED'; + } + }, + }), + ); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + await assertSnapshot(snapshots); + }) }); From f0dd000e7ac986b4615f8abddd4134a3ac021d38 Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 24 Jan 2025 19:44:28 +0000 Subject: [PATCH 2/3] and maybe attributes as well --- packages/rrweb-snapshot/src/snapshot.ts | 3 +++ packages/rrweb-snapshot/test/css.test.ts | 8 ++------ packages/rrweb/src/record/index.ts | 4 ++++ packages/rrweb/src/record/mutation.ts | 9 ++++++--- packages/rrweb/src/types.ts | 7 +++++-- 5 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 8f7e2fa7..ba855b6d 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -1266,6 +1266,7 @@ function snapshot( maskAllInputs?: boolean | MaskInputOptions; maskTextFn?: MaskTextFn; maskInputFn?: MaskInputFn; + maskAttributeFn?: MaskAttributeFn; slimDOM?: 'all' | boolean | SlimDOMOptions; dataURLOptions?: DataURLOptions; inlineImages?: boolean; @@ -1297,6 +1298,7 @@ function snapshot( maskAllInputs = false, maskTextFn, maskInputFn, + maskAttributeFn, slimDOM = false, dataURLOptions, preserveWhiteSpace, @@ -1362,6 +1364,7 @@ function snapshot( maskInputOptions, maskTextFn, maskInputFn, + maskAttributeFn, slimDOMOptions, dataURLOptions, inlineImages, diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index a7f3ce81..ab4d6709 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -9,13 +9,9 @@ import { splitCssText, stringifyStylesheet } from './../src/utils'; import { applyCssSplits } from './../src/rebuild'; import * as fs from 'fs'; import * as path from 'path'; -import type { - serializedElementNodeWithId, - BuildCache, - textNode, -} from '../src/types'; -import { NodeType } from '@posthog-internal/rrweb-types'; +import { textNode,NodeType,serializedElementNodeWithId } from '@posthog-internal/rrweb-types'; import { Window } from 'happy-dom'; +import { BuildCache } from "../src"; describe('css parser', () => { function parse(plugin: AcceptedPlugin, input: string): string { diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index e0b0fe04..00a2918f 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -81,6 +81,7 @@ function record( slimDOMOptions: _slimDOMOptions, maskInputFn, maskTextFn, + maskAttributeFn, hooks, packFn, sampling = {}, @@ -340,6 +341,7 @@ function record( dataURLOptions, maskTextFn, maskInputFn, + maskAttributeFn, recordCanvas, inlineImages, sampling, @@ -384,6 +386,7 @@ function record( inlineStylesheet, maskAllInputs: maskInputOptions, maskTextFn, + maskAttributeFn, maskInputFn, slimDOM: slimDOMOptions, dataURLOptions, @@ -542,6 +545,7 @@ function record( doc, maskInputFn, maskTextFn, + maskAttributeFn, keepIframeSrcFn, blockSelector, slimDOMOptions, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 2b07409a..33689a6e 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -9,8 +9,8 @@ import { Mirror, isNativeShadowDom, getInputType, - toLowerCase, -} from '@posthog-internal/rrweb-snapshot'; + toLowerCase, type MaskAttributeFn, +} from "@posthog-internal/rrweb-snapshot"; import type { observerParam, MutationBufferParam } from '../types'; import type { mutationRecord, @@ -180,6 +180,7 @@ export default class MutationBuffer { private maskInputOptions: observerParam['maskInputOptions']; private maskTextFn: observerParam['maskTextFn']; private maskInputFn: observerParam['maskInputFn']; + private maskAttributeFn: observerParam['maskAttributeFn']; private keepIframeSrcFn: observerParam['keepIframeSrcFn']; private recordCanvas: observerParam['recordCanvas']; private inlineImages: observerParam['inlineImages']; @@ -206,6 +207,7 @@ export default class MutationBuffer { 'maskInputOptions', 'maskTextFn', 'maskInputFn', + 'maskAttributeFn', 'keepIframeSrcFn', 'recordCanvas', 'inlineImages', @@ -571,7 +573,8 @@ export default class MutationBuffer { case 'attributes': { const target = m.target as HTMLElement; let attributeName = m.attributeName as string; - let value = (m.target as HTMLElement).getAttribute(attributeName); + const maskAttrFn: MaskAttributeFn = this.maskAttributeFn || ((_n, v, _el) => v) + let value = maskAttrFn(attributeName, (m.target as HTMLElement).getAttribute(attributeName), target); if (attributeName === 'value') { const type = getInputType(target); diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 4bd7206c..5c36d9f0 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -3,8 +3,8 @@ import type { MaskInputOptions, SlimDOMOptions, MaskInputFn, - MaskTextFn, -} from '@posthog-internal/rrweb-snapshot'; + MaskTextFn, MaskAttributeFn, +} from "@posthog-internal/rrweb-snapshot"; import type { IframeManager } from './record/iframe-manager'; import type { ShadowDomManager } from './record/shadow-dom-manager'; import type { Replayer } from './replay'; @@ -55,6 +55,7 @@ export type recordOptions = { maskInputOptions?: MaskInputOptions; maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; + maskAttributeFn?: MaskAttributeFn; slimDOMOptions?: SlimDOMOptions | 'all' | true; ignoreCSSAttributes?: Set; inlineStylesheet?: boolean; @@ -94,6 +95,7 @@ export type observerParam = { maskInputOptions: MaskInputOptions; maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; + maskAttributeFn?: MaskAttributeFn; keepIframeSrcFn: KeepIframeSrcFn; inlineStylesheet: boolean; styleSheetRuleCb: styleSheetRuleCallback; @@ -139,6 +141,7 @@ export type MutationBufferParam = Pick< | 'maskInputOptions' | 'maskTextFn' | 'maskInputFn' + | 'maskAttributeFn' | 'keepIframeSrcFn' | 'recordCanvas' | 'inlineImages' From b3f42c9485c64ca35ae9a1c189a636d127eb469d Mon Sep 17 00:00:00 2001 From: Paul D'Ambra Date: Fri, 24 Jan 2025 20:00:38 +0000 Subject: [PATCH 3/3] format --- packages/rrweb-snapshot/src/snapshot.ts | 18 ++--- packages/rrweb-snapshot/src/types.ts | 6 +- packages/rrweb-snapshot/test/css.test.ts | 8 ++- packages/rrweb-snapshot/test/snapshot.test.ts | 65 +++++++++++-------- packages/rrweb/src/record/mutation.ts | 14 ++-- packages/rrweb/src/types.ts | 5 +- packages/rrweb/test/integration.test.ts | 4 +- 7 files changed, 73 insertions(+), 47 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index ba855b6d..9efb8653 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -5,8 +5,9 @@ import type { MaskInputFn, KeepIframeSrcFn, ICanvas, - DialogAttributes, MaskAttributeFn, -} from "./types"; + DialogAttributes, + MaskAttributeFn, +} from './types'; import { NodeType } from '@posthog-internal/rrweb-types'; import type { serializedNode, @@ -594,7 +595,7 @@ function serializeElementNode( newlyAddedElement = false, rootId, // by default, we can just pass the attribute through - maskAttributeFn = (_name, value, _element) => value + maskAttributeFn = (_name, value, _element) => value, } = options; const needBlock = _isBlockedElement(n, blockClass, blockSelector); const tagName = getValidTagName(n); @@ -603,12 +604,11 @@ function serializeElementNode( for (let i = 0; i < len; i++) { const attr = n.attributes[i]; if (!ignoreAttribute(tagName, attr.name, attr.value)) { - attributes[attr.name] = maskAttributeFn(attr.name, transformAttribute( - doc, - tagName, - toLowerCase(attr.name), - attr.value, - ), n); + attributes[attr.name] = maskAttributeFn( + attr.name, + transformAttribute(doc, tagName, toLowerCase(attr.name), attr.value), + n, + ); } } // remote css diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index 41eb4520..311e99da 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -68,7 +68,11 @@ export type SlimDOMOptions = Partial<{ export type MaskTextFn = (text: string, element: HTMLElement | null) => string; export type MaskInputFn = (text: string, element: HTMLElement) => string; -export type MaskAttributeFn = (name: string, value: string | null, element: HTMLElement) => string | null; +export type MaskAttributeFn = ( + name: string, + value: string | null, + element: HTMLElement, +) => string | null; export type KeepIframeSrcFn = (src: string) => boolean; diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index ab4d6709..001fa020 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -9,9 +9,13 @@ import { splitCssText, stringifyStylesheet } from './../src/utils'; import { applyCssSplits } from './../src/rebuild'; import * as fs from 'fs'; import * as path from 'path'; -import { textNode,NodeType,serializedElementNodeWithId } from '@posthog-internal/rrweb-types'; +import { + textNode, + NodeType, + serializedElementNodeWithId, +} from '@posthog-internal/rrweb-types'; import { Window } from 'happy-dom'; -import { BuildCache } from "../src"; +import { BuildCache } from '../src'; describe('css parser', () => { function parse(plugin: AcceptedPlugin, input: string): string { diff --git a/packages/rrweb-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index 88418b0f..3faebbb2 100644 --- a/packages/rrweb-snapshot/test/snapshot.test.ts +++ b/packages/rrweb-snapshot/test/snapshot.test.ts @@ -8,10 +8,16 @@ import snapshot, { _isBlockedElement, serializeNodeWithId, } from '../src/snapshot'; -import { elementNode, serializedNodeWithId } from '@posthog-internal/rrweb-types'; +import { + elementNode, + serializedNodeWithId, +} from '@posthog-internal/rrweb-types'; import { Mirror, absolutifyURLs } from '../src/utils'; -const serializeNode = (node: Node, options: Partial[1]> = {}): serializedNodeWithId | null => { +const serializeNode = ( + node: Node, + options: Partial[1]> = {}, +): serializedNodeWithId | null => { return serializeNodeWithId(node, { doc: document, mirror: new Mirror(), @@ -83,7 +89,7 @@ describe('snapshot', () => { ), ).toEqual( `background-image: url(http://localhost/css/images/b.jpg);` + - `background: #aabbcc url(http://localhost/css/images/a.jpg) 50% 50% repeat;`, + `background: #aabbcc url(http://localhost/css/images/a.jpg) 50% 50% repeat;`, ); }); @@ -257,31 +263,37 @@ describe('snapshot', () => { }); }); - describe('masking', () => { const render = (html: string): Document => { document.write(html); return document; }; - ;[{ - name: 'mask based on attribute name', - el: `
content
`, - expected: { 'data-attr-pii': '**********' }, - }, - { - name: 'mask by using the attribute value argument', - el: `mask1`, - expected: { "src": "REDACTED DATA URL" }, - },{ - name: 'mask by using the element argument', - el: `
should hide me
`, - expected: { 'data-attr-wat': 'REDACTED' }, - }].forEach(({ el, expected }) => { + [ + { + name: 'mask based on attribute name', + el: `
content
`, + expected: { 'data-attr-pii': '**********' }, + }, + { + name: 'mask by using the attribute value argument', + el: `mask1`, + expected: { src: 'REDACTED DATA URL' }, + }, + { + name: 'mask by using the element argument', + el: `
should hide me
`, + expected: { 'data-attr-wat': 'REDACTED' }, + }, + ].forEach(({ el, expected }) => { it('attribute masking: %s', () => { const rendered = render(el); const sel = serializeNode(rendered, { - maskAttributeFn: (n: string, v: string | null, el: HTMLElement): string | null => { + maskAttributeFn: ( + n: string, + v: string | null, + el: HTMLElement, + ): string | null => { // a function that will mask // any data url if (v && v.startsWith('data:')) { @@ -289,15 +301,15 @@ describe('snapshot', () => { } // any attribute that contains pii in the name if (n && n.includes('pii')) { - return "*".repeat(v?.length || 0).substring(0, 100); + return '*'.repeat(v?.length || 0).substring(0, 100); } // any element that has hide me in its content if (el.innerHTML?.includes('hide me')) { return 'REDACTED'; } // if no match, return the original value - return v - } + return v; + }, }) as elementNode; let html = sel.childNodes[0] as elementNode; @@ -305,8 +317,7 @@ describe('snapshot', () => { expect(body.childNodes[0]).toMatchObject({ attributes: expected, }); - }) - }) - }) -}) - + }); + }); + }); +}); diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index 33689a6e..31fcf98e 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -9,8 +9,9 @@ import { Mirror, isNativeShadowDom, getInputType, - toLowerCase, type MaskAttributeFn, -} from "@posthog-internal/rrweb-snapshot"; + toLowerCase, + type MaskAttributeFn, +} from '@posthog-internal/rrweb-snapshot'; import type { observerParam, MutationBufferParam } from '../types'; import type { mutationRecord, @@ -573,8 +574,13 @@ export default class MutationBuffer { case 'attributes': { const target = m.target as HTMLElement; let attributeName = m.attributeName as string; - const maskAttrFn: MaskAttributeFn = this.maskAttributeFn || ((_n, v, _el) => v) - let value = maskAttrFn(attributeName, (m.target as HTMLElement).getAttribute(attributeName), target); + const maskAttrFn: MaskAttributeFn = + this.maskAttributeFn || ((_n, v, _el) => v); + let value = maskAttrFn( + attributeName, + (m.target as HTMLElement).getAttribute(attributeName), + target, + ); if (attributeName === 'value') { const type = getInputType(target); diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 5c36d9f0..8fd7b190 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -3,8 +3,9 @@ import type { MaskInputOptions, SlimDOMOptions, MaskInputFn, - MaskTextFn, MaskAttributeFn, -} from "@posthog-internal/rrweb-snapshot"; + MaskTextFn, + MaskAttributeFn, +} from '@posthog-internal/rrweb-snapshot'; import type { IframeManager } from './record/iframe-manager'; import type { ShadowDomManager } from './record/shadow-dom-manager'; import type { Replayer } from './replay'; diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 2b1e4b10..06e6a7fa 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -1563,7 +1563,7 @@ describe('record integration tests', function (this: ISuite) { } // any attribute that contains pii in the name if (n && n.includes('pii')) { - return "*".repeat(v?.length || 0).substring(0, 100); + return '*'.repeat(v?.length || 0).substring(0, 100); } // any element that has hide me in its content if (el.innerText.includes('hide me')) { @@ -1577,5 +1577,5 @@ describe('record integration tests', function (this: ISuite) { 'window.snapshots', )) as eventWithTime[]; await assertSnapshot(snapshots); - }) + }); });