Skip to content

Commit 223ec43

Browse files
committed
Add attrUnmaskAllowlist config option
Signed-off-by: andrewatwood <sandurz@gmail.com>
1 parent a7154bb commit 223ec43

File tree

7 files changed

+88
-2
lines changed

7 files changed

+88
-2
lines changed

packages/core/src/domain/telemetry/telemetryEvent.types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,10 @@ export type TelemetryConfigurationEvent = CommonTelemetryProperties & {
249249
* Whether the request origins list to ignore when computing the page activity is used
250250
*/
251251
use_excluded_activity_urls?: boolean
252+
/**
253+
* Whether the attribute unmask allowlist is used for Session Replay
254+
*/
255+
use_attr_unmask_allowlist?: boolean
252256
/**
253257
* Whether the Worker is loaded from an external URL
254258
*/

packages/rum-core/src/domain/configuration/configuration.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,7 @@ describe('serializeRumConfiguration', () => {
613613
traceSampleRate: 50,
614614
traceContextInjection: TraceContextInjection.ALL,
615615
defaultPrivacyLevel: 'allow',
616+
attrUnmaskAllowlist: [],
616617
enablePrivacyForActionName: false,
617618
subdomain: 'foo',
618619
sessionReplaySampleRate: 60,
@@ -639,6 +640,7 @@ describe('serializeRumConfiguration', () => {
639640
| 'workerUrl'
640641
| 'allowedTracingUrls'
641642
| 'excludedActivityUrls'
643+
| 'attrUnmaskAllowlist'
642644
| 'remoteConfigurationProxy'
643645
| 'allowedGraphQlUrls'
644646
? `use_${CamelToSnakeCase<Key>}`
@@ -675,6 +677,7 @@ describe('serializeRumConfiguration', () => {
675677
start_session_replay_recording_manually: true,
676678
action_name_attribute: 'test-id',
677679
default_privacy_level: 'allow',
680+
use_attr_unmask_allowlist: false,
678681
enable_privacy_for_action_name: false,
679682
track_resources: true,
680683
track_long_task: true,

packages/rum-core/src/domain/configuration/configuration.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,15 @@ export interface RumInitConfiguration extends InitConfiguration {
152152
*/
153153
defaultPrivacyLevel?: DefaultPrivacyLevel | undefined
154154

155+
/**
156+
* List of attribute names that should not be masked when privacy level is mask or mask-unless-allowlisted.
157+
* Attributes in this list will be recorded with their original values in Session Replay.
158+
*
159+
* @category Privacy
160+
* @defaultValue []
161+
*/
162+
attrUnmaskAllowlist?: string[] | undefined
163+
155164
/**
156165
* If you are accessing Datadog through a custom subdomain, you can set `subdomain` to include your custom domain in the `getSessionReplayLink()` returned URL .
157166
*
@@ -305,6 +314,7 @@ export interface RumConfiguration extends Configuration {
305314
compressIntakeRequests: boolean
306315
applicationId: string
307316
defaultPrivacyLevel: DefaultPrivacyLevel
317+
attrUnmaskAllowlist: string[]
308318
enablePrivacyForActionName: boolean
309319
sessionReplaySampleRate: number
310320
startSessionReplayRecordingManually: boolean
@@ -351,6 +361,10 @@ export function validateAndBuildRumConfiguration(
351361
return
352362
}
353363

364+
if (initConfiguration.attrUnmaskAllowlist !== undefined && !Array.isArray(initConfiguration.attrUnmaskAllowlist)) {
365+
display.warn('attrUnmaskAllowlist should be an array')
366+
}
367+
354368
const allowedTracingUrls = validateAndBuildTracingOptions(initConfiguration)
355369
if (!allowedTracingUrls) {
356370
return
@@ -391,6 +405,7 @@ export function validateAndBuildRumConfiguration(
391405
defaultPrivacyLevel: objectHasValue(DefaultPrivacyLevel, initConfiguration.defaultPrivacyLevel)
392406
? initConfiguration.defaultPrivacyLevel
393407
: DefaultPrivacyLevel.MASK,
408+
attrUnmaskAllowlist: Array.isArray(initConfiguration.attrUnmaskAllowlist) ? initConfiguration.attrUnmaskAllowlist : [],
394409
enablePrivacyForActionName: !!initConfiguration.enablePrivacyForActionName,
395410
traceContextInjection: objectHasValue(TraceContextInjection, initConfiguration.traceContextInjection)
396411
? initConfiguration.traceContextInjection
@@ -531,6 +546,7 @@ export function serializeRumConfiguration(configuration: RumInitConfiguration) {
531546
selected_tracing_propagators: getSelectedTracingPropagators(configuration),
532547
default_privacy_level: configuration.defaultPrivacyLevel,
533548
enable_privacy_for_action_name: configuration.enablePrivacyForActionName,
549+
use_attr_unmask_allowlist: isNonEmptyArray(configuration.attrUnmaskAllowlist),
534550
use_excluded_activity_urls: isNonEmptyArray(configuration.excludedActivityUrls),
535551
use_worker_url: !!configuration.workerUrl,
536552
compress_intake_requests: configuration.compressIntakeRequests,

packages/rum-core/src/domain/privacy.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import {
1010
reducePrivacyLevel,
1111
getNodePrivacyLevel,
1212
shouldMaskNode,
13+
shouldMaskAttribute,
1314
maskDisallowedTextContent,
1415
} from './privacy'
1516
import { ACTION_NAME_MASK } from './action/actionNameConstants'
17+
import { mockRumConfiguration } from '../../test'
1618

1719
describe('getNodePrivacyLevel', () => {
1820
it('returns the element privacy mode if it has one', () => {
@@ -489,6 +491,58 @@ describe('shouldMaskNode', () => {
489491
})
490492
})
491493

494+
describe('shouldMaskAttribute', () => {
495+
it('does not mask attributes in attrUnmaskAllowlist when privacy level is MASK', () => {
496+
const configuration = mockRumConfiguration({ attrUnmaskAllowlist: ['title', 'data-testid'] })
497+
expect(
498+
shouldMaskAttribute('DIV', 'title', 'some title', NodePrivacyLevel.MASK, configuration)
499+
).toBeFalse()
500+
expect(
501+
shouldMaskAttribute('DIV', 'data-testid', 'my-element', NodePrivacyLevel.MASK, configuration)
502+
).toBeFalse()
503+
})
504+
505+
it('does not mask attributes in attrUnmaskAllowlist when privacy level is MASK_UNLESS_ALLOWLISTED', () => {
506+
const configuration = mockRumConfiguration({ attrUnmaskAllowlist: ['alt', 'placeholder'] })
507+
expect(
508+
shouldMaskAttribute('IMG', 'alt', 'description', NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED, configuration)
509+
).toBeFalse()
510+
expect(
511+
shouldMaskAttribute('INPUT', 'placeholder', 'Enter text', NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED, configuration)
512+
).toBeFalse()
513+
})
514+
515+
it('masks attributes not in allowlist according to normal rules when privacy level is MASK', () => {
516+
const configuration = mockRumConfiguration({ attrUnmaskAllowlist: ['data-allowlisted'] })
517+
expect(shouldMaskAttribute('DIV', 'title', 'secret', NodePrivacyLevel.MASK, configuration)).toBeTrue()
518+
expect(shouldMaskAttribute('A', 'href', 'https://example.com', NodePrivacyLevel.MASK, configuration)).toBeTrue()
519+
expect(
520+
shouldMaskAttribute('DIV', 'data-allowlisted', 'value', NodePrivacyLevel.MASK, configuration)
521+
).toBeFalse()
522+
})
523+
524+
it('preserves default masking behavior when attrUnmaskAllowlist is empty', () => {
525+
const configuration = mockRumConfiguration({ attrUnmaskAllowlist: [] })
526+
expect(shouldMaskAttribute('DIV', 'title', 'secret', NodePrivacyLevel.MASK, configuration)).toBeTrue()
527+
expect(shouldMaskAttribute('IMG', 'alt', 'desc', NodePrivacyLevel.MASK, configuration)).toBeTrue()
528+
expect(shouldMaskAttribute('A', 'href', 'https://x.com', NodePrivacyLevel.MASK, configuration)).toBeTrue()
529+
})
530+
531+
it('does not mask standard sensitive attributes when they are in allowlist', () => {
532+
const configuration = mockRumConfiguration({
533+
attrUnmaskAllowlist: ['title', 'alt', 'placeholder', 'aria-label', 'name', 'href', 'srcdoc', 'src'],
534+
})
535+
expect(shouldMaskAttribute('DIV', 'title', 'visible', NodePrivacyLevel.MASK, configuration)).toBeFalse()
536+
expect(shouldMaskAttribute('IMG', 'alt', 'visible', NodePrivacyLevel.MASK, configuration)).toBeFalse()
537+
expect(shouldMaskAttribute('INPUT', 'placeholder', 'visible', NodePrivacyLevel.MASK, configuration)).toBeFalse()
538+
expect(shouldMaskAttribute('DIV', 'aria-label', 'visible', NodePrivacyLevel.MASK, configuration)).toBeFalse()
539+
expect(shouldMaskAttribute('INPUT', 'name', 'visible', NodePrivacyLevel.MASK, configuration)).toBeFalse()
540+
expect(shouldMaskAttribute('A', 'href', 'https://example.com', NodePrivacyLevel.MASK, configuration)).toBeFalse()
541+
expect(shouldMaskAttribute('IFRAME', 'srcdoc', '<p>html</p>', NodePrivacyLevel.MASK, configuration)).toBeFalse()
542+
expect(shouldMaskAttribute('IMG', 'src', 'https://img.com/x.png', NodePrivacyLevel.MASK, configuration)).toBeFalse()
543+
})
544+
})
545+
492546
const TEST_STRINGS = {
493547
COMPLEX_MIXED: 'test-team-name:💥$$$',
494548
PARAGRAPH_MIXED: '✅ This is an action name in allowlist',

packages/rum-core/src/domain/privacy.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ export function shouldMaskAttribute(
163163
if (nodePrivacyLevel !== NodePrivacyLevel.MASK && nodePrivacyLevel !== NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED) {
164164
return false
165165
}
166+
167+
const attrUnmaskAllowlist: string[] = configuration.attrUnmaskAllowlist ?? []
168+
if (attrUnmaskAllowlist.includes(attributeName)) {
169+
return false
170+
}
171+
166172
if (
167173
attributeName === PRIVACY_ATTR_NAME ||
168174
STABLE_ATTRIBUTES.includes(attributeName) ||

packages/rum/src/domain/record/serialization/serializeAttribute.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from '@datadog/browser-rum-core'
88
import { serializeAttribute, MAX_ATTRIBUTE_VALUE_CHAR_LENGTH } from './serializeAttribute'
99

10-
const DEFAULT_CONFIGURATION = {} as RumConfiguration
10+
const DEFAULT_CONFIGURATION = { attrUnmaskAllowlist: [] as string[] } as RumConfiguration
1111

1212
describe('serializeAttribute', () => {
1313
it('truncates "data:" URIs after long string length', () => {
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import { NodePrivacyLevel } from '@datadog/browser-rum-core'
22
import type { RumConfiguration } from '@datadog/browser-rum-core'
33

4-
export const DEFAULT_CONFIGURATION = { defaultPrivacyLevel: NodePrivacyLevel.ALLOW } as RumConfiguration
4+
export const DEFAULT_CONFIGURATION = {
5+
defaultPrivacyLevel: NodePrivacyLevel.ALLOW,
6+
attrUnmaskAllowlist: [] as string[],
7+
} as RumConfiguration

0 commit comments

Comments
 (0)