diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 4dba1f536c..b1e8d8740f 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -241,6 +241,7 @@ export function transformAttribute( name: string, value: string | null, maskAllText: boolean, + unmaskTextSelector: string | undefined | null, maskTextFn: MaskTextFn | undefined, ): string | null { if (!value) { @@ -266,13 +267,7 @@ export function transformAttribute( return absoluteToDoc(doc, value); } else if ( maskAllText && - (['placeholder', 'title', 'aria-label'].indexOf(name) > -1 || - (tagName === 'input' && - name === 'value' && - element.getAttribute('type') && - ['submit', 'button'].indexOf( - element.getAttribute('type')!.toLowerCase(), - ) > -1)) + _shouldMaskAttribute(element, name, tagName, unmaskTextSelector) ) { return maskTextFn ? maskTextFn(value) : defaultMaskFn(value); } @@ -280,6 +275,26 @@ export function transformAttribute( return value; } +function _shouldMaskAttribute( + element: HTMLElement, + attribute: string, + tagName: string, + unmaskTextSelector: string | undefined | null, +): boolean { + if (unmaskTextSelector && element.matches(unmaskTextSelector)) { + return false; + } + return ( + ['placeholder', 'title', 'aria-label'].indexOf(attribute) > -1 || + (tagName === 'input' && + attribute === 'value' && + element.hasAttribute('type') && + ['submit', 'button'].indexOf( + element.getAttribute('type')!.toLowerCase(), + ) > -1) + ); +} + export function _isBlockedElement( element: HTMLElement, blockClass: string | RegExp, @@ -521,6 +536,7 @@ function serializeNode( name, value, maskAllText, + unmaskTextSelector, maskTextFn, ); } diff --git a/packages/rrweb-snapshot/typings/snapshot.d.ts b/packages/rrweb-snapshot/typings/snapshot.d.ts index 86233a5def..56a967f751 100644 --- a/packages/rrweb-snapshot/typings/snapshot.d.ts +++ b/packages/rrweb-snapshot/typings/snapshot.d.ts @@ -2,7 +2,7 @@ import { serializedNodeWithId, INode, idNodeMap, MaskInputOptions, SlimDOMOption export declare const IGNORED_NODE = -2; export declare function absoluteToStylesheet(cssText: string | null, href: string): string; export declare function absoluteToDoc(doc: Document, attributeValue: string): string; -export declare function transformAttribute(doc: Document, element: HTMLElement, tagName: string, name: string, value: string | null, maskAllText: boolean, maskTextFn: MaskTextFn | undefined): string | null; +export declare function transformAttribute(doc: Document, element: HTMLElement, tagName: string, name: string, value: string | null, maskAllText: boolean, unmaskTextSelector: string | undefined | null, maskTextFn: MaskTextFn | undefined): string | null; export declare function _isBlockedElement(element: HTMLElement, blockClass: string | RegExp, blockSelector: string | null, unblockSelector: string | null): boolean; export declare function needMaskingText(node: Node | null, maskTextClass: string | RegExp, maskTextSelector: string | null, unmaskTextSelector: string | null, maskAllText: boolean): boolean; export declare function serializeNodeWithId(n: Node | INode, options: { diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index f6767ecb48..849fca54a8 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -562,6 +562,7 @@ export default class MutationBuffer { m.attributeName!, value!, this.maskAllText, + this.unmaskTextSelector, this.maskTextFn ); } diff --git a/packages/rrweb/test/__snapshots__/integration.test.ts.snap b/packages/rrweb/test/__snapshots__/integration.test.ts.snap index b508216b30..f0dd3aa94d 100644 --- a/packages/rrweb/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb/test/__snapshots__/integration.test.ts.snap @@ -4097,6 +4097,366 @@ exports[`record integration tests can use maskInputOptions to configure which ty ]" `; +exports[`record integration tests correctly masks & unmasks attribute values 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": "********** ****", + "id": 13 + } + ], + "id": 12 + }, + { + "type": 3, + "textContent": "\\n ", + "id": 14 + } + ], + "id": 4 + }, + { + "type": 3, + "textContent": "\\n\\n ", + "id": 15 + }, + { + "type": 2, + "tagName": "body", + "attributes": {}, + "childNodes": [ + { + "type": 3, + "textContent": "\\n ", + "id": 17 + }, + { + "type": 2, + "tagName": "form", + "attributes": {}, + "childNodes": [ + { + "type": 3, + "textContent": "\\n ", + "id": 19 + }, + { + "type": 2, + "tagName": "div", + "attributes": { + "title": "Test title", + "aria-label": "Test aria label", + "class": "rr-unmask" + }, + "childNodes": [ + { + "type": 3, + "textContent": "\\n Test content\\n ", + "id": 21 + } + ], + "id": 20 + }, + { + "type": 3, + "textContent": "\\n\\n ", + "id": 22 + }, + { + "type": 2, + "tagName": "div", + "attributes": { + "title": "**** ***** *", + "aria-label": "**** **** ***** *" + }, + "childNodes": [ + { + "type": 3, + "textContent": "\\n **** ******* *\\n ", + "id": 24 + } + ], + "id": 23 + }, + { + "type": 3, + "textContent": "\\n\\n ", + "id": 25 + }, + { + "type": 2, + "tagName": "input", + "attributes": { + "type": "text", + "placeholder": "Test placeholder 1", + "class": "rr-unmask" + }, + "childNodes": [], + "id": 26 + }, + { + "type": 3, + "textContent": "\\n ", + "id": 27 + }, + { + "type": 2, + "tagName": "input", + "attributes": { + "type": "text", + "placeholder": "**** *********** *", + "class": "" + }, + "childNodes": [], + "id": 28 + }, + { + "type": 3, + "textContent": "\\n\\n ", + "id": 29 + }, + { + "type": 2, + "tagName": "input", + "attributes": { + "type": "submit", + "value": "Submit button 1", + "class": "rr-unmask" + }, + "childNodes": [], + "id": 30 + }, + { + "type": 3, + "textContent": "\\n ", + "id": 31 + }, + { + "type": 2, + "tagName": "input", + "attributes": { + "type": "submit", + "value": "****** ****** *", + "class": "" + }, + "childNodes": [], + "id": 32 + }, + { + "type": 3, + "textContent": "\\n\\n ", + "id": 33 + }, + { + "type": 2, + "tagName": "input", + "attributes": { + "type": "button", + "value": "****** *", + "class": "" + }, + "childNodes": [], + "id": 34 + }, + { + "type": 3, + "textContent": "\\n ", + "id": 35 + } + ], + "id": 18 + }, + { + "type": 3, + "textContent": "\\n \\n ", + "id": 36 + }, + { + "type": 2, + "tagName": "script", + "attributes": {}, + "childNodes": [ + { + "type": 3, + "textContent": "SCRIPT_PLACEHOLDER", + "id": 38 + } + ], + "id": 37 + }, + { + "type": 3, + "textContent": "\\n \\n \\n\\n", + "id": 39 + } + ], + "id": 16 + } + ], + "id": 3 + } + ], + "id": 1 + }, + "initialOffset": { + "left": 0, + "top": 0 + } + } + }, + { + "type": 3, + "data": { + "source": 0, + "texts": [], + "attributes": [ + { + "id": 20, + "attributes": { + "title": "new title", + "aria-label": "new aria label" + } + }, + { + "id": 23, + "attributes": { + "title": "*** *****", + "aria-label": "*** **** *****" + } + }, + { + "id": 26, + "attributes": { + "placeholder": "new placeholder" + } + }, + { + "id": 28, + "attributes": { + "placeholder": "*** ***********" + } + }, + { + "id": 30, + "attributes": { + "value": "new value" + } + }, + { + "id": 32, + "attributes": { + "value": "new value" + } + }, + { + "id": 34, + "attributes": { + "value": "new value" + } + } + ], + "removes": [], + "adds": [] + } + } +]" +`; + exports[`record integration tests handles null attribute values 1`] = ` "[ { diff --git a/packages/rrweb/test/html/attributes-mask.html b/packages/rrweb/test/html/attributes-mask.html new file mode 100644 index 0000000000..cab12e9bf3 --- /dev/null +++ b/packages/rrweb/test/html/attributes-mask.html @@ -0,0 +1,29 @@ + + + + + + + attributes mask + + + +
+
+ Test content +
+ +
+ Test content 2 +
+ + + + + + + + +
+ + diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 1583d1ba7a..30a526675b 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -10,7 +10,12 @@ import { waitForRAF, replaceLast, } from './utils'; -import { recordOptions, eventWithTime, EventType, IncrementalSource } from '../src/types'; +import { + recordOptions, + eventWithTime, + EventType, + IncrementalSource, +} from '../src/types'; import { visitSnapshot, NodeType } from '@sentry-internal/rrweb-snapshot'; interface ISuite { @@ -28,7 +33,10 @@ interface IMimeType { * Used to filter scroll events out of snapshots as they are flakey */ function isNotScroll(snapshot: eventWithTime) { - return !(snapshot.type === EventType.IncrementalSnapshot && snapshot.data.source === IncrementalSource.Scroll) + return !( + snapshot.type === EventType.IncrementalSnapshot && + snapshot.data.source === IncrementalSource.Scroll + ); } describe('record integration tests', function (this: ISuite) { @@ -222,15 +230,15 @@ describe('record integration tests', function (this: ISuite) { await page.goto('about:blank'); await page.setContent( - getHtml.call(this, 'mutation-observer.html', { - onMutation: `(mutations) => { window.lastMutationsLength = mutations.length; return mutations.length < 500 }` - }), + getHtml.call(this, 'mutation-observer.html', { + onMutation: `(mutations) => { window.lastMutationsLength = mutations.length; return mutations.length < 500 }`, + }), ); await page.evaluate(() => { const ul = document.querySelector('ul') as HTMLUListElement; - for(let i = 0; i < 2000; i++) { + for (let i = 0; i < 2000; i++) { const li = document.createElement('li'); ul.appendChild(li); const p = document.querySelector('p') as HTMLParagraphElement; @@ -241,11 +249,12 @@ describe('record integration tests', function (this: ISuite) { const snapshots = await page.evaluate('window.snapshots'); assertSnapshot(snapshots); - const lastMutationsLength = await page.evaluate('window.lastMutationsLength'); + const lastMutationsLength = await page.evaluate( + 'window.lastMutationsLength', + ); expect(lastMutationsLength).toBe(4000); }); - it('can freeze mutations', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); @@ -335,6 +344,36 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots); }); + it('correctly masks & unmasks attribute values', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'attributes-mask.html', { + maskAllText: true, + unmaskTextSelector: '.rr-unmask', + }), + ); + + // Change attributes, should still be masked + await page.evaluate(() => { + document + .querySelectorAll('body [title]') + .forEach((el) => el.setAttribute('title', 'new title')); + document + .querySelectorAll('body [aria-label]') + .forEach((el) => el.setAttribute('aria-label', 'new aria label')); + document + .querySelectorAll('body [placeholder]') + .forEach((el) => el.setAttribute('placeholder', 'new placeholder')); + document + .querySelectorAll('input[type="button"],input[type="submit"]') + .forEach((el) => el.setAttribute('value', 'new value')); + }); + + const snapshots = await page.evaluate('window.snapshots'); + assertSnapshot(snapshots); + }); + it('should record input values if dynamically added and maskAllInputs is false', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); @@ -353,7 +392,9 @@ describe('record integration tests', function (this: ISuite) { await page.type('#input', 'moo'); - const snapshots = await page.evaluate('window.snapshots') as eventWithTime[]; + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; assertSnapshot(snapshots.filter(isNotScroll)); }); @@ -376,7 +417,9 @@ describe('record integration tests', function (this: ISuite) { await page.type('#textarea', 'moo'); - const snapshots = await page.evaluate('window.snapshots') as eventWithTime[]; + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; assertSnapshot(snapshots.filter(isNotScroll)); }); @@ -384,7 +427,10 @@ describe('record integration tests', function (this: ISuite) { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); await page.setContent( - getHtml.call(this, 'empty.html', { maskAllInputs: false, maskInputSelector: '.rr-mask' }), + getHtml.call(this, 'empty.html', { + maskAllInputs: false, + maskInputSelector: '.rr-mask', + }), ); await page.evaluate(() => { @@ -399,7 +445,9 @@ describe('record integration tests', function (this: ISuite) { await page.type('#input-masked', 'moo'); - const snapshots = await page.evaluate('window.snapshots') as eventWithTime[]; + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; assertSnapshot(snapshots.filter(isNotScroll)); }); @@ -421,7 +469,9 @@ describe('record integration tests', function (this: ISuite) { await page.type('#input', 'moo'); - const snapshots = await page.evaluate('window.snapshots') as eventWithTime[]; + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; assertSnapshot(snapshots.filter(isNotScroll)); }); @@ -444,7 +494,9 @@ describe('record integration tests', function (this: ISuite) { await page.type('#textarea', 'moo'); - const snapshots = await page.evaluate('window.snapshots') as eventWithTime[]; + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; assertSnapshot(snapshots.filter(isNotScroll)); }); @@ -452,7 +504,10 @@ describe('record integration tests', function (this: ISuite) { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); await page.setContent( - getHtml.call(this, 'empty.html', { maskAllInputs: true, unmaskInputSelector: '.rr-unmask'}), + getHtml.call(this, 'empty.html', { + maskAllInputs: true, + unmaskInputSelector: '.rr-unmask', + }), ); await page.evaluate(() => { @@ -467,7 +522,9 @@ describe('record integration tests', function (this: ISuite) { await page.type('#input-unmasked', 'moo'); - const snapshots = await page.evaluate('window.snapshots') as eventWithTime[]; + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; assertSnapshot(snapshots.filter(isNotScroll)); }); @@ -479,7 +536,7 @@ describe('record integration tests', function (this: ISuite) { maskInputOptions: { text: false, textarea: false, - color: true + color: true, }, }), );