diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 18df0aeb..9efb8653 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -6,6 +6,7 @@ import type { KeepIframeSrcFn, ICanvas, DialogAttributes, + MaskAttributeFn, } from './types'; import { NodeType } from '@posthog-internal/rrweb-types'; import type { @@ -399,6 +400,7 @@ function serializeNode( maskInputOptions: MaskInputOptions; maskTextFn: MaskTextFn | undefined; maskInputFn: MaskInputFn | undefined; + maskAttributeFn: MaskAttributeFn | undefined; dataURLOptions?: DataURLOptions; inlineImages: boolean; recordCanvas: boolean; @@ -420,6 +422,7 @@ function serializeNode( maskInputOptions = {}, maskTextFn, maskInputFn, + maskAttributeFn, dataURLOptions = {}, inlineImages, recordCanvas, @@ -465,6 +468,7 @@ function serializeNode( keepIframeSrcFn, newlyAddedElement, rootId, + maskAttributeFn, }); case n.TEXT_NODE: return serializeTextNode(n as Text, { @@ -574,6 +578,7 @@ function serializeElementNode( */ newlyAddedElement?: boolean; rootId: number | undefined; + maskAttributeFn: MaskAttributeFn | undefined; }, ): serializedNode | false { const { @@ -589,6 +594,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,11 +604,10 @@ 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( - doc, - tagName, - toLowerCase(attr.name), - attr.value, + attributes[attr.name] = maskAttributeFn( + attr.name, + transformAttribute(doc, tagName, toLowerCase(attr.name), attr.value), + n, ); } } @@ -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 ( @@ -1256,6 +1266,7 @@ function snapshot( maskAllInputs?: boolean | MaskInputOptions; maskTextFn?: MaskTextFn; maskInputFn?: MaskInputFn; + maskAttributeFn?: MaskAttributeFn; slimDOM?: 'all' | boolean | SlimDOMOptions; dataURLOptions?: DataURLOptions; inlineImages?: boolean; @@ -1287,6 +1298,7 @@ function snapshot( maskAllInputs = false, maskTextFn, maskInputFn, + maskAttributeFn, slimDOM = false, dataURLOptions, preserveWhiteSpace, @@ -1352,6 +1364,7 @@ function snapshot( maskInputOptions, maskTextFn, maskInputFn, + maskAttributeFn, slimDOMOptions, dataURLOptions, inlineImages, diff --git a/packages/rrweb-snapshot/src/types.ts b/packages/rrweb-snapshot/src/types.ts index 8fe564c0..311e99da 100644 --- a/packages/rrweb-snapshot/src/types.ts +++ b/packages/rrweb-snapshot/src/types.ts @@ -68,6 +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 KeepIframeSrcFn = (src: string) => boolean; diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index a7f3ce81..001fa020 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -9,13 +9,13 @@ 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, +import { textNode, -} from '../src/types'; -import { NodeType } from '@posthog-internal/rrweb-types'; + 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-snapshot/test/snapshot.test.ts b/packages/rrweb-snapshot/test/snapshot.test.ts index 5778eb0a..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 '../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 +29,295 @@ 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);` + - `background: #aabbcc url(http://localhost/css/images/a.jpg) 50% 50% repeat;`, - ); - }); + 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); + 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, + 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, + 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', - }, + 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 }); - 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; - }; - - 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('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/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..31fcf98e 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -10,6 +10,7 @@ import { isNativeShadowDom, getInputType, toLowerCase, + type MaskAttributeFn, } from '@posthog-internal/rrweb-snapshot'; import type { observerParam, MutationBufferParam } from '../types'; import type { @@ -180,6 +181,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 +208,7 @@ export default class MutationBuffer { 'maskInputOptions', 'maskTextFn', 'maskInputFn', + 'maskAttributeFn', 'keepIframeSrcFn', 'recordCanvas', 'inlineImages', @@ -571,7 +574,13 @@ 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..8fd7b190 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -4,6 +4,7 @@ import type { SlimDOMOptions, MaskInputFn, MaskTextFn, + MaskAttributeFn, } from '@posthog-internal/rrweb-snapshot'; import type { IframeManager } from './record/iframe-manager'; import type { ShadowDomManager } from './record/shadow-dom-manager'; @@ -55,6 +56,7 @@ export type recordOptions = { maskInputOptions?: MaskInputOptions; maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; + maskAttributeFn?: MaskAttributeFn; slimDOMOptions?: SlimDOMOptions | 'all' | true; ignoreCSSAttributes?: Set; inlineStylesheet?: boolean; @@ -94,6 +96,7 @@ export type observerParam = { maskInputOptions: MaskInputOptions; maskInputFn?: MaskInputFn; maskTextFn?: MaskTextFn; + maskAttributeFn?: MaskAttributeFn; keepIframeSrcFn: KeepIframeSrcFn; inlineStylesheet: boolean; styleSheetRuleCb: styleSheetRuleCallback; @@ -139,6 +142,7 @@ export type MutationBufferParam = Pick< | 'maskInputOptions' | 'maskTextFn' | 'maskInputFn' + | 'maskAttributeFn' | 'keepIframeSrcFn' | 'recordCanvas' | 'inlineImages' 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..06e6a7fa 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); + }); });