Skip to content

Commit 97bf326

Browse files
authored
fix: Session Replay text masking for whitespace (#1416)
1 parent 6699a5e commit 97bf326

File tree

4 files changed

+73
-11
lines changed

4 files changed

+73
-11
lines changed

src/features/session_replay/shared/recorder.js

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { stylesheetEvaluator } from './stylesheet-evaluator'
1111
import { handle } from '../../../common/event-emitter/handle'
1212
import { SUPPORTABILITY_METRIC_CHANNEL } from '../../metrics/constants'
1313
import { FEATURE_NAMES } from '../../../loaders/features/features'
14-
import { buildNRMetaNode } from './utils'
14+
import { buildNRMetaNode, customMasker } from './utils'
1515
import { IDEAL_PAYLOAD_SIZE } from '../../../common/constants/agent-constants'
1616
import { AggregateBase } from '../../utils/aggregate-base'
1717

@@ -78,14 +78,7 @@ export class Recorder {
7878
startRecording () {
7979
this.recording = true
8080
const { block_class, ignore_class, mask_text_class, block_selector, mask_input_options, mask_text_selector, mask_all_inputs, inline_images, collect_fonts } = this.parent.agentRef.init.session_replay
81-
const customMasker = (text, element) => {
82-
try {
83-
if (typeof element?.type === 'string' && element.type.toLowerCase() !== 'password' && (element?.dataset?.nrUnmask !== undefined || element?.classList?.contains('nr-unmask'))) return text
84-
} catch (err) {
85-
// likely an element was passed to this handler that was invalid and was missing attributes or methods
86-
}
87-
return '*'.repeat(text?.length || 0)
88-
}
81+
8982
// set up rrweb configurations for maximum privacy --
9083
// https://newrelic.atlassian.net/wiki/spaces/O11Y/pages/2792293280/2023+02+28+Browser+-+Session+Replay#Configuration-options
9184
const stop = recorder({

src/features/session_replay/shared/utils.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,15 @@ export function buildNRMetaNode (timestamp, timeKeeper) {
2828
originTimeDiff: Math.floor(originTime - timeKeeper.correctedOriginTime)
2929
}
3030
}
31+
32+
export function customMasker (text, element) {
33+
try {
34+
if (typeof element?.type === 'string') {
35+
if (element.type.toLowerCase() === 'password') return '*'.repeat(text?.length || 0)
36+
if (element?.dataset?.nrUnmask !== undefined || element?.classList?.contains('nr-unmask')) return text
37+
}
38+
} catch (err) {
39+
// likely an element was passed to this handler that was invalid and was missing attributes or methods
40+
}
41+
return typeof text === 'string' ? text.replace(/[\S]/g, '*') : '*'.repeat(text?.length || 0)
42+
}

tests/specs/session-replay/rrweb-configuration.e2e.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ describe('RRWeb Configuration', () => {
8484
})
8585

8686
describe('mask_text_selector', () => {
87-
it('mask_text_selector: "*" should convert all text to *', async () => {
87+
it('mask_text_selector: "*" should convert text to * and leave whitespace-only text as-is', async () => {
8888
await Promise.all([
8989
sessionReplaysCapture.waitForResult({ totalCount: 1 }),
9090
browser.url(await browser.testHandle.assetURL('rrweb-instrumented.html', srConfig({ session_replay: { mask_all_inputs: false } })))
@@ -95,7 +95,15 @@ describe('RRWeb Configuration', () => {
9595
expect(sessionReplaysHarvests.length).toBeGreaterThan(1)
9696
expect(decodeAttributes(sessionReplaysHarvests[0].request.query.attributes).hasSnapshot).toEqual(true)
9797

98-
const testNodes = JSONPath({ path: '$.[*].request.body.[?(!!@ && @.type===3 && !!@.textContent && ![\'script\',\'link\',\'style\'].includes(@parent.tagName))]', json: sessionReplaysHarvests })
98+
// note: we need to leave text nodes containing only whitespace as-is to avoid adding extraneous '*'
99+
const whitespaceOnlyNodes = JSONPath({ path: '$.[*].request.body.[?(!!@ && @.type===3 && !!@.textContent && @.textContent.match(/^\\s+$/) && ![\'script\',\'link\',\'style\'].includes(@parent.tagName))]', json: sessionReplaysHarvests })
100+
expect(whitespaceOnlyNodes.length).toBeGreaterThan(0)
101+
102+
whitespaceOnlyNodes.forEach(node => {
103+
expect(node.textContent).toMatch(/\s+/)
104+
})
105+
106+
const testNodes = JSONPath({ path: '$.[*].request.body.[?(!!@ && @.type===3 && !!@.textContent && @.textContent.match(/\\S+/) && ![\'script\',\'link\',\'style\'].includes(@parent.tagName))]', json: sessionReplaysHarvests })
99107
expect(testNodes.length).toBeGreaterThan(0)
100108

101109
testNodes.forEach(node => {

tests/unit/features/session_replay/shared/utils.test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,52 @@ describe('buildNRMetaNode', () => {
9898
expect(metadata.correctedTimestamp).toEqual(expected)
9999
})
100100
})
101+
102+
describe('customMasker', () => {
103+
test('should return input text as masked when the element is a non-password field and not decorated with unmask option', async () => {
104+
const text = 'foobar'
105+
const element = { type: 'string' }
106+
107+
expect(sessionReplaySharedUtils.customMasker(text, element)).toEqual('******')
108+
})
109+
110+
// NR-739491 - fixes issue where whitespace was masked, pushing layout/styling out-of-place
111+
test('should return input text as-is when the input text is whitespace and the element is a non-password field and not decorated with unmask option', async () => {
112+
const text = ' \n '
113+
const element = { type: 'string' }
114+
115+
expect(sessionReplaySharedUtils.customMasker(text, element)).toEqual(' \n ')
116+
})
117+
118+
test('should return input text as masked when the element is a password field', async () => {
119+
const text = 'foobar'
120+
const element = { type: 'password' }
121+
122+
expect(sessionReplaySharedUtils.customMasker(text, element)).toEqual('******')
123+
})
124+
125+
test('should return input text as masked when the input text is whitespace and the element is a password field', async () => {
126+
const text = ' \n '
127+
const element = { type: 'password' }
128+
129+
expect(sessionReplaySharedUtils.customMasker(text, element)).toEqual('*******')
130+
})
131+
132+
test('should return input text as-is when the element is a non-password field decorated with unmask option', async () => {
133+
const text = 'foobar'
134+
const element = { type: 'string', dataset: { nrUnmask: true } }
135+
expect(sessionReplaySharedUtils.customMasker(text, element)).toEqual('foobar')
136+
137+
const element2 = { type: 'string', classList: { contains: () => true } }
138+
expect(sessionReplaySharedUtils.customMasker(text, element2)).toEqual('foobar')
139+
})
140+
141+
test('should return input text as-is when input text is whitespace and the element is a non-password field decorated with unmask option', async () => {
142+
const text = ' \n '
143+
const element = { type: 'string', dataset: { nrUnmask: true } }
144+
expect(sessionReplaySharedUtils.customMasker(text, element)).toEqual(' \n ')
145+
146+
const element2 = { type: 'string', classList: { contains: () => true } }
147+
expect(sessionReplaySharedUtils.customMasker(text, element2)).toEqual(' \n ')
148+
})
149+
})

0 commit comments

Comments
 (0)