From ddb4d8cd96658c99f872b6f0a9e0e08e256e0830 Mon Sep 17 00:00:00 2001 From: Michael Dellanoce Date: Thu, 12 Jan 2023 12:54:17 -0500 Subject: [PATCH] apply text mask settings to inputs #1096 --- packages/rrweb-snapshot/src/snapshot.ts | 39 ++++++++++++++++++++++++- packages/rrweb-snapshot/src/utils.ts | 5 +++- packages/rrweb/src/record/index.ts | 1 + packages/rrweb/src/record/mutation.ts | 10 +++++++ packages/rrweb/src/record/observer.ts | 19 +++++++++++- packages/rrweb/test/integration.test.ts | 23 +++++++++++++++ packages/rrweb/test/utils.ts | 1 + 7 files changed, 95 insertions(+), 3 deletions(-) diff --git a/packages/rrweb-snapshot/src/snapshot.ts b/packages/rrweb-snapshot/src/snapshot.ts index 1f9670a455..1c307221a5 100644 --- a/packages/rrweb-snapshot/src/snapshot.ts +++ b/packages/rrweb-snapshot/src/snapshot.ts @@ -570,6 +570,11 @@ function serializeNode( keepIframeSrcFn, newlyAddedElement, rootId, + maskAllText, + maskTextClass, + unmaskTextClass, + maskTextSelector, + unmaskTextSelector, }); case n.TEXT_NODE: return serializeTextNode(n as Text, { @@ -579,6 +584,8 @@ function serializeNode( maskTextSelector, unmaskTextSelector, maskTextFn, + maskInputOptions, + maskInputFn, rootId, }); case n.CDATA_SECTION_NODE: @@ -613,6 +620,8 @@ function serializeTextNode( maskTextSelector: string | null; unmaskTextSelector: string | null; maskTextFn: MaskTextFn | undefined; + maskInputOptions: MaskInputOptions; + maskInputFn: MaskInputFn | undefined; rootId: number | undefined; }, ): serializedNode { @@ -623,6 +632,8 @@ function serializeTextNode( maskTextSelector, unmaskTextSelector, maskTextFn, + maskInputOptions, + maskInputFn, rootId, } = options; // The parent node may not be a html element which has a tagName attribute. @@ -631,6 +642,7 @@ function serializeTextNode( let textContent = n.textContent; const isStyle = parentTagName === 'STYLE' ? true : undefined; const isScript = parentTagName === 'SCRIPT' ? true : undefined; + const isTextarea = parentTagName === 'TEXTAREA' ? true : undefined; if (isStyle && textContent) { try { // try to read style sheet @@ -672,6 +684,11 @@ function serializeTextNode( ? maskTextFn(textContent, n.parentElement) : textContent.replace(/[\S]/g, '*'); } + if (isTextarea && textContent && maskInputOptions.textarea) { + textContent = maskInputFn + ? maskInputFn(textContent, n.parentNode as HTMLElement) + : textContent.replace(/[\S]/g, '*'); + } return { type: NodeType.Text, @@ -699,6 +716,11 @@ function serializeElementNode( */ newlyAddedElement?: boolean; rootId: number | undefined; + maskAllText: boolean; + maskTextClass: string | RegExp; + unmaskTextClass: string | RegExp; + maskTextSelector: string | null; + unmaskTextSelector: string | null; }, ): serializedNode | false { const { @@ -714,6 +736,11 @@ function serializeElementNode( keepIframeSrcFn, newlyAddedElement = false, rootId, + maskAllText, + maskTextClass, + unmaskTextClass, + maskTextSelector, + unmaskTextSelector, } = options; const needBlock = _isBlockedElement(n, blockClass, blockSelector); const tagName = getValidTagName(n); @@ -771,6 +798,15 @@ function serializeElementNode( value ) { const type = getInputType(n); + const forceMask = needMaskingText( + n, + maskTextClass, + maskTextSelector, + unmaskTextClass, + unmaskTextSelector, + maskAllText, + ); + attributes.value = maskInputValue({ element: n, type, @@ -778,6 +814,7 @@ function serializeElementNode( value, maskInputOptions, maskInputFn, + forceMask, }); } else if (checked) { attributes.checked = checked; @@ -1326,7 +1363,7 @@ function snapshot( inlineStylesheet?: boolean; maskAllInputs?: boolean | MaskInputOptions; maskTextFn?: MaskTextFn; - maskInputFn?: MaskTextFn; + maskInputFn?: MaskInputFn; slimDOM?: 'all' | boolean | SlimDOMOptions; dataURLOptions?: DataURLOptions; inlineImages?: boolean; diff --git a/packages/rrweb-snapshot/src/utils.ts b/packages/rrweb-snapshot/src/utils.ts index 95444c18b3..702d1c7b7e 100644 --- a/packages/rrweb-snapshot/src/utils.ts +++ b/packages/rrweb-snapshot/src/utils.ts @@ -219,6 +219,7 @@ export function maskInputValue({ type, value, maskInputFn, + forceMask, }: { element: HTMLElement; maskInputOptions: MaskInputOptions; @@ -226,13 +227,15 @@ export function maskInputValue({ type: string | null; value: string | null; maskInputFn?: MaskInputFn; + forceMask?: boolean; }): string { let text = value || ''; const actualType = type && toLowerCase(type); if ( maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || - (actualType && maskInputOptions[actualType as keyof MaskInputOptions]) + (actualType && maskInputOptions[actualType as keyof MaskInputOptions]) || + forceMask ) { if (maskInputFn) { text = maskInputFn(text, element); diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index 033452dccb..ea6aa7c0ff 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -384,6 +384,7 @@ function record( unmaskTextSelector, inlineStylesheet, maskAllInputs: maskInputOptions, + maskInputFn, maskTextFn, slimDOM: slimDOMOptions, dataURLOptions, diff --git a/packages/rrweb/src/record/mutation.ts b/packages/rrweb/src/record/mutation.ts index a3d5e3082a..453236e086 100644 --- a/packages/rrweb/src/record/mutation.ts +++ b/packages/rrweb/src/record/mutation.ts @@ -545,6 +545,15 @@ export default class MutationBuffer { if (attributeName === 'value') { const type = getInputType(target); + const forceMask = needMaskingText( + m.target, + this.maskTextClass, + this.maskTextSelector, + this.unmaskTextClass, + this.unmaskTextSelector, + this.maskAllText, + ); + value = maskInputValue({ element: target, maskInputOptions: this.maskInputOptions, @@ -552,6 +561,7 @@ export default class MutationBuffer { type, value, maskInputFn: this.maskInputFn, + forceMask, }); } if ( diff --git a/packages/rrweb/src/record/observer.ts b/packages/rrweb/src/record/observer.ts index 0aa0f9856b..40853af8a6 100644 --- a/packages/rrweb/src/record/observer.ts +++ b/packages/rrweb/src/record/observer.ts @@ -4,6 +4,7 @@ import { Mirror, getInputType, toLowerCase, + needMaskingText, } from 'rrweb-snapshot'; import type { FontFaceSet } from 'css-font-loading-module'; import { @@ -420,6 +421,11 @@ function initInputObserver({ maskInputFn, sampling, userTriggeredOnInput, + maskAllText, + maskTextClass, + unmaskTextClass, + maskTextSelector, + unmaskTextSelector, }: observerParam): listenerHandler { function eventHandler(event: Event) { let target = getEventTarget(event) as HTMLElement | null; @@ -452,11 +458,21 @@ function initInputObserver({ let isChecked = false; const type: Lowercase = getInputType(target) || ''; + const forceMask = needMaskingText( + target as Node, + maskTextClass, + maskTextSelector, + unmaskTextClass, + unmaskTextSelector, + maskAllText, + ); + if (type === 'radio' || type === 'checkbox') { isChecked = (target as HTMLInputElement).checked; } else if ( maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] || - maskInputOptions[type as keyof MaskInputOptions] + maskInputOptions[type as keyof MaskInputOptions] || + forceMask ) { text = maskInputValue({ element: target, @@ -465,6 +481,7 @@ function initInputObserver({ type, value: text, maskInputFn, + forceMask, }); } cbWithDedup( diff --git a/packages/rrweb/test/integration.test.ts b/packages/rrweb/test/integration.test.ts index 48d051dcc5..08d30c0e94 100644 --- a/packages/rrweb/test/integration.test.ts +++ b/packages/rrweb/test/integration.test.ts @@ -362,6 +362,29 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots); }); + it('can use maskTextSelector to configure which inputs should be masked', async () => { + const page: puppeteer.Page = await browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'form.html', { + maskTextSelector: 'input[type="text"],textarea', + maskInputFn: () => '*'.repeat(10), + }), + ); + + await page.type('input[type="text"]', 'test'); + await page.click('input[type="radio"]'); + await page.click('input[type="checkbox"]'); + await page.type('textarea', 'textarea test'); + await page.type('input[type="password"]', 'password'); + await page.select('select', '1'); + + const snapshots = (await page.evaluate( + 'window.snapshots', + )) as eventWithTime[]; + assertSnapshot(snapshots); + }); + it('should mask password value attribute with maskInputOptions', async () => { const page: puppeteer.Page = await browser.newPage(); await page.goto('about:blank'); diff --git a/packages/rrweb/test/utils.ts b/packages/rrweb/test/utils.ts index a32932c308..a3284daddd 100644 --- a/packages/rrweb/test/utils.ts +++ b/packages/rrweb/test/utils.ts @@ -694,6 +694,7 @@ export function generateRecordSnippet(options: recordOptions) { maskAllInputs: ${options.maskAllInputs}, maskAllText: ${options.maskAllText}, maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, + maskInputFn: ${options.maskInputFn}, userTriggeredOnInput: ${options.userTriggeredOnInput}, maskTextFn: ${options.maskTextFn}, maskInputFn: ${options.maskInputFn},