Skip to content

Commit f36b26c

Browse files
committed
feat: Better masking of option/radio/checkbox values
1 parent a82a3b4 commit f36b26c

File tree

10 files changed

+1009
-445
lines changed

10 files changed

+1009
-445
lines changed

.changeset/little-moons-camp.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'rrweb-snapshot': minor
3+
'rrweb': minor
4+
---
5+
6+
feat: Better masking of option/radio/checkbox values

packages/rrweb-snapshot/src/snapshot.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
maskInputValue,
2121
isNativeShadowDom,
2222
getCssRulesString,
23+
getInputValue,
2324
} from './utils';
2425

2526
let _id = 1;
@@ -31,12 +32,14 @@ export function genId(): number {
3132
return _id++;
3233
}
3334

34-
function getValidTagName(element: HTMLElement): string {
35+
function getValidTagName(element: HTMLElement): Lowercase<string> {
3536
if (element instanceof HTMLFormElement) {
3637
return 'form';
3738
}
3839

39-
const processedTagName = element.tagName.toLowerCase().trim();
40+
const processedTagName = element.tagName
41+
.toLowerCase()
42+
.trim() as unknown as Lowercase<string>;
4043

4144
if (tagNameRegex.test(processedTagName)) {
4245
// if the tag name is odd and we cannot extract
@@ -501,6 +504,8 @@ function serializeNode(
501504
maskTextClass,
502505
maskTextSelector,
503506
maskTextFn,
507+
maskInputOptions,
508+
maskInputFn,
504509
rootId,
505510
});
506511
case n.CDATA_SECTION_NODE:
@@ -532,10 +537,19 @@ function serializeTextNode(
532537
maskTextClass: string | RegExp;
533538
maskTextSelector: string | null;
534539
maskTextFn: MaskTextFn | undefined;
540+
maskInputOptions: MaskInputOptions;
541+
maskInputFn: MaskInputFn | undefined;
535542
rootId: number | undefined;
536543
},
537544
): serializedNode {
538-
const { maskTextClass, maskTextSelector, maskTextFn, rootId } = options;
545+
const {
546+
maskTextClass,
547+
maskTextSelector,
548+
maskTextFn,
549+
maskInputFn,
550+
maskInputOptions,
551+
rootId,
552+
} = options;
539553
// The parent node may not be a html element which has a tagName attribute.
540554
// So just let it be undefined which is ok in this use case.
541555
const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName;
@@ -577,6 +591,17 @@ function serializeTextNode(
577591
: textContent.replace(/[\S]/g, '*');
578592
}
579593

594+
// Handle <option> text like an input value
595+
if (parentTagName === 'OPTION' && textContent) {
596+
textContent = maskInputValue({
597+
type: null,
598+
tagName: parentTagName,
599+
value: textContent,
600+
maskInputOptions,
601+
maskInputFn,
602+
});
603+
}
604+
580605
return {
581606
type: NodeType.Text,
582607
textContent: textContent || '',
@@ -664,24 +689,30 @@ function serializeElementNode(
664689
}
665690
}
666691
// form fields
667-
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
668-
const value = (n as HTMLInputElement | HTMLTextAreaElement).value;
692+
if (
693+
tagName === 'input' ||
694+
tagName === 'textarea' ||
695+
tagName === 'select' ||
696+
tagName === 'option'
697+
) {
698+
const el = n as
699+
| HTMLInputElement
700+
| HTMLTextAreaElement
701+
| HTMLSelectElement
702+
| HTMLOptionElement;
703+
704+
const value = getInputValue(el, tagName.toUpperCase() as Uppercase<typeof tagName>, attributes.type);
669705
const checked = (n as HTMLInputElement).checked;
670-
if (
671-
attributes.type !== 'radio' &&
672-
attributes.type !== 'checkbox' &&
673-
attributes.type !== 'submit' &&
674-
attributes.type !== 'button' &&
675-
value
676-
) {
706+
if (attributes.type !== 'submit' && attributes.type !== 'button' && value) {
677707
attributes.value = maskInputValue({
678708
type: attributes.type,
679-
tagName,
709+
tagName: tagName.toUpperCase() as Uppercase<typeof tagName>,
680710
value,
681711
maskInputOptions,
682712
maskInputFn,
683713
});
684-
} else if (checked) {
714+
}
715+
if (checked) {
685716
attributes.checked = checked;
686717
}
687718
}

packages/rrweb-snapshot/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ export type MaskInputOptions = Partial<{
133133
textarea: boolean;
134134
select: boolean;
135135
password: boolean;
136+
radio: boolean;
137+
checkbox: boolean;
136138
}>;
137139

138140
export type SlimDOMOptions = Partial<{

packages/rrweb-snapshot/src/utils.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
documentTypeNode,
1212
textNode,
1313
elementNode,
14+
attributes,
1415
} from './types';
1516

1617
export function isElement(n: Node): n is Element {
@@ -161,12 +162,18 @@ export function maskInputValue({
161162
maskInputFn,
162163
}: {
163164
maskInputOptions: MaskInputOptions;
164-
tagName: string;
165+
tagName: Uppercase<string>;
165166
type: string | number | boolean | null;
166167
value: string | null;
167168
maskInputFn?: MaskInputFn;
168169
}): string {
169170
let text = value || '';
171+
172+
// Handle `option` like `select
173+
if (tagName === 'OPTION') {
174+
tagName = 'SELECT';
175+
}
176+
170177
if (
171178
maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] ||
172179
maskInputOptions[type as keyof MaskInputOptions]
@@ -246,3 +253,22 @@ export function isNodeMetaEqual(a: serializedNode, b: serializedNode): boolean {
246253
);
247254
return false;
248255
}
256+
257+
export function getInputValue(
258+
el:
259+
| HTMLInputElement
260+
| HTMLTextAreaElement
261+
| HTMLSelectElement
262+
| HTMLOptionElement,
263+
tagName: Uppercase<string>,
264+
type: attributes[string],
265+
): string {
266+
const normalizedType = typeof type === 'string' ? type.toLowerCase() : type;
267+
if (tagName === 'INPUT' && (normalizedType === 'radio' || normalizedType === 'checkbox')) {
268+
// checkboxes & radio buttons return `on` as their el.value when no value is specified
269+
// we only want to get the value if it is specified as `value='xxx'`
270+
return el.getAttribute('value') || '';
271+
}
272+
273+
return el.value;
274+
}

packages/rrweb/src/record/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ function record<T = eventWithTime>(
134134
textarea: true,
135135
select: true,
136136
password: true,
137+
radio: true,
138+
checkbox: true,
137139
}
138140
: _maskInputOptions !== undefined
139141
? _maskInputOptions

packages/rrweb/src/record/mutation.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -484,15 +484,16 @@ export default class MutationBuffer {
484484
}
485485
break;
486486
}
487+
487488
case 'attributes': {
488489
const target = m.target as HTMLElement;
489490
let attributeName = m.attributeName as string;
490491
let value = (m.target as HTMLElement).getAttribute(attributeName);
491492
if (attributeName === 'value') {
492493
value = maskInputValue({
493494
maskInputOptions: this.maskInputOptions,
494-
tagName: (m.target as HTMLElement).tagName,
495-
type: (m.target as HTMLElement).getAttribute('type'),
495+
tagName: target.tagName as unknown as Uppercase<string>,
496+
type: target.getAttribute('type'),
496497
value,
497498
maskInputFn: this.maskInputFn,
498499
});

packages/rrweb/src/record/observer.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { MaskInputOptions, maskInputValue, Mirror } from 'rrweb-snapshot';
1+
import {
2+
MaskInputOptions,
3+
maskInputValue,
4+
Mirror,
5+
getInputValue,
6+
} from 'rrweb-snapshot';
27
import type { FontFaceSet } from 'css-font-loading-module';
38
import {
49
throttle,
@@ -340,37 +345,47 @@ function initInputObserver({
340345
function eventHandler(event: Event) {
341346
let target = getEventTarget(event);
342347
const userTriggered = event.isTrusted;
348+
const tagName =
349+
target &&
350+
((
351+
target as Element
352+
).tagName.toUpperCase() as unknown as Uppercase<string>);
343353
/**
344354
* If a site changes the value 'selected' of an option element, the value of its parent element, usually a select element, will be changed as well.
345355
* We can treat this change as a value change of the select element the current target belongs to.
346356
*/
347-
if (target && (target as Element).tagName === 'OPTION')
348-
target = (target as Element).parentElement;
357+
if (tagName === 'OPTION') target = (target as Element).parentElement;
349358
if (
350359
!target ||
351-
!(target as Element).tagName ||
352-
INPUT_TAGS.indexOf((target as Element).tagName) < 0 ||
360+
!tagName ||
361+
INPUT_TAGS.indexOf(tagName) < 0 ||
353362
isBlocked(target as Node, blockClass, blockSelector, true)
354363
) {
355364
return;
356365
}
357-
const type: string | undefined = (target as HTMLInputElement).type;
358-
if ((target as HTMLElement).classList.contains(ignoreClass)) {
366+
367+
const el = target as
368+
| HTMLInputElement
369+
| HTMLTextAreaElement
370+
| HTMLSelectElement;
371+
372+
const type: string | undefined = (el as HTMLInputElement).type;
373+
if (el.classList.contains(ignoreClass)) {
359374
return;
360375
}
361-
let text = (target as HTMLInputElement).value;
376+
377+
let text = getInputValue(el, tagName, type);
362378
let isChecked = false;
363379
if (type === 'radio' || type === 'checkbox') {
364380
isChecked = (target as HTMLInputElement).checked;
365-
} else if (
366-
maskInputOptions[
367-
(target as Element).tagName.toLowerCase() as keyof MaskInputOptions
368-
] ||
381+
}
382+
if (
383+
maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] ||
369384
maskInputOptions[type as keyof MaskInputOptions]
370385
) {
371386
text = maskInputValue({
372387
maskInputOptions,
373-
tagName: (target as HTMLElement).tagName,
388+
tagName,
374389
type,
375390
value: text,
376391
maskInputFn,
@@ -391,11 +406,18 @@ function initInputObserver({
391406
.querySelectorAll(`input[type="radio"][name="${name}"]`)
392407
.forEach((el) => {
393408
if (el !== target) {
409+
const text = maskInputValue({
410+
maskInputOptions,
411+
tagName,
412+
type,
413+
value: getInputValue(el as HTMLInputElement, tagName, type),
414+
maskInputFn,
415+
});
394416
cbWithDedup(
395417
el,
396418
wrapEventWithUserTriggeredFlag(
397419
{
398-
text: (el as HTMLInputElement).value,
420+
text,
399421
isChecked: !isChecked,
400422
userTriggered: false,
401423
},

0 commit comments

Comments
 (0)