Skip to content

Commit

Permalink
feat: Better masking of option/radio/checkbox values
Browse files Browse the repository at this point in the history
  • Loading branch information
mydea committed Mar 20, 2023
1 parent a82a3b4 commit f36b26c
Show file tree
Hide file tree
Showing 10 changed files with 1,009 additions and 445 deletions.
6 changes: 6 additions & 0 deletions .changeset/little-moons-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'rrweb-snapshot': minor
'rrweb': minor
---

feat: Better masking of option/radio/checkbox values
59 changes: 45 additions & 14 deletions packages/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
maskInputValue,
isNativeShadowDom,
getCssRulesString,
getInputValue,
} from './utils';

let _id = 1;
Expand All @@ -31,12 +32,14 @@ export function genId(): number {
return _id++;
}

function getValidTagName(element: HTMLElement): string {
function getValidTagName(element: HTMLElement): Lowercase<string> {
if (element instanceof HTMLFormElement) {
return 'form';
}

const processedTagName = element.tagName.toLowerCase().trim();
const processedTagName = element.tagName
.toLowerCase()
.trim() as unknown as Lowercase<string>;

if (tagNameRegex.test(processedTagName)) {
// if the tag name is odd and we cannot extract
Expand Down Expand Up @@ -501,6 +504,8 @@ function serializeNode(
maskTextClass,
maskTextSelector,
maskTextFn,
maskInputOptions,
maskInputFn,
rootId,
});
case n.CDATA_SECTION_NODE:
Expand Down Expand Up @@ -532,10 +537,19 @@ function serializeTextNode(
maskTextClass: string | RegExp;
maskTextSelector: string | null;
maskTextFn: MaskTextFn | undefined;
maskInputOptions: MaskInputOptions;
maskInputFn: MaskInputFn | undefined;
rootId: number | undefined;
},
): serializedNode {
const { maskTextClass, maskTextSelector, maskTextFn, rootId } = options;
const {
maskTextClass,
maskTextSelector,
maskTextFn,
maskInputFn,
maskInputOptions,
rootId,
} = options;
// The parent node may not be a html element which has a tagName attribute.
// So just let it be undefined which is ok in this use case.
const parentTagName = n.parentNode && (n.parentNode as HTMLElement).tagName;
Expand Down Expand Up @@ -577,6 +591,17 @@ function serializeTextNode(
: textContent.replace(/[\S]/g, '*');
}

// Handle <option> text like an input value
if (parentTagName === 'OPTION' && textContent) {
textContent = maskInputValue({
type: null,
tagName: parentTagName,
value: textContent,
maskInputOptions,
maskInputFn,
});
}

return {
type: NodeType.Text,
textContent: textContent || '',
Expand Down Expand Up @@ -664,24 +689,30 @@ function serializeElementNode(
}
}
// form fields
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') {
const value = (n as HTMLInputElement | HTMLTextAreaElement).value;
if (
tagName === 'input' ||
tagName === 'textarea' ||
tagName === 'select' ||
tagName === 'option'
) {
const el = n as
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
| HTMLOptionElement;

const value = getInputValue(el, tagName.toUpperCase() as Uppercase<typeof tagName>, attributes.type);
const checked = (n as HTMLInputElement).checked;
if (
attributes.type !== 'radio' &&
attributes.type !== 'checkbox' &&
attributes.type !== 'submit' &&
attributes.type !== 'button' &&
value
) {
if (attributes.type !== 'submit' && attributes.type !== 'button' && value) {
attributes.value = maskInputValue({
type: attributes.type,
tagName,
tagName: tagName.toUpperCase() as Uppercase<typeof tagName>,
value,
maskInputOptions,
maskInputFn,
});
} else if (checked) {
}
if (checked) {
attributes.checked = checked;
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/rrweb-snapshot/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ export type MaskInputOptions = Partial<{
textarea: boolean;
select: boolean;
password: boolean;
radio: boolean;
checkbox: boolean;
}>;

export type SlimDOMOptions = Partial<{
Expand Down
28 changes: 27 additions & 1 deletion packages/rrweb-snapshot/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
documentTypeNode,
textNode,
elementNode,
attributes,
} from './types';

export function isElement(n: Node): n is Element {
Expand Down Expand Up @@ -161,12 +162,18 @@ export function maskInputValue({
maskInputFn,
}: {
maskInputOptions: MaskInputOptions;
tagName: string;
tagName: Uppercase<string>;
type: string | number | boolean | null;
value: string | null;
maskInputFn?: MaskInputFn;
}): string {
let text = value || '';

// Handle `option` like `select
if (tagName === 'OPTION') {
tagName = 'SELECT';
}

if (
maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] ||
maskInputOptions[type as keyof MaskInputOptions]
Expand Down Expand Up @@ -246,3 +253,22 @@ export function isNodeMetaEqual(a: serializedNode, b: serializedNode): boolean {
);
return false;
}

export function getInputValue(
el:
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
| HTMLOptionElement,
tagName: Uppercase<string>,
type: attributes[string],
): string {
const normalizedType = typeof type === 'string' ? type.toLowerCase() : type;
if (tagName === 'INPUT' && (normalizedType === 'radio' || normalizedType === 'checkbox')) {
// checkboxes & radio buttons return `on` as their el.value when no value is specified
// we only want to get the value if it is specified as `value='xxx'`
return el.getAttribute('value') || '';
}

return el.value;
}
2 changes: 2 additions & 0 deletions packages/rrweb/src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ function record<T = eventWithTime>(
textarea: true,
select: true,
password: true,
radio: true,
checkbox: true,
}
: _maskInputOptions !== undefined
? _maskInputOptions
Expand Down
5 changes: 3 additions & 2 deletions packages/rrweb/src/record/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,15 +484,16 @@ export default class MutationBuffer {
}
break;
}

case 'attributes': {
const target = m.target as HTMLElement;
let attributeName = m.attributeName as string;
let value = (m.target as HTMLElement).getAttribute(attributeName);
if (attributeName === 'value') {
value = maskInputValue({
maskInputOptions: this.maskInputOptions,
tagName: (m.target as HTMLElement).tagName,
type: (m.target as HTMLElement).getAttribute('type'),
tagName: target.tagName as unknown as Uppercase<string>,
type: target.getAttribute('type'),
value,
maskInputFn: this.maskInputFn,
});
Expand Down
50 changes: 36 additions & 14 deletions packages/rrweb/src/record/observer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { MaskInputOptions, maskInputValue, Mirror } from 'rrweb-snapshot';
import {
MaskInputOptions,
maskInputValue,
Mirror,
getInputValue,
} from 'rrweb-snapshot';
import type { FontFaceSet } from 'css-font-loading-module';
import {
throttle,
Expand Down Expand Up @@ -340,37 +345,47 @@ function initInputObserver({
function eventHandler(event: Event) {
let target = getEventTarget(event);
const userTriggered = event.isTrusted;
const tagName =
target &&
((
target as Element
).tagName.toUpperCase() as unknown as Uppercase<string>);
/**
* 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.
* We can treat this change as a value change of the select element the current target belongs to.
*/
if (target && (target as Element).tagName === 'OPTION')
target = (target as Element).parentElement;
if (tagName === 'OPTION') target = (target as Element).parentElement;
if (
!target ||
!(target as Element).tagName ||
INPUT_TAGS.indexOf((target as Element).tagName) < 0 ||
!tagName ||
INPUT_TAGS.indexOf(tagName) < 0 ||
isBlocked(target as Node, blockClass, blockSelector, true)
) {
return;
}
const type: string | undefined = (target as HTMLInputElement).type;
if ((target as HTMLElement).classList.contains(ignoreClass)) {

const el = target as
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement;

const type: string | undefined = (el as HTMLInputElement).type;
if (el.classList.contains(ignoreClass)) {
return;
}
let text = (target as HTMLInputElement).value;

let text = getInputValue(el, tagName, type);
let isChecked = false;
if (type === 'radio' || type === 'checkbox') {
isChecked = (target as HTMLInputElement).checked;
} else if (
maskInputOptions[
(target as Element).tagName.toLowerCase() as keyof MaskInputOptions
] ||
}
if (
maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] ||
maskInputOptions[type as keyof MaskInputOptions]
) {
text = maskInputValue({
maskInputOptions,
tagName: (target as HTMLElement).tagName,
tagName,
type,
value: text,
maskInputFn,
Expand All @@ -391,11 +406,18 @@ function initInputObserver({
.querySelectorAll(`input[type="radio"][name="${name}"]`)
.forEach((el) => {
if (el !== target) {
const text = maskInputValue({
maskInputOptions,
tagName,
type,
value: getInputValue(el as HTMLInputElement, tagName, type),
maskInputFn,
});
cbWithDedup(
el,
wrapEventWithUserTriggeredFlag(
{
text: (el as HTMLInputElement).value,
text,
isChecked: !isChecked,
userTriggered: false,
},
Expand Down
Loading

0 comments on commit f36b26c

Please sign in to comment.