Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ sentryTest(
expect.arrayContaining([
{
...expectedClickBreadcrumb,
message: 'body > div#error.btn.btn-error[aria-label="An Error in aria-label"]',
message: 'body > div#error.btn.btn-error[aria-label="** ***** ** **********"]',
data: {
nodeId: expect.any(Number),
node: {
Expand All @@ -124,7 +124,7 @@ sentryTest(
expect.arrayContaining([
{
...expectedClickBreadcrumb,
message: 'body > button#img-button[title="Button title"]',
message: 'body > button#img-button[title="****** *****"]',
data: {
nodeId: expect.any(Number),
node: {
Expand Down
31 changes: 20 additions & 11 deletions packages/browser-utils/src/htmlTreeAsString.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { isString } from '@sentry/core';

const DEFAULT_MAX_STRING_LENGTH = 80;

type MaskAttributeFn = (key: string, value: string, el: HTMLElement) => string;

type SimpleNode = {
parentNode: SimpleNode;
} | null;
Expand Down Expand Up @@ -54,7 +56,7 @@ function _safeRead<T>(el: unknown, prop: AccessorKey, arg?: string): T {
*/
export function htmlTreeAsString(
elem: unknown,
options: string[] | { keyAttrs?: string[]; maxStringLength?: number } = {},
options: string[] | { keyAttrs?: string[]; maxStringLength?: number; maskAttributeFn?: MaskAttributeFn } = {},
): string {
if (!elem) {
return '<unknown>';
Expand All @@ -74,10 +76,11 @@ export function htmlTreeAsString(
const sepLength = separator.length;
let nextStr;
const keyAttrs = Array.isArray(options) ? options : options.keyAttrs;
const maskAttributeFn = Array.isArray(options) ? undefined : options.maskAttributeFn;
const maxStringLength = (!Array.isArray(options) && options.maxStringLength) || DEFAULT_MAX_STRING_LENGTH;

while (currentElem && height++ < MAX_TRAVERSE_HEIGHT) {
nextStr = _htmlElementAsString(currentElem, keyAttrs);
nextStr = _htmlElementAsString(currentElem, keyAttrs, maskAttributeFn);
// bail out if
// - nextStr is the 'html' element
// - the length of the string that would be created exceeds maxStringLength
Expand All @@ -103,7 +106,7 @@ export function htmlTreeAsString(
* e.g. [HTMLElement] => input#foo.btn[name=baz]
* @returns generated DOM path
*/
function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string {
function _htmlElementAsString(el: unknown, keyAttrs?: string[], maskAttributeFn?: MaskAttributeFn): string {
const out = [];

const tagName = _safeRead<string | undefined>(el, 'tagName');
Expand All @@ -117,10 +120,14 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string {
const dataset = _safeRead<DOMStringMap | undefined>(el, 'dataset');
if (dataset) {
if (dataset['sentryComponent']) {
return dataset['sentryComponent'];
return maskAttributeFn
? maskAttributeFn('data-sentry-component', dataset['sentryComponent'], el)
: dataset['sentryComponent'];
}
if (dataset['sentryElement']) {
return dataset['sentryElement'];
return maskAttributeFn
? maskAttributeFn('data-sentry-element', dataset['sentryElement'], el)
: dataset['sentryElement'];
}
}
}
Expand All @@ -132,22 +139,24 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string {
const keyAttrPairs = keyAttrs?.length
? keyAttrs
.filter(keyAttr => _safeRead<string | null>(el, 'getAttribute', keyAttr))
.map(keyAttr => [keyAttr, _safeRead<string | null>(el, 'getAttribute', keyAttr)])
.map(keyAttr => [keyAttr, _safeRead<string | null>(el, 'getAttribute', keyAttr)] as const)
: null;

if (keyAttrPairs?.length) {
keyAttrPairs.forEach(keyAttrPair => {
out.push(`[${keyAttrPair[0]}="${keyAttrPair[1]}"]`);
keyAttrPairs.forEach(([key, value]) => {
out.push(`[${key}="${maskAttributeFn && value ? maskAttributeFn(key, value, el as HTMLElement) : value}"]`);
});
} else {
const id = _safeRead<string | undefined>(el, 'id');
if (id) {
out.push(`#${id}`);
out.push(`#${maskAttributeFn ? maskAttributeFn('id', id, el as HTMLElement) : id}`);
}

const className = _safeRead<string | undefined>(el, 'className');
if (className && isString(className)) {
const classes = className.split(/\s+/);
const classes = (maskAttributeFn ? maskAttributeFn('class', className, el as HTMLElement) : className).split(
/\s+/,
);
for (const c of classes) {
out.push(`.${c}`);
}
Expand All @@ -156,7 +165,7 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string {
for (const k of ['aria-label', 'type', 'name', 'title', 'alt']) {
const attr = _safeRead<string | null>(el, 'getAttribute', k);
if (attr) {
out.push(`[${k}="${attr}"]`);
out.push(`[${k}="${maskAttributeFn ? maskAttributeFn(k, attr, el as HTMLElement) : attr}"]`);
}
}

Expand Down
40 changes: 40 additions & 0 deletions packages/browser-utils/test/htmlTreeAsString.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,46 @@ import { describe, expect, it } from 'vitest';
import { htmlTreeAsString } from '../src/htmlTreeAsString';

describe('htmlTreeAsString', () => {
it('masks attributes on the target and its ancestors', () => {
const parent = document.createElement('div');
parent.title = 'Account PIN';
const target = document.createElement('button');
target.setAttribute('aria-label', 'Pay Alice');
target.setAttribute('type', 'button');
parent.appendChild(target);

const result = htmlTreeAsString(target, {
maskAttributeFn: (key, value) => (['title', 'aria-label'].includes(key) ? '[masked]' : value),
});

expect(result).toBe('div[title="[masked]"] > button[aria-label="[masked]"][type="button"]');
});

it('masks custom key attributes', () => {
const target = document.createElement('button');
target.setAttribute('data-account', 'alice@example.com');

const result = htmlTreeAsString(target, {
keyAttrs: ['data-account'],
maskAttributeFn: (_key, _value, el) => (el === target ? '[masked]' : '[unexpected element]'),
});

expect(result).toBe('button[data-account="[masked]"]');
});

it('returns unknown when attribute masking throws', () => {
const target = document.createElement('button');
target.title = 'Account PIN';

const result = htmlTreeAsString(target, {
maskAttributeFn: () => {
throw new Error('Invalid masking selector');
},
});

expect(result).toBe('<unknown>');
});

it('returns a query-selector-like string for a single element', () => {
const el = document.createElement('button');
el.id = 'submit';
Expand Down
18 changes: 12 additions & 6 deletions packages/replay-internal/src/coreHandlers/handleDom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Breadcrumb } from '@sentry/core';
import { record } from '@sentry/rrweb';
import type { serializedElementNodeWithId, serializedNodeWithId } from '@sentry/rrweb-snapshot';
import { NodeType } from '@sentry/rrweb-snapshot';
import type { ReplayContainer } from '../types';
import type { RecordingOptions, ReplayContainer } from '../types';
import { createBreadcrumb } from '../util/createBreadcrumb';
import { handleClick } from './handleClick';
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
Expand All @@ -19,7 +19,7 @@ export const handleDomListener: (replay: ReplayContainer) => (handlerData: Handl
return;
}

const result = handleDom(handlerData);
const result = handleDom(handlerData, replay.getMaskAttributeFn());

if (!result) {
return;
Expand Down Expand Up @@ -75,16 +75,22 @@ export function getBaseDomBreadcrumb(target: Node | null, message: string): Brea
* An event handler to react to DOM events.
* Exported for tests.
*/
export function handleDom(handlerData: HandlerDataDom): Breadcrumb | null {
const { target, message } = getDomTarget(handlerData);
export function handleDom(
handlerData: HandlerDataDom,
maskAttributeFn?: RecordingOptions['maskAttributeFn'],
): Breadcrumb | null {
const { target, message } = getDomTarget(handlerData, maskAttributeFn);

return createBreadcrumb({
category: `ui.${handlerData.name}`,
...getBaseDomBreadcrumb(target, message),
});
}

function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; message: string } {
function getDomTarget(
handlerData: HandlerDataDom,
maskAttributeFn?: RecordingOptions['maskAttributeFn'],
): { target: Node | null; message: string } {
const isClick = handlerData.name === 'click';

let message: string | undefined;
Expand All @@ -93,7 +99,7 @@ function getDomTarget(handlerData: HandlerDataDom): { target: Node | null; messa
// Accessing event.target can throw (see getsentry/raven-js#838, #768)
try {
target = isClick ? getClickTargetNode(handlerData.event as Event) : getTargetNode(handlerData.event as Event);
message = htmlTreeAsString(target, { maxStringLength: 200 }) || '<unknown>';
message = htmlTreeAsString(target, { maxStringLength: 200, maskAttributeFn }) || '<unknown>';
} catch {
message = '<unknown>';
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { htmlTreeAsString } from '@sentry/browser-utils';
import type { Breadcrumb } from '@sentry/core';
import type { ReplayContainer } from '../types';
import type { RecordingOptions, ReplayContainer } from '../types';
import { createBreadcrumb } from '../util/createBreadcrumb';
import { getBaseDomBreadcrumb } from './handleDom';
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
Expand All @@ -16,7 +16,7 @@ export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEven
// session with a single "keydown" breadcrumb is created)
replay.updateUserActivity();

const breadcrumb = getKeyboardBreadcrumb(event);
const breadcrumb = getKeyboardBreadcrumb(event, replay.getMaskAttributeFn());

if (!breadcrumb) {
return;
Expand All @@ -26,7 +26,10 @@ export function handleKeyboardEvent(replay: ReplayContainer, event: KeyboardEven
}

/** exported only for tests */
export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null {
export function getKeyboardBreadcrumb(
event: KeyboardEvent,
maskAttributeFn?: RecordingOptions['maskAttributeFn'],
): Breadcrumb | null {
const { metaKey, shiftKey, ctrlKey, altKey, key, target } = event;

// never capture for input fields
Expand All @@ -44,7 +47,7 @@ export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null {
return null;
}

const message = htmlTreeAsString(target, { maxStringLength: 200 }) || '<unknown>';
const message = htmlTreeAsString(target, { maxStringLength: 200, maskAttributeFn }) || '<unknown>';
const baseBreadcrumb = getBaseDomBreadcrumb(target as Node, message);

return createBreadcrumb({
Expand Down
4 changes: 4 additions & 0 deletions packages/replay-internal/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ export class ReplayContainer implements ReplayContainerInterface {
return this._options;
}

public getMaskAttributeFn(): RecordingOptions['maskAttributeFn'] {
return this._recordingOptions.maskAttributeFn;
}

/** A wrapper to conditionally capture exceptions. */
public handleException(error: unknown): void {
DEBUG_BUILD && debug.exception(error);
Expand Down
1 change: 1 addition & 0 deletions packages/replay-internal/src/types/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ export interface ReplayContainer {
updateUserActivity(): void;
addUpdate(cb: AddUpdateCallback): void;
getOptions(): ReplayPluginOptions;
getMaskAttributeFn(): RecordingOptions['maskAttributeFn'];
getSessionId(): string | undefined;
checkAndHandleExpiredSession(): boolean | void;
setInitialState(): void;
Expand Down
1 change: 1 addition & 0 deletions packages/replay-internal/src/types/rrweb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export type ReplayEventWithTime = {
export type RrwebRecordOptions = {
maskAllText?: boolean;
maskAllInputs?: boolean;
maskAttributeFn?: (key: string, value: string, el: HTMLElement) => string;
blockClass?: ClassOption;
ignoreClass?: string;
maskTextClass?: ClassOption;
Expand Down
Loading
Loading