diff --git a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts index a83f6e9fa5ce..8a9c0046f5b4 100644 --- a/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/customEvents/test.ts @@ -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: { @@ -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: { diff --git a/packages/browser-utils/src/htmlTreeAsString.ts b/packages/browser-utils/src/htmlTreeAsString.ts index 1fd7f75590bf..46dcc493a431 100644 --- a/packages/browser-utils/src/htmlTreeAsString.ts +++ b/packages/browser-utils/src/htmlTreeAsString.ts @@ -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; @@ -54,7 +56,7 @@ function _safeRead(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 ''; @@ -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 @@ -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(el, 'tagName'); @@ -117,10 +120,14 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { const dataset = _safeRead(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']; } } } @@ -132,22 +139,24 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { const keyAttrPairs = keyAttrs?.length ? keyAttrs .filter(keyAttr => _safeRead(el, 'getAttribute', keyAttr)) - .map(keyAttr => [keyAttr, _safeRead(el, 'getAttribute', keyAttr)]) + .map(keyAttr => [keyAttr, _safeRead(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(el, 'id'); if (id) { - out.push(`#${id}`); + out.push(`#${maskAttributeFn ? maskAttributeFn('id', id, el as HTMLElement) : id}`); } const className = _safeRead(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}`); } @@ -156,7 +165,7 @@ function _htmlElementAsString(el: unknown, keyAttrs?: string[]): string { for (const k of ['aria-label', 'type', 'name', 'title', 'alt']) { const attr = _safeRead(el, 'getAttribute', k); if (attr) { - out.push(`[${k}="${attr}"]`); + out.push(`[${k}="${maskAttributeFn ? maskAttributeFn(k, attr, el as HTMLElement) : attr}"]`); } } diff --git a/packages/browser-utils/test/htmlTreeAsString.test.ts b/packages/browser-utils/test/htmlTreeAsString.test.ts index 86db19c4143a..02b2cc9090dc 100644 --- a/packages/browser-utils/test/htmlTreeAsString.test.ts +++ b/packages/browser-utils/test/htmlTreeAsString.test.ts @@ -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(''); + }); + it('returns a query-selector-like string for a single element', () => { const el = document.createElement('button'); el.id = 'submit'; diff --git a/packages/replay-internal/src/coreHandlers/handleDom.ts b/packages/replay-internal/src/coreHandlers/handleDom.ts index 4c3171aa3276..468080132715 100644 --- a/packages/replay-internal/src/coreHandlers/handleDom.ts +++ b/packages/replay-internal/src/coreHandlers/handleDom.ts @@ -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'; @@ -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; @@ -75,8 +75,11 @@ 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}`, @@ -84,7 +87,10 @@ export function handleDom(handlerData: HandlerDataDom): Breadcrumb | null { }); } -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; @@ -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 }) || ''; + message = htmlTreeAsString(target, { maxStringLength: 200, maskAttributeFn }) || ''; } catch { message = ''; } diff --git a/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts b/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts index aba74d6c8abc..4fc1d3553089 100644 --- a/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleKeyboardEvent.ts @@ -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'; @@ -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; @@ -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 @@ -44,7 +47,7 @@ export function getKeyboardBreadcrumb(event: KeyboardEvent): Breadcrumb | null { return null; } - const message = htmlTreeAsString(target, { maxStringLength: 200 }) || ''; + const message = htmlTreeAsString(target, { maxStringLength: 200, maskAttributeFn }) || ''; const baseBreadcrumb = getBaseDomBreadcrumb(target as Node, message); return createBreadcrumb({ diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 6d6ec213b653..b02133abdd0d 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -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); diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 955393c2f225..f3a275ad95aa 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -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; diff --git a/packages/replay-internal/src/types/rrweb.ts b/packages/replay-internal/src/types/rrweb.ts index c6ca1333918d..caacbb515146 100644 --- a/packages/replay-internal/src/types/rrweb.ts +++ b/packages/replay-internal/src/types/rrweb.ts @@ -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; diff --git a/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts b/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts new file mode 100644 index 000000000000..c282e6f2ce7c --- /dev/null +++ b/packages/replay-internal/test/integration/breadcrumbAttributeMasking.test.ts @@ -0,0 +1,158 @@ +/** + * @vitest-environment jsdom + */ + +import '../utils/mock-internal-setTimeout'; +import type { Breadcrumb } from '@sentry/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Replay } from '../../src/integration'; +import type { ReplayConfiguration } from '../../src/types'; +import { resetSdkMock } from '../mocks/resetSdkMock'; + +describe('Integration | breadcrumb attribute masking', () => { + let integration: Replay; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(async () => { + await integration?.stop(); + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + describe.each(['data-sentry-component', 'data-sentry-element'])('%s', attribute => { + it.each([ + { + label: 'configured masking', + options: { maskAttributes: [attribute] }, + expected: '******* *** > *** *****', + }, + { + label: 'explicit unmasking', + options: { maskAttributes: [attribute], unmask: ['.sentry-unmask'] }, + expected: 'Account PIN > Pay Alice', + }, + { + label: 'default masking', + options: {}, + expected: 'Account PIN > Pay Alice', + }, + ])('honors $label for annotated target and ancestor breadcrumbs', async ({ options, expected }) => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + ...options, + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + }, + }); + integration = sdk.integration; + const parent = document.createElement('div'); + parent.className = 'sentry-unmask'; + parent.setAttribute(attribute, 'Account PIN'); + const target = document.createElement('button'); + target.className = 'sentry-unmask'; + target.setAttribute(attribute, 'Pay Alice'); + parent.appendChild(target); + const event = new Event('click'); + target.dispatchEvent(event); + + sdk.domHandler({ name: 'click', event }); + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe(expected); + }); + }); + + it.each(['click', 'keypress', 'keydown'])('masks target and ancestor attributes in %s breadcrumbs', async name => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + }, + }); + integration = sdk.integration; + 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 event = new KeyboardEvent(name, { key: 'Escape' }); + target.dispatchEvent(event); + + if (name === 'keydown') { + sdk.replay['_handleKeyboardEvent'](event); + } else { + sdk.domHandler({ name, event }); + } + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe('div[title="******* ***"] > button[aria-label="*** *****"][type="button"]'); + }); + + it.each([ + { + label: 'custom attributes with text masking disabled', + options: { maskAttributes: ['alt', 'name', 'id', 'class'], maskAllText: false }, + expected: 'button#******.*******[aria-label="Pay Alice"][name="*******"][title="Account PIN"][alt="*****"]', + }, + { + label: 'explicit unmasking', + options: { unmask: ['.sentry-unmask'] }, + expected: + 'button#secret.private.sentry-unmask[aria-label="Pay Alice"][name="account"][title="Account PIN"][alt="Alice"]', + }, + { + label: 'an empty attribute mask list', + options: { maskAttributes: [] }, + expected: 'button#secret.private[aria-label="Pay Alice"][name="account"][title="Account PIN"][alt="Alice"]', + }, + ])('honors $label in click breadcrumbs', async ({ options, expected }) => { + const breadcrumbs: Breadcrumb[] = []; + const sdk = await resetSdkMock({ + replayOptions: { + ...options, + beforeAddRecordingEvent: event => { + if (event.data.tag === 'breadcrumb') { + breadcrumbs.push(event.data.payload); + } + return event; + }, + } satisfies ReplayConfiguration, + }); + integration = sdk.integration; + const target = document.createElement('button'); + target.id = 'secret'; + target.className = 'private'; + target.setAttribute('aria-label', 'Pay Alice'); + target.setAttribute('name', 'account'); + target.title = 'Account PIN'; + target.setAttribute('alt', 'Alice'); + if ('unmask' in options) { + target.classList.add('sentry-unmask'); + } + const event = new Event('click'); + target.dispatchEvent(event); + + sdk.domHandler({ name: 'click', event }); + await vi.runAllTimersAsync(); + + expect(breadcrumbs).toHaveLength(1); + expect(breadcrumbs[0]?.message).toBe(expected); + }); +});