Skip to content

Commit 1f7525e

Browse files
authored
replay: add dd-privacy attribute for obfuscation & ignoring input (#715)
* recorder: introduce helpers to obfuscate elements Adds the nodeIsHidden & nodeOrAncestorsAreHidden helpers. These helpers, part of the new "privacy" file, replace rrweb's isBlocked() and rrweb-snapshot's _isBlockedElement() functions. They do not rely on the blockClass variable (removed in a later commit), but instead use constants defined within the same file. An element is considered hidden if it has the data attribute "data-dd-privacy" set to "hidden" or a class of "dd-privacy-hidden". * recorder: introduce helpers to ignore input Adds the nodeHasInputIngored & nodeOrAncestorsHaveInputIngnored helpers. These helpers will return true when a node should have it's input ignored, either because an attribute/class is set, or because it's a kind of input we don't want to track. They do not rely on the ignoreClass variable (removed in a later commit), but instead use constants defined within the same file. An element is considered ignored if it has the data attribute "data-dd-privacy" set to "input-ignored", a class of "dd-privacy-input-ingored", or a type of "email", "password" or "tel". * recorder: add unit tests for obfuscation & input ignore * e2e: update appium version Calls to setValue() would fail with 1.9.1: Error: invalid argument: 'value' must be a list (Session info: chrome=86.0.4240.198) (Driver info: chromedriver=86.0.4240.22 (398b0743353ff36fb1b82468f63a3a93b4e2e89e-refs/branch-heads/4240@{#378}),platform=Linux 4.1.13-101.fc21.x86_64 x86_64) * recorder: add e2e tests for obfuscation & input ignore * recorder: remove unused variables blockClass, blockSelector, and ignoreClass are now unused * recorder: rename helpers Renames: - nodeIsHidden to nodeShouldBeHidden - nodeOrAncestorsAreHidden to nodeOrAncestorsShouldBeHidden - nodeHasInputIngored to nodeShouldHaveInputIngored - nodeOrAncestorsHaveInputIngnored to nodeOrAncestorsShouldHaveInputIngnored * recorder: rename ElementNode's needBlock to shouldBeHidden
1 parent 6ecac3f commit 1f7525e

File tree

13 files changed

+387
-165
lines changed

13 files changed

+387
-165
lines changed
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { isIE } from '@datadog/browser-core'
2+
import {
3+
nodeShouldBeHidden,
4+
nodeOrAncestorsShouldBeHidden,
5+
nodeShouldHaveInputIgnored,
6+
nodeOrAncestorsShouldHaveInputIgnored,
7+
} from './privacy'
8+
9+
describe('privacy helpers', () => {
10+
beforeEach(() => {
11+
if (isIE()) {
12+
pending('IE not supported')
13+
}
14+
})
15+
16+
describe('for hiding blocks', () => {
17+
it('considers a normal DOM Element as not hidden', () => {
18+
const node = document.createElement('p')
19+
expect(nodeShouldBeHidden(node)).toBeFalsy()
20+
})
21+
it('considers a DOM Element with a data-dd-privacy="hidden" attribute as hidden', () => {
22+
const node = document.createElement('p')
23+
node.setAttribute('data-dd-privacy', 'hidden')
24+
expect(nodeShouldBeHidden(node)).toBeTruthy()
25+
})
26+
it('considers a DOM Element with a data-dd-privacy="foo" attribute as not hidden', () => {
27+
const node = document.createElement('p')
28+
node.setAttribute('data-dd-privacy', 'foo')
29+
expect(nodeShouldBeHidden(node)).toBeFalsy()
30+
})
31+
it('considers a DOM Element with a dd-privacy-hidden class as hidden', () => {
32+
const node = document.createElement('p')
33+
node.className = 'dd-privacy-hidden'
34+
expect(nodeShouldBeHidden(node)).toBeTruthy()
35+
})
36+
it('considers a normal DOM Element with a normal parent as not hidden', () => {
37+
const node = document.createElement('p')
38+
const parent = document.createElement('div')
39+
parent.appendChild(node)
40+
expect(nodeOrAncestorsShouldBeHidden(node)).toBeFalsy()
41+
})
42+
it('considers a DOM Element with a parent node with a dd-privacy="hidden" attribute as hidden', () => {
43+
const node = document.createElement('p')
44+
const parent = document.createElement('div')
45+
parent.setAttribute('data-dd-privacy', 'hidden')
46+
parent.appendChild(node)
47+
expect(nodeOrAncestorsShouldBeHidden(node)).toBeTruthy()
48+
})
49+
it('considers a DOM Element with a parent node with a dd-privacy-hidden class as hidden', () => {
50+
const node = document.createElement('p')
51+
const parent = document.createElement('div')
52+
parent.className = 'dd-privacy-hidden'
53+
parent.appendChild(node)
54+
expect(nodeOrAncestorsShouldBeHidden(node)).toBeTruthy()
55+
})
56+
it('considers a DOM Document as not hidden', () => {
57+
expect(nodeOrAncestorsShouldBeHidden(document)).toBeFalsy()
58+
})
59+
})
60+
describe('for ignoring input events', () => {
61+
it('considers a normal DOM Element as not to be ignored', () => {
62+
const node = document.createElement('input')
63+
expect(nodeShouldHaveInputIgnored(node)).toBeFalsy()
64+
})
65+
it('considers a DOM Element with a data-dd-privacy="input-ignored" attribute to be ignored', () => {
66+
const node = document.createElement('input')
67+
node.setAttribute('data-dd-privacy', 'input-ignored')
68+
expect(nodeShouldHaveInputIgnored(node)).toBeTruthy()
69+
})
70+
it('considers a DOM Element with a data-dd-privacy="foo" attribute as not to be ignored', () => {
71+
const node = document.createElement('input')
72+
node.setAttribute('data-dd-privacy', 'foo')
73+
expect(nodeShouldHaveInputIgnored(node)).toBeFalsy()
74+
})
75+
it('considers a DOM Element with a dd-privacy-input-ignored class to be ignored', () => {
76+
const node = document.createElement('input')
77+
node.className = 'dd-privacy-input-ignored'
78+
expect(nodeShouldHaveInputIgnored(node)).toBeTruthy()
79+
})
80+
it('considers a DOM HTMLInputElement with a type of "password" to be ignored', () => {
81+
const node = document.createElement('input')
82+
node.type = 'password'
83+
expect(nodeShouldHaveInputIgnored(node)).toBeTruthy()
84+
})
85+
it('considers a DOM HTMLInputElement with a type of "text" as not to be ignored', () => {
86+
const node = document.createElement('input')
87+
node.type = 'text'
88+
expect(nodeShouldHaveInputIgnored(node)).toBeFalse()
89+
})
90+
it('considers a normal DOM Element with a normal parent as not to be ignored', () => {
91+
const node = document.createElement('input')
92+
const parent = document.createElement('form')
93+
parent.appendChild(node)
94+
expect(nodeOrAncestorsShouldHaveInputIgnored(node)).toBeFalsy()
95+
})
96+
it('considers a DOM Element with a parent node with a dd-privacy="input-ignored" attribute to be ignored', () => {
97+
const node = document.createElement('input')
98+
const parent = document.createElement('form')
99+
parent.setAttribute('data-dd-privacy', 'input-ignored')
100+
parent.appendChild(node)
101+
expect(nodeOrAncestorsShouldHaveInputIgnored(node)).toBeTruthy()
102+
})
103+
it('considers a DOM Element with a parent node with a dd-privacy-input-ignored class to be ignored', () => {
104+
const node = document.createElement('input')
105+
const parent = document.createElement('form')
106+
parent.className = 'dd-privacy-input-ignored'
107+
parent.appendChild(node)
108+
expect(nodeOrAncestorsShouldHaveInputIgnored(node)).toBeTruthy()
109+
})
110+
it('considers a DOM Document as not to be ignored', () => {
111+
expect(nodeOrAncestorsShouldHaveInputIgnored(document)).toBeFalsy()
112+
})
113+
})
114+
})
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
const PRIVACY_ATTR_NAME = 'data-dd-privacy'
2+
const PRIVACY_ATTR_VALUE_HIDDEN = 'hidden'
3+
const PRIVACY_ATTR_VALUE_INPUT_IGNORED = 'input-ignored'
4+
5+
const PRIVACY_CLASS_HIDDEN = 'dd-privacy-hidden'
6+
const PRIVACY_CLASS_INPUT_IGNORED = 'dd-privacy-input-ignored'
7+
8+
// PRIVACY_INPUT_TYPES_TO_IGNORE defines the input types whose input
9+
// events we want to ignore by default, as they often contain PII.
10+
// TODO: We might want to differentiate types to fully ignore vs types
11+
// to obfuscate.
12+
const PRIVACY_INPUT_TYPES_TO_IGNORE = ['email', 'password', 'tel']
13+
14+
// Returns true if the given DOM node should be hidden. Ancestors
15+
// are not checked.
16+
export function nodeShouldBeHidden(node: Node): boolean {
17+
return (
18+
isElement(node) &&
19+
(node.getAttribute(PRIVACY_ATTR_NAME) === PRIVACY_ATTR_VALUE_HIDDEN ||
20+
node.classList.contains(PRIVACY_CLASS_HIDDEN))
21+
)
22+
}
23+
24+
// Returns true if the given DOM node should be hidden, recursively
25+
// checking its ancestors.
26+
export function nodeOrAncestorsShouldBeHidden(node: Node | null): boolean {
27+
if (!node) {
28+
return false
29+
}
30+
31+
if (nodeShouldBeHidden(node)) {
32+
return true
33+
}
34+
35+
return nodeOrAncestorsShouldBeHidden(node.parentNode)
36+
}
37+
38+
// Returns true if the given DOM node should have it's input events
39+
// ignored. Ancestors are not checked.
40+
export function nodeShouldHaveInputIgnored(node: Node): boolean {
41+
return (
42+
isElement(node) &&
43+
(node.getAttribute(PRIVACY_ATTR_NAME) === PRIVACY_ATTR_VALUE_INPUT_IGNORED ||
44+
node.classList.contains(PRIVACY_CLASS_INPUT_IGNORED) ||
45+
// if element is an HTMLInputElement, check the type is not to be ignored by default
46+
(isInputElement(node) && PRIVACY_INPUT_TYPES_TO_IGNORE.includes(node.type)))
47+
)
48+
}
49+
50+
// Returns true if the given DOM node should have it's input events
51+
// ignored, recursively checking its ancestors.
52+
export function nodeOrAncestorsShouldHaveInputIgnored(node: Node | null): boolean {
53+
if (!node) {
54+
return false
55+
}
56+
57+
if (nodeShouldHaveInputIgnored(node)) {
58+
return true
59+
}
60+
61+
return nodeOrAncestorsShouldHaveInputIgnored(node.parentNode)
62+
}
63+
64+
function isElement(node: Node): node is Element {
65+
return node.nodeType === node.ELEMENT_NODE
66+
}
67+
68+
function isInputElement(elem: Element): elem is HTMLInputElement {
69+
return elem.tagName === 'INPUT'
70+
}

packages/rum-recorder/src/domain/rrweb-snapshot/snapshot.ts

Lines changed: 8 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable no-underscore-dangle */
2-
import { forEach } from '../rrweb/utils'
2+
import { nodeShouldBeHidden } from '../privacy'
33
import {
44
SerializedNode,
55
SerializedNodeWithId,
@@ -159,41 +159,16 @@ export function transformAttribute(doc: Document, name: string, value: string):
159159
return value
160160
}
161161

162-
export function isBlockedElement(
163-
element: HTMLElement,
164-
blockClass: string | RegExp,
165-
blockSelector: string | null
166-
): boolean {
167-
if (typeof blockClass === 'string') {
168-
if (element.classList.contains(blockClass)) {
169-
return true
170-
}
171-
} else {
172-
forEach(element.classList, (className: string) => {
173-
if (blockClass.test(className)) {
174-
return true
175-
}
176-
})
177-
}
178-
if (blockSelector) {
179-
return element.matches(blockSelector)
180-
}
181-
182-
return false
183-
}
184-
185162
function serializeNode(
186163
n: Node,
187164
options: {
188165
doc: Document
189-
blockClass: string | RegExp
190-
blockSelector: string | null
191166
inlineStylesheet: boolean
192167
maskInputOptions: MaskInputOptions
193168
recordCanvas: boolean
194169
}
195170
): SerializedNode | false {
196-
const { doc, blockClass, blockSelector, inlineStylesheet, maskInputOptions = {}, recordCanvas } = options
171+
const { doc, inlineStylesheet, maskInputOptions = {}, recordCanvas } = options
197172
switch (n.nodeType) {
198173
case n.DOCUMENT_NODE:
199174
return {
@@ -208,7 +183,7 @@ function serializeNode(
208183
systemId: (n as DocumentType).systemId,
209184
}
210185
case n.ELEMENT_NODE:
211-
const needBlock = isBlockedElement(n as HTMLElement, blockClass, blockSelector)
186+
const shouldBeHidden = nodeShouldBeHidden(n)
212187
const tagName = getValidTagName((n as HTMLElement).tagName)
213188
let attributes: Attributes = {}
214189
for (const { name, value } of Array.from((n as HTMLElement).attributes)) {
@@ -276,7 +251,7 @@ function serializeNode(
276251
if ((n as HTMLElement).scrollTop) {
277252
attributes.rr_scrollTop = (n as HTMLElement).scrollTop
278253
}
279-
if (needBlock) {
254+
if (shouldBeHidden) {
280255
const { width, height } = (n as HTMLElement).getBoundingClientRect()
281256
attributes = {
282257
class: attributes.class,
@@ -290,7 +265,7 @@ function serializeNode(
290265
attributes,
291266
childNodes: [],
292267
isSVG: isSVGElement(n as Element) || undefined,
293-
needBlock,
268+
shouldBeHidden,
294269
}
295270
case n.TEXT_NODE:
296271
// The parent node may not be a html element which has a tagName attribute.
@@ -407,8 +382,6 @@ export function serializeNodeWithId(
407382
options: {
408383
doc: Document
409384
map: IdNodeMap
410-
blockClass: string | RegExp
411-
blockSelector: string | null
412385
skipChild: boolean
413386
inlineStylesheet: boolean
414387
maskInputOptions?: MaskInputOptions
@@ -420,8 +393,6 @@ export function serializeNodeWithId(
420393
const {
421394
doc,
422395
map,
423-
blockClass,
424-
blockSelector,
425396
skipChild = false,
426397
inlineStylesheet = true,
427398
maskInputOptions = {},
@@ -431,8 +402,6 @@ export function serializeNodeWithId(
431402
let { preserveWhiteSpace = true } = options
432403
const _serializedNode = serializeNode(n, {
433404
doc,
434-
blockClass,
435-
blockSelector,
436405
inlineStylesheet,
437406
maskInputOptions,
438407
recordCanvas,
@@ -466,9 +435,9 @@ export function serializeNodeWithId(
466435
map[id] = n as INode
467436
let recordChild = !skipChild
468437
if (serializedNode.type === NodeType.Element) {
469-
recordChild = recordChild && !serializedNode.needBlock
438+
recordChild = recordChild && !serializedNode.shouldBeHidden
470439
// this property was not needed in replay side
471-
delete serializedNode.needBlock
440+
delete serializedNode.shouldBeHidden
472441
}
473442
if ((serializedNode.type === NodeType.Document || serializedNode.type === NodeType.Element) && recordChild) {
474443
if (
@@ -483,8 +452,6 @@ export function serializeNodeWithId(
483452
const serializedChildNode = serializeNodeWithId(childN, {
484453
doc,
485454
map,
486-
blockClass,
487-
blockSelector,
488455
skipChild,
489456
inlineStylesheet,
490457
maskInputOptions,
@@ -503,22 +470,13 @@ export function serializeNodeWithId(
503470
export function snapshot(
504471
n: Document,
505472
options?: {
506-
blockClass?: string | RegExp
507473
inlineStylesheet?: boolean
508474
maskAllInputs?: boolean | MaskInputOptions
509475
slimDOM?: boolean | SlimDOMOptions
510476
recordCanvas?: boolean
511-
blockSelector?: string | null
512477
}
513478
): [SerializedNodeWithId | null, IdNodeMap] {
514-
const {
515-
blockClass = 'rr-block',
516-
inlineStylesheet = true,
517-
recordCanvas = false,
518-
blockSelector = null,
519-
maskAllInputs = false,
520-
slimDOM = false,
521-
} = options || {}
479+
const { inlineStylesheet = true, recordCanvas = false, maskAllInputs = false, slimDOM = false } = options || {}
522480
const idNodeMap: IdNodeMap = {}
523481
const maskInputOptions: MaskInputOptions =
524482
maskAllInputs === true
@@ -565,8 +523,6 @@ export function snapshot(
565523
serializeNodeWithId(n, {
566524
doc: n,
567525
map: idNodeMap,
568-
blockClass,
569-
blockSelector,
570526
skipChild: false,
571527
inlineStylesheet,
572528
maskInputOptions,

packages/rum-recorder/src/domain/rrweb-snapshot/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export type ElementNode = {
2828
attributes: Attributes
2929
childNodes: SerializedNodeWithId[]
3030
isSVG?: true
31-
needBlock?: boolean
31+
shouldBeHidden?: boolean
3232
}
3333

3434
export type TextNode = {

packages/rum-recorder/src/domain/rrweb/mutation.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,6 @@ describe('MutationBuffer', () => {
2929
mutationBuffer = new MutationBuffer()
3030
mutationBuffer.init(
3131
mutationCallbackSpy,
32-
DEFAULT_OPTIONS.blockClass,
33-
DEFAULT_OPTIONS.blockSelector,
3432
DEFAULT_OPTIONS.inlineStylesheet,
3533
DEFAULT_OPTIONS.maskInputOptions,
3634
DEFAULT_OPTIONS.recordCanvas,

0 commit comments

Comments
 (0)