import { isElementNode, getParentNode, isTextNode } from '../browser/htmlDomUtils' import { NodePrivacyLevel, FORM_PRIVATE_TAG_NAMES, CENSORED_STRING_MARK, getPrivacySelector, TEXT_MASKING_CHAR, PRIVACY_ATTR_NAME, } from './privacyConstants' import { STABLE_ATTRIBUTES } from './getSelectorFromElement' import type { RumConfiguration } from './configuration' export type NodePrivacyLevelCache = Map /** * Get node privacy level by iterating over its ancestors. When the direct parent privacy level is * know, it is best to use something like: * * derivePrivacyLevelGivenParent(getNodeSelfPrivacyLevel(node), parentNodePrivacyLevel) */ export function getNodePrivacyLevel( node: Node, defaultPrivacyLevel: NodePrivacyLevel, cache?: NodePrivacyLevelCache ): NodePrivacyLevel { if (cache && cache.has(node)) { return cache.get(node)! } const parentNode = getParentNode(node) const parentNodePrivacyLevel = parentNode ? getNodePrivacyLevel(parentNode, defaultPrivacyLevel, cache) : defaultPrivacyLevel const selfNodePrivacyLevel = getNodeSelfPrivacyLevel(node) const nodePrivacyLevel = reducePrivacyLevel(selfNodePrivacyLevel, parentNodePrivacyLevel) if (cache) { cache.set(node, nodePrivacyLevel) } return nodePrivacyLevel } /** * Reduces the next privacy level based on self + parent privacy levels */ export function reducePrivacyLevel( childPrivacyLevel: NodePrivacyLevel | undefined, parentNodePrivacyLevel: NodePrivacyLevel ): NodePrivacyLevel { switch (parentNodePrivacyLevel) { // These values cannot be overridden case NodePrivacyLevel.HIDDEN: case NodePrivacyLevel.IGNORE: return parentNodePrivacyLevel } switch (childPrivacyLevel) { case NodePrivacyLevel.ALLOW: case NodePrivacyLevel.MASK: case NodePrivacyLevel.MASK_USER_INPUT: case NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED: case NodePrivacyLevel.HIDDEN: case NodePrivacyLevel.IGNORE: return childPrivacyLevel default: return parentNodePrivacyLevel } } /** * Determines the node's own privacy level without checking for ancestors. */ export function getNodeSelfPrivacyLevel(node: Node): NodePrivacyLevel | undefined { // Only Element types can have a privacy level set if (!isElementNode(node)) { return } // Overrules for replay purpose if (node.tagName === 'BASE') { return NodePrivacyLevel.ALLOW } // Overrules to enforce end-user protection if (node.tagName === 'INPUT') { const inputElement = node as HTMLInputElement if (inputElement.type === 'password' || inputElement.type === 'email' || inputElement.type === 'tel') { return NodePrivacyLevel.MASK } if (inputElement.type === 'hidden') { return NodePrivacyLevel.MASK } const autocomplete = inputElement.getAttribute('autocomplete') // Handle input[autocomplete=cc-number/cc-csc/cc-exp/cc-exp-month/cc-exp-year/new-password/current-password] if (autocomplete && (autocomplete.startsWith('cc-') || autocomplete.endsWith('-password'))) { return NodePrivacyLevel.MASK } } // Check HTML privacy attributes and classes if (node.matches(getPrivacySelector(NodePrivacyLevel.HIDDEN))) { return NodePrivacyLevel.HIDDEN } if (node.matches(getPrivacySelector(NodePrivacyLevel.MASK))) { return NodePrivacyLevel.MASK } if (node.matches(getPrivacySelector(NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED))) { return NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED } if (node.matches(getPrivacySelector(NodePrivacyLevel.MASK_USER_INPUT))) { return NodePrivacyLevel.MASK_USER_INPUT } if (node.matches(getPrivacySelector(NodePrivacyLevel.ALLOW))) { return NodePrivacyLevel.ALLOW } if (shouldIgnoreElement(node)) { return NodePrivacyLevel.IGNORE } } /** * Helper aiming to unify `mask` and `mask-user-input` privacy levels: * * In the `mask` case, it is trivial: we should mask the element. * * In the `mask-user-input` case, we should mask the element only if it is a "form" element or the * direct parent is a form element for text nodes). * * Other `shouldMaskNode` cases are edge cases that should not matter too much (ex: should we mask a * node if it is ignored or hidden? it doesn't matter since it won't be serialized). */ export function shouldMaskNode(node: Node, privacyLevel: NodePrivacyLevel) { switch (privacyLevel) { case NodePrivacyLevel.MASK: case NodePrivacyLevel.HIDDEN: case NodePrivacyLevel.IGNORE: return true case NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED: if (isTextNode(node)) { // Always return true if our parent is a form element, like MASK_USER_INPUT. // Otherwise, decide whether to mask based on the allowlist. return isFormElement(node.parentNode) ? true : !isAllowlisted(node.textContent || '') } // Always return true if we're a form element, like MASK_USER_INPUT. // Otherwise, return false; MASK_UNLESS_ALLOWLISTED only directly masks text nodes. return isFormElement(node) case NodePrivacyLevel.MASK_USER_INPUT: return isTextNode(node) ? isFormElement(node.parentNode) : isFormElement(node) default: return false } } export function shouldMaskAttribute( tagName: string, attributeName: string, attributeValue: string | null, nodePrivacyLevel: NodePrivacyLevel, configuration: RumConfiguration ) { if (nodePrivacyLevel !== NodePrivacyLevel.MASK && nodePrivacyLevel !== NodePrivacyLevel.MASK_UNLESS_ALLOWLISTED) { return false } if ( attributeName === PRIVACY_ATTR_NAME || STABLE_ATTRIBUTES.includes(attributeName) || attributeName === configuration.actionNameAttribute ) { return false } switch (attributeName) { case 'title': case 'alt': case 'placeholder': return true } if (tagName === 'A' && attributeName === 'href') { return true } if (tagName === 'IFRAME' && attributeName === 'srcdoc') { return true } if (attributeValue && attributeName.startsWith('data-')) { return true } if ((tagName === 'IMG' || tagName === 'SOURCE') && (attributeName === 'src' || attributeName === 'srcset')) { return true } return false } function isFormElement(node: Node | null): boolean { if (!node || node.nodeType !== node.ELEMENT_NODE) { return false } const element = node as HTMLInputElement if (element.tagName === 'INPUT') { switch (element.type) { case 'button': case 'color': case 'reset': case 'submit': return false } } return !!FORM_PRIVATE_TAG_NAMES[element.tagName] } /** * Text censoring non-destructively maintains whitespace characters in order to preserve text shape * during replay. */ const censorText = (text: string) => text.replace(/\S/g, TEXT_MASKING_CHAR) export function getTextContent(textNode: Node, parentNodePrivacyLevel: NodePrivacyLevel): string | undefined { // The parent node may not be a html element which has a tagName attribute. // So just let it be undefined which is ok in this use case. const parentTagName = textNode.parentElement?.tagName let textContent = textNode.textContent || '' const shouldIgnoreWhiteSpace = parentTagName === 'HEAD' if (shouldIgnoreWhiteSpace && !textContent.trim()) { return } const nodePrivacyLevel = parentNodePrivacyLevel const isScript = parentTagName === 'SCRIPT' if (isScript) { // For perf reasons, we don't record script (heuristic) textContent = CENSORED_STRING_MARK } else if (nodePrivacyLevel === NodePrivacyLevel.HIDDEN) { // Should never occur, but just in case, we set to CENSORED_MARK. textContent = CENSORED_STRING_MARK } else if (shouldMaskNode(textNode, nodePrivacyLevel)) { if ( // Scrambling the child list breaks text nodes for DATALIST/SELECT/OPTGROUP parentTagName === 'DATALIST' || parentTagName === 'SELECT' || parentTagName === 'OPTGROUP' ) { if (!textContent.trim()) { return } } else if (parentTagName === 'OPTION') { //