Skip to content

Commit d64e874

Browse files
authored
šŸ› [RUM-12503] Add mask-unless-allowlisted privacy level support for standard attr (#3907)
* Add mask-unless-allowlisted privacy level support for standard attributes action names * Extract shouldMaskAttriubte to share for SR and action names; Improve code according to comments * Fix censor img mark for source elements and update test cases * Improve code
1 parent d4aaac4 commit d64e874

File tree

5 files changed

+373
-68
lines changed

5 files changed

+373
-68
lines changed

ā€Žpackages/rum-core/src/domain/action/getActionNameFromElement.spec.tsā€Ž

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,27 @@ describe('getActionNameFromElement', () => {
643643
expectedName: 'foo bar baz',
644644
expectedNameSource: 'text_content',
645645
},
646+
{
647+
html: `
648+
<div data-dd-privacy="mask-unless-allowlisted" alt="bar" target>
649+
<span>bar</span>
650+
</div>
651+
`,
652+
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
653+
expectedName: 'Masked Element',
654+
expectedNameSource: 'standard_attribute',
655+
},
656+
{
657+
html: `
658+
<div data-dd-privacy="mask-unless-allowlisted" alt="foo" target>
659+
<span>bar</span>
660+
</div>
661+
`,
662+
defaultPrivacyLevel: NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED,
663+
allowlist: ['foo'],
664+
expectedName: 'foo',
665+
expectedNameSource: 'standard_attribute',
666+
},
646667
]
647668
testCases.forEach(({ html, defaultPrivacyLevel, allowlist, expectedName, expectedNameSource }) => {
648669
mockExperimentalFeatures([ExperimentalFeature.USE_TREE_WALKER_FOR_ACTION_NAME])

ā€Žpackages/rum-core/src/domain/action/getActionNameFromElement.tsā€Ž

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ExperimentalFeature, isExperimentalFeatureEnabled, safeTruncate } from '@datadog/browser-core'
22
import { getPrivacySelector, NodePrivacyLevel } from '../privacyConstants'
3-
import { getNodePrivacyLevel, shouldMaskNode } from '../privacy'
3+
import { getNodePrivacyLevel, maskDisallowedTextContent, shouldMaskNode, shouldMaskAttribute } from '../privacy'
44
import type { NodePrivacyLevelCache } from '../privacy'
55
import type { RumConfiguration } from '../configuration'
66
import { isElementNode } from '../../browser/htmlDomUtils'
@@ -16,6 +16,8 @@ export function getActionNameFromElement(
1616
rumConfiguration: RumConfiguration,
1717
nodePrivacyLevel: NodePrivacyLevel = NodePrivacyLevel.ALLOW
1818
): ActionName {
19+
const nodePrivacyLevelCache: NodePrivacyLevelCache = new Map()
20+
1921
const { actionNameAttribute: userProgrammaticAttribute } = rumConfiguration
2022

2123
// Proceed to get the action name in two steps:
@@ -36,8 +38,8 @@ export function getActionNameFromElement(
3638
}
3739

3840
return (
39-
getActionNameFromElementForStrategies(element, priorityStrategies, rumConfiguration) ||
40-
getActionNameFromElementForStrategies(element, fallbackStrategies, rumConfiguration) || {
41+
getActionNameFromElementForStrategies(element, priorityStrategies, rumConfiguration, nodePrivacyLevelCache) ||
42+
getActionNameFromElementForStrategies(element, fallbackStrategies, rumConfiguration, nodePrivacyLevelCache) || {
4143
name: '',
4244
nameSource: ActionNameSource.BLANK,
4345
}
@@ -58,14 +60,15 @@ function getActionNameFromElementProgrammatically(targetElement: Element, progra
5860

5961
type NameStrategy = (
6062
element: Element | HTMLElement | HTMLInputElement | HTMLSelectElement,
61-
rumConfiguration: RumConfiguration
63+
rumConfiguration: RumConfiguration,
64+
nodePrivacyLevelCache: NodePrivacyLevelCache
6265
) => ActionName | undefined | null
6366

6467
const priorityStrategies: NameStrategy[] = [
6568
// associated LABEL text
66-
(element, rumConfiguration) => {
69+
(element, rumConfiguration, nodePrivacyLevelCache) => {
6770
if ('labels' in element && element.labels && element.labels.length > 0) {
68-
return getActionNameFromTextualContent(element.labels[0], rumConfiguration)
71+
return getActionNameFromTextualContent(element.labels[0], rumConfiguration, nodePrivacyLevelCache)
6972
}
7073
},
7174
// INPUT button (and associated) value
@@ -79,41 +82,47 @@ const priorityStrategies: NameStrategy[] = [
7982
}
8083
},
8184
// BUTTON, LABEL or button-like element text
82-
(element, rumConfiguration) => {
85+
(element, rumConfiguration, nodePrivacyLevelCache) => {
8386
if (element.nodeName === 'BUTTON' || element.nodeName === 'LABEL' || element.getAttribute('role') === 'button') {
84-
return getActionNameFromTextualContent(element, rumConfiguration)
87+
return getActionNameFromTextualContent(element, rumConfiguration, nodePrivacyLevelCache)
8588
}
8689
},
87-
(element) => getActionNameFromStandardAttribute(element, 'aria-label'),
90+
(element, rumConfiguration, nodePrivacyLevelCache) =>
91+
getActionNameFromStandardAttribute(element, 'aria-label', rumConfiguration, nodePrivacyLevelCache),
8892
// associated element text designated by the aria-labelledby attribute
89-
(element, rumConfiguration) => {
93+
(element, rumConfiguration, nodePrivacyLevelCache) => {
9094
const labelledByAttribute = element.getAttribute('aria-labelledby')
9195
if (labelledByAttribute) {
9296
return {
9397
name: labelledByAttribute
9498
.split(/\s+/)
9599
.map((id) => getElementById(element, id))
96100
.filter((label): label is HTMLElement => Boolean(label))
97-
.map((element) => getTextualContent(element, rumConfiguration))
101+
.map((element) => getTextualContent(element, rumConfiguration, nodePrivacyLevelCache))
98102
.join(' '),
99103
nameSource: ActionNameSource.TEXT_CONTENT,
100104
}
101105
}
102106
},
103-
(element) => getActionNameFromStandardAttribute(element, 'alt'),
104-
(element) => getActionNameFromStandardAttribute(element, 'name'),
105-
(element) => getActionNameFromStandardAttribute(element, 'title'),
106-
(element) => getActionNameFromStandardAttribute(element, 'placeholder'),
107+
(element, rumConfiguration, nodePrivacyLevelCache) =>
108+
getActionNameFromStandardAttribute(element, 'alt', rumConfiguration, nodePrivacyLevelCache),
109+
(element, rumConfiguration, nodePrivacyLevelCache) =>
110+
getActionNameFromStandardAttribute(element, 'name', rumConfiguration, nodePrivacyLevelCache),
111+
(element, rumConfiguration, nodePrivacyLevelCache) =>
112+
getActionNameFromStandardAttribute(element, 'title', rumConfiguration, nodePrivacyLevelCache),
113+
(element, rumConfiguration, nodePrivacyLevelCache) =>
114+
getActionNameFromStandardAttribute(element, 'placeholder', rumConfiguration, nodePrivacyLevelCache),
107115
// SELECT first OPTION text
108-
(element, rumConfiguration) => {
116+
(element, rumConfiguration, nodePrivacyLevelCache) => {
109117
if ('options' in element && element.options.length > 0) {
110-
return getActionNameFromTextualContent(element.options[0], rumConfiguration)
118+
return getActionNameFromTextualContent(element.options[0], rumConfiguration, nodePrivacyLevelCache)
111119
}
112120
},
113121
]
114122

115123
const fallbackStrategies: NameStrategy[] = [
116-
(element, rumConfiguration) => getActionNameFromTextualContent(element, rumConfiguration),
124+
(element, rumConfiguration, nodePrivacyLevelCache) =>
125+
getActionNameFromTextualContent(element, rumConfiguration, nodePrivacyLevelCache),
117126
]
118127

119128
/**
@@ -124,7 +133,8 @@ const MAX_PARENTS_TO_CONSIDER = 10
124133
function getActionNameFromElementForStrategies(
125134
targetElement: Element,
126135
strategies: NameStrategy[],
127-
rumConfiguration: RumConfiguration
136+
rumConfiguration: RumConfiguration,
137+
nodePrivacyLevelCache: NodePrivacyLevelCache
128138
) {
129139
let element: Element | null = targetElement
130140
let recursionCounter = 0
@@ -136,7 +146,7 @@ function getActionNameFromElementForStrategies(
136146
element.nodeName !== 'HEAD'
137147
) {
138148
for (const strategy of strategies) {
139-
const actionName = strategy(element, rumConfiguration)
149+
const actionName = strategy(element, rumConfiguration, nodePrivacyLevelCache)
140150
if (actionName) {
141151
const { name, nameSource } = actionName
142152
const trimmedName = name && name.trim()
@@ -169,24 +179,45 @@ function getElementById(refElement: Element, id: string) {
169179
return refElement.ownerDocument ? refElement.ownerDocument.getElementById(id) : null
170180
}
171181

172-
function getActionNameFromStandardAttribute(element: Element | HTMLElement, attribute: string): ActionName {
182+
function getActionNameFromStandardAttribute(
183+
element: Element | HTMLElement,
184+
attribute: string,
185+
rumConfiguration: RumConfiguration,
186+
nodePrivacyLevelCache: NodePrivacyLevelCache
187+
): ActionName {
188+
const { enablePrivacyForActionName, defaultPrivacyLevel } = rumConfiguration
189+
let attributeValue = element.getAttribute(attribute)
190+
if (attributeValue && enablePrivacyForActionName) {
191+
const nodePrivacyLevel = getNodePrivacyLevel(element, defaultPrivacyLevel, nodePrivacyLevelCache)
192+
if (shouldMaskAttribute(element.tagName, attribute, attributeValue, nodePrivacyLevel, rumConfiguration)) {
193+
attributeValue = maskDisallowedTextContent(attributeValue, ACTION_NAME_PLACEHOLDER)
194+
}
195+
} else if (!attributeValue) {
196+
attributeValue = ''
197+
}
198+
173199
return {
174-
name: element.getAttribute(attribute) || '',
200+
name: attributeValue,
175201
nameSource: ActionNameSource.STANDARD_ATTRIBUTE,
176202
}
177203
}
178204

179205
function getActionNameFromTextualContent(
180206
element: Element | HTMLElement,
181-
rumConfiguration: RumConfiguration
207+
rumConfiguration: RumConfiguration,
208+
nodePrivacyLevelCache: NodePrivacyLevelCache
182209
): ActionName {
183210
return {
184-
name: getTextualContent(element, rumConfiguration) || '',
211+
name: getTextualContent(element, rumConfiguration, nodePrivacyLevelCache) || '',
185212
nameSource: ActionNameSource.TEXT_CONTENT,
186213
}
187214
}
188215

189-
function getTextualContent(element: Element, rumConfiguration: RumConfiguration) {
216+
function getTextualContent(
217+
element: Element,
218+
rumConfiguration: RumConfiguration,
219+
nodePrivacyLevelCache: NodePrivacyLevelCache
220+
) {
190221
if ((element as HTMLElement).isContentEditable) {
191222
return
192223
}
@@ -202,7 +233,8 @@ function getTextualContent(element: Element, rumConfiguration: RumConfiguration)
202233
element,
203234
userProgrammaticAttribute,
204235
enablePrivacyForActionName,
205-
defaultPrivacyLevel
236+
defaultPrivacyLevel,
237+
nodePrivacyLevelCache
206238
)
207239
}
208240

@@ -246,10 +278,9 @@ function getTextualContentWithTreeWalker(
246278
element: Element,
247279
userProgrammaticAttribute: string | undefined,
248280
privacyEnabledActionName: boolean,
249-
defaultPrivacyLevel: NodePrivacyLevel
281+
defaultPrivacyLevel: NodePrivacyLevel,
282+
nodePrivacyLevelCache: NodePrivacyLevelCache
250283
) {
251-
const nodePrivacyLevelCache: NodePrivacyLevelCache = new Map()
252-
253284
const walker = document.createTreeWalker(
254285
element,
255286
// eslint-disable-next-line no-bitwise

ā€Žpackages/rum-core/src/domain/privacy.tsā€Ž

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
CENSORED_STRING_MARK,
66
getPrivacySelector,
77
TEXT_MASKING_CHAR,
8+
PRIVACY_ATTR_NAME,
89
} from './privacyConstants'
10+
import { STABLE_ATTRIBUTES } from './getSelectorFromElement'
11+
import type { RumConfiguration } from './configuration'
912

1013
export type NodePrivacyLevelCache = Map<Node, NodePrivacyLevel>
1114

@@ -150,6 +153,46 @@ export function shouldMaskNode(node: Node, privacyLevel: NodePrivacyLevel) {
150153
}
151154
}
152155

156+
export function shouldMaskAttribute(
157+
tagName: string,
158+
attributeName: string,
159+
attributeValue: string | null,
160+
nodePrivacyLevel: NodePrivacyLevel,
161+
configuration: RumConfiguration
162+
) {
163+
if (nodePrivacyLevel !== NodePrivacyLevel.MASK && nodePrivacyLevel !== NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED) {
164+
return false
165+
}
166+
if (
167+
attributeName === PRIVACY_ATTR_NAME ||
168+
STABLE_ATTRIBUTES.includes(attributeName) ||
169+
attributeName === configuration.actionNameAttribute
170+
) {
171+
return false
172+
}
173+
174+
switch (attributeName) {
175+
case 'title':
176+
case 'alt':
177+
case 'placeholder':
178+
return true
179+
}
180+
if (tagName === 'A' && attributeName === 'href') {
181+
return true
182+
}
183+
if (tagName === 'IFRAME' && attributeName === 'srcdoc') {
184+
return true
185+
}
186+
if (attributeValue && attributeName.startsWith('data-')) {
187+
return true
188+
}
189+
if ((tagName === 'IMG' || tagName === 'SOURCE') && (attributeName === 'src' || attributeName === 'srcset')) {
190+
return true
191+
}
192+
193+
return false
194+
}
195+
153196
function isFormElement(node: Node | null): boolean {
154197
if (!node || node.nodeType !== node.ELEMENT_NODE) {
155198
return false

0 commit comments

Comments
Ā (0)