Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Better masking of option/radio/checkbox values #1164

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -21,7 +21,9 @@ import {
isNativeShadowDom,
getCssRulesString,
getInputType,
getInputValue,
toLowerCase,
toUpperCase,
validateStringifiedCssRule,
} from './utils';

Expand Down Expand Up @@ -514,6 +516,8 @@ function serializeNode(
maskTextClass,
maskTextSelector,
maskTextFn,
maskInputOptions,
maskInputFn,
rootId,
});
case n.CDATA_SECTION_NODE:
Expand Down Expand Up @@ -545,10 +549,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 @@ -590,6 +603,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 @@ -677,26 +702,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 @@ -174,17 +174,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 @@ -199,6 +203,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 @@ -283,3 +291,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 @@ -431,15 +433,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 @@ -449,18 +448,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 @@ -486,11 +492,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