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 Jul 7, 2023
1 parent 46df5cd commit 9aa9fbf
Show file tree
Hide file tree
Showing 11 changed files with 1,168 additions and 518 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
61 changes: 46 additions & 15 deletions packages/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import {
maskInputValue,
isNativeShadowDom,
getCssRulesString,
getInputType,
getInputValue,
toLowerCase,
toUpperCase,
getInputType,
} from './utils';

let _id = 1;
Expand Down Expand Up @@ -511,6 +513,8 @@ function serializeNode(
maskTextClass,
maskTextSelector,
maskTextFn,
maskInputOptions,
maskInputFn,
rootId,
});
case n.CDATA_SECTION_NODE:
Expand Down Expand Up @@ -542,10 +546,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 @@ -587,6 +600,18 @@ function serializeTextNode(
: textContent.replace(/[\S]/g, '*');
}

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

return {
type: NodeType.Text,
textContent: textContent || '',
Expand Down Expand Up @@ -674,26 +699,32 @@ 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 type = getInputType(el);
const value = getInputValue(el, toUpperCase(tagName), type);
const checked = (n as HTMLInputElement).checked;
if (
attributes.type !== 'radio' &&
attributes.type !== 'checkbox' &&
attributes.type !== 'submit' &&
attributes.type !== 'button' &&
value
) {
const type = getInputType(n);
if (type !== 'submit' && type !== 'button' && value) {
attributes.value = maskInputValue({
element: n,
element: el,
type,
tagName,
tagName: toUpperCase(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
34 changes: 30 additions & 4 deletions packages/rrweb-snapshot/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,17 +163,21 @@ export function maskInputValue({
}: {
element: HTMLElement;
maskInputOptions: MaskInputOptions;
tagName: string;
type: string | null;
tagName: Uppercase<string>;
type: Lowercase<string> | null;
value: string | null;
maskInputFn?: MaskInputFn;
}): string {
let text = value || '';
const actualType = type && toLowerCase(type);

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

if (
maskInputOptions[tagName.toLowerCase() as keyof MaskInputOptions] ||
(actualType && maskInputOptions[actualType as keyof MaskInputOptions])
(type && maskInputOptions[type as keyof MaskInputOptions])
) {
if (maskInputFn) {
text = maskInputFn(text, element);
Expand All @@ -188,6 +192,10 @@ export function toLowerCase<T extends string>(str: T): Lowercase<T> {
return str.toLowerCase() as unknown as Lowercase<T>;
}

export function toUpperCase<T extends string>(str: T): Uppercase<T> {
return str.toUpperCase() as unknown as Uppercase<T>;
}

const ORIGINAL_ATTRIBUTE_NAME = '__rrweb_original__';
type PatchedGetImageData = {
[ORIGINAL_ATTRIBUTE_NAME]: CanvasImageData['getImageData'];
Expand Down Expand Up @@ -272,3 +280,21 @@ export function getInputType(element: HTMLElement): Lowercase<string> | null {
toLowerCase(type)
: null;
}

export function getInputValue(
el:
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
| HTMLOptionElement,
tagName: Uppercase<string>,
type: Lowercase<string> | null,
): string {
if (tagName === 'INPUT' && (type === 'radio' || type === '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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,12 @@ exports[`integration tests [html file]: picture-in-frame.html 1`] = `
</body></html>"
`;
exports[`integration tests [html file]: picture-with-inline-onload.html 1`] = `
"<html xmlns=\\"http://www.w3.org/1999/xhtml\\"><head></head><body>
<img src=\\"http://localhost:3030/images/robot.png\\" alt=\\"This is a robot\\" style=\\"opacity: 1;\\" _onload=\\"this.style.opacity=1\\" />
</body></html>"
`;

exports[`integration tests [html file]: preload.html 1`] = `
"<!DOCTYPE html><html lang=\\"en\\"><head>
<meta charset=\\"UTF-8\\" />
Expand Down
2 changes: 2 additions & 0 deletions packages/rrweb/src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ function record<T = eventWithTime>(
textarea: true,
select: true,
password: true,
radio: true,
checkbox: true,
}
: _maskInputOptions !== undefined
? _maskInputOptions
Expand Down
6 changes: 5 additions & 1 deletion packages/rrweb/src/record/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
isNativeShadowDom,
getInputType,
toLowerCase,
getInputValue,
} from 'rrweb-snapshot';
import type { observerParam, MutationBufferParam } from '../types';
import type {
Expand Down Expand Up @@ -504,18 +505,21 @@ 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') {
const type = getInputType(target);
const tagName = target.tagName as unknown as Uppercase<string>;
value = getInputValue(target as HTMLInputElement, tagName, type);

value = maskInputValue({
element: target,
maskInputOptions: this.maskInputOptions,
tagName: target.tagName,
tagName,
type,
value,
maskInputFn: this.maskInputFn,
Expand Down
36 changes: 25 additions & 11 deletions packages/rrweb/src/record/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {
maskInputValue,
Mirror,
getInputType,
getInputValue,
toLowerCase,
toUpperCase,
} from 'rrweb-snapshot';
import type { FontFaceSet } from 'css-font-loading-module';
import {
Expand Down Expand Up @@ -429,15 +431,12 @@ function initInputObserver({
function eventHandler(event: Event) {
let target = getEventTarget(event) as HTMLElement | null;
const userTriggered = event.isTrusted;
const tagName = target && target.tagName;

const tagName = target && toUpperCase((target as Element).tagName);
/**
* 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 && tagName === 'OPTION') {
target = target.parentElement;
}
if (tagName === 'OPTION') target = (target as Element).parentElement;
if (
!target ||
!tagName ||
Expand All @@ -447,18 +446,25 @@ function initInputObserver({
return;
}

if (target.classList.contains(ignoreClass)) {
const el = target as
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement;

if (el.classList.contains(ignoreClass)) {
return;
}
let text = (target as HTMLInputElement).value;

const type = getInputType(target);
let text = getInputValue(el, tagName, type);
let isChecked = false;
const type: Lowercase<string> = getInputType(target) || '';

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

0 comments on commit 9aa9fbf

Please sign in to comment.