fix(analytics): mask session replays by default, chrome-only unmask (#1639)

* fix(analytics): mask session replays by default, chrome-only unmask

Invert PostHog session-replay masking from visible-by-default with pattern
masking to deny-by-default: every input value is masked wholesale (rrweb
maskAllInputs, no maskInputFn) and every text node is masked unless it sits
under data-ph-unmask chrome or a table column header (th). Chrome tags live
on the shared UI primitives (PageHeader, Label, Button except combobox
triggers, TabsTrigger, Badge, Card/Dialog/Sheet titles, tooltips, help
popovers, empty states, settings labels), and tagged chrome is still
pattern-scrubbed for amounts and person-/organisationsnummer. data-ph-mask
beats data-ph-unmask, so call sites that interpolate user data into chrome
stay masked; a very-thorough audit swept every unmasked primitive and each
found site got a call-site mask. Confirm-dialog wrappers and toasts stay
masked centrally: their copy describes user objects by design. Untagged new
UI over-masks instead of leaking. Privacy policy, RoPA and decision log
updated in the same change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(analytics): tag detail-section chrome merged from main

The register-detail primitives landed on main after the replay-masking
audit ran: kickers and DefRow labels are static i18n chrome, values stay
masked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(analytics): close skeptic and review findings on replay masking

Explicit data-ph tags now resolve before the th chrome fallback, so a th
nested inside a data-ph-mask container masks correctly (regression test
added). Seven missed text-leak sites get call-site masks: delete-invoice
and credit-page invoice numbers, IB-correction voucher reference, TIC
orgnr (served unnormalized, so the separator-based scrub cannot be relied
on), articles search-term empty state, dimension segment labels, and
activate-account buttons. The attribute channel is closed with rrweb's
blockClass: inputs whose placeholder carries an effective user value
(salary overrides, correction description, danger-zone confirms, credit
confirm) get ph-no-capture, removing the element from recordings while
the prefill UX stays intact; the pivot-th title attribute is dropped.
Privacy-policy effective date bumped to 2026-08-17.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-17 11:32:45 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 1bb423b2b3
commit f8db38f989
74 changed files with 366 additions and 224 deletions
+43 -38
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { maskSensitiveText, replayMaskInput, replayMaskText } from '@/lib/analytics/replay-masking'
import { maskSensitiveText, replayMaskText } from '@/lib/analytics/replay-masking'
// Repo test convention. eventBus.clear() is deliberately absent: these are
// pure functions and importing the bus would only add module side effects.
@@ -9,9 +9,13 @@ beforeEach(() => {
/**
* Minimal stand-ins for the DOM elements rrweb hands to the masking
* functions (tests run in the node environment, no jsdom).
* functions (tests run in the node environment, no jsdom). `closest` is
* called once with the explicit-tag selector and, when that misses, once
* with 'th'; the fake answers each selector like a real DOM lookup would.
*/
function fakeElement(opts: { type?: string; tagged?: 'mask' | 'unmask' | 'both' | null } = {}): HTMLElement {
function fakeElement(
opts: { tagged?: 'mask' | 'unmask' | 'both' | null; th?: boolean } = {}
): HTMLElement {
const attrs =
opts.tagged === 'mask'
? ['data-ph-mask']
@@ -19,18 +23,18 @@ function fakeElement(opts: { type?: string; tagged?: 'mask' | 'unmask' | 'both'
? ['data-ph-unmask']
: opts.tagged === 'both'
? ['data-ph-mask', 'data-ph-unmask']
: []
const tagged = attrs.length > 0 ? { hasAttribute: (name: string) => attrs.includes(name) } : null
: null
const tagged = attrs ? { hasAttribute: (name: string) => attrs.includes(name) } : null
const thAncestor = opts.th ? { hasAttribute: () => false } : null
return {
type: opts.type,
closest: (_selector: string) => tagged,
closest: (selector: string) => (selector.includes('data-ph') ? tagged : thAncestor),
} as unknown as HTMLElement
}
describe('maskSensitiveText', () => {
it('masks sv-SE formatted amounts, preserving length and whitespace', () => {
// First variant groups thousands with U+00A0 (what Intl sv-SE emits), the second with a regular space.
expect(maskSensitiveText('1 234,56 kr')).toBe('* ****** **')
expect(maskSensitiveText('1 234,56 kr')).toBe('* ****** **')
expect(maskSensitiveText('1 234,56 kr')).toBe('* ****** **')
})
@@ -73,43 +77,44 @@ describe('maskSensitiveText', () => {
})
describe('replayMaskText', () => {
it('pattern-masks when the node has no tagged ancestor', () => {
expect(replayMaskText('Saldo 1 234 kr', fakeElement())).toBe('Saldo * *** **')
expect(replayMaskText('Saldo 1 234 kr', undefined)).toBe('Saldo * *** **')
it('masks everything when the node has no chrome ancestor', () => {
expect(replayMaskText('Acme AB', fakeElement())).toBe('**** **')
expect(replayMaskText('Kaffe till kontoret', fakeElement())).toBe('***** **** ********')
expect(replayMaskText('Acme AB', undefined)).toBe('**** **')
})
it('masks everything when rrweb passes an element without closest (text node parents can be non-Element)', () => {
expect(replayMaskText('Acme AB', {} as unknown as HTMLElement)).toBe('**** **')
})
it('shows chrome text under data-ph-unmask', () => {
expect(replayMaskText('Bokför och godkänn', fakeElement({ tagged: 'unmask' }))).toBe(
'Bokför och godkänn'
)
})
it('shows table column headers (th) without a tag', () => {
expect(replayMaskText('Datum', fakeElement({ th: true }))).toBe('Datum')
})
it('lets an explicit data-ph-mask beat the th fallback (th inside a masked container, or masked th)', () => {
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'mask', th: true }))).toBe('**** **')
})
it('pattern-scrubs amounts and identity numbers even inside chrome', () => {
expect(replayMaskText('Betala 1 234 kr nu', fakeElement({ tagged: 'unmask' }))).toBe(
'Betala * *** ** nu'
)
expect(replayMaskText('Ta bort 556677-8899', fakeElement({ tagged: 'unmask' }))).toBe(
'Ta bort ***********'
)
})
it('masks everything under data-ph-mask', () => {
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'mask' }))).toBe('**** **')
})
it('passes everything through under data-ph-unmask', () => {
expect(replayMaskText('Belopp i kr', fakeElement({ tagged: 'unmask' }))).toBe('Belopp i kr')
})
it('lets mask win when both attributes land on the same element', () => {
expect(replayMaskText('Acme AB', fakeElement({ tagged: 'both' }))).toBe('**** **')
})
})
describe('replayMaskInput', () => {
it('always masks password inputs, even under data-ph-unmask', () => {
expect(replayMaskInput('hunter2', fakeElement({ type: 'password' }))).toBe('*******')
expect(replayMaskInput('hunter2', fakeElement({ type: 'password', tagged: 'unmask' }))).toBe('*******')
})
it('masks identity-number-shaped values, including partial typing', () => {
expect(replayMaskInput('556677-8899', fakeElement({ type: 'text' }))).toBe('***********')
expect(replayMaskInput('19850101-1234', fakeElement({ type: 'text' }))).toBe('*************')
expect(replayMaskInput('5566778', fakeElement({ type: 'text' }))).toBe('*******')
})
it('passes ordinary typed values through', () => {
for (const value of ['1234,56', 'Kaffe till kontoret', 'namn@exempel.se', '1930', 'Acme AB']) {
expect(replayMaskInput(value, fakeElement({ type: 'text' }))).toBe(value)
}
})
it('honors data-ph-mask on inputs', () => {
expect(replayMaskInput('Acme AB', fakeElement({ type: 'text', tagged: 'mask' }))).toBe('**** **')
})
})
+48 -56
View File
@@ -1,31 +1,46 @@
/**
* Pattern-based masking for PostHog session replay.
* Deny-by-default masking for PostHog session replay.
*
* Replays are visible by default so support can see WHERE a user gets stuck
* and WHAT they typed while getting there. What must never be readable in a
* replay is the content of a user's books and identity numbers:
* Everything is masked unless it is app chrome (founder-approved 2026-08-17,
* supersedes the 2026-08-06 pattern-based default where user content was
* visible). Replays show layout, clicks and static UI text: headers, nav,
* form labels, placeholders, buttons. They never show user data: not what a
* user typed (input values are masked wholesale by rrweb, see
* instrumentation-client.ts) and not user content rendered as text
* (counterparty names, descriptions, amounts, identity numbers).
*
* 1. Monetary amounts. Every amount in the app renders through
* `formatCurrency()` (Intl sv-SE currency style, e.g. "1 234,56 kr"), so a
* currency-shaped text pattern covers transactions, vouchers, reports,
* invoices and dashboards in one place, including future code, without
* tagging hundreds of render sites.
* 2. Person- and organisationsnummer. For an enskild firma the orgnr IS the
* owner's personnummer. Masked both as rendered text (formatOrgNumber()
* output, "556677-8899") and as typed input values.
* 3. Passwords. Always masked, never overridable.
* What counts as chrome, i.e. renders readable in a replay:
* - Any subtree tagged `data-ph-unmask`. Tags live on the shared UI
* primitives (nav, PageHeader, Label, Button, tabs, dialog/sheet titles,
* card titles, badges, tooltips, empty states), so page code gets readable
* chrome without per-page tagging.
* - `<th>` elements with NO explicit tag anywhere above them: table column
* headers are static chrome, but the page-level dry-table pattern writes
* raw `<th className={TH_CLASS}>` per page, so there is no shared
* component to tag. An explicit data-ph-mask (on the th or any ancestor)
* always wins over this fallback.
*
* Tag overrides (nearest tagged ancestor wins, mask wins on a tie):
* - `data-ph-mask` force-masks an element's whole subtree (used on deliberate
* PII spots: company name / email in danger-zone labels, user-defined
* dimension names, nav count bubbles).
* - `data-ph-unmask` exempts a subtree from pattern masking (static chrome
* such as form labels and nav). It never unmasks a password input.
* Chrome is still pattern-scrubbed (belt and braces): an i18n string that
* interpolates an amount or a person-/organisationsnummer into a title or
* button label gets that span masked even inside an unmasked subtree.
*
* Known limits, accepted deliberately: masking is length-preserving (star
* count reveals magnitude, layout stays stable in the replay), bare numbers
* without a currency marker stay visible, and an identity number rendered
* WITHOUT its separator is only caught on the input side.
* `data-ph-mask` force-masks a subtree and wins over `data-ph-unmask`: the
* NEAREST tagged ancestor decides, and mask wins when both attributes land
* on the same element. Use it where user data flows into a chrome primitive
* (e.g. a Label interpolating the user's email, a dialog title carrying a
* counterparty name).
*
* Untagged text is fully masked, so the failure mode for new UI is
* over-masking (asterisks where chrome should be readable), never leaking
* a user's books.
*
* Known limit: rrweb masks text nodes and input values, not ATTRIBUTES.
* posthog-js exposes no attribute mask hook, so a title/aria-label/
* placeholder attribute is recorded as-is. An element whose attributes
* carry user data (e.g. a placeholder prefilled with an effective value)
* must carry the `ph-no-capture` class instead: rrweb's blockClass removes
* the whole element from the recording while the app UX is untouched. Do
* not put user data in title or aria-label attributes.
*/
/**
@@ -47,16 +62,6 @@ const AMOUNT_PATTERN = new RegExp(
*/
const IDENTITY_TEXT_PATTERN = /(?<!\d)\d{6}(?:\d{2})?[-+]\d{4}(?!\d)/g
/**
* A typed input value that is (or is on its way to becoming) a person-/
* organisationsnummer: 6 or more leading digits, optionally a separator and
* up to 4 more. Matching from the 6th digit means intermediate keystroke
* snapshots never ship the birthdate prefix of a personnummer. Accepted
* over-masking: any bare 6-12 digit value (e.g. a raw amount over 99 999)
* is masked too; amounts with decimals or thousand separators stay visible.
*/
const IDENTITY_INPUT_PATTERN = /^\s*\d{6,8}[-+ ]?\d{0,4}\s*$/
const TAG_SELECTOR = '[data-ph-mask],[data-ph-unmask]'
/** Length-preserving mask: whitespace survives so table layout stays legible. */
@@ -70,7 +75,8 @@ function maskSpan(span: string): string {
/**
* Masks currency amounts and separator-formatted identity numbers inside a
* text node, leaving the surrounding text readable.
* text node, leaving the surrounding text readable. Applied to CHROME text:
* non-chrome text never gets here, it is masked wholesale.
*/
export function maskSensitiveText(text: string): string {
return text.replace(AMOUNT_PATTERN, maskSpan).replace(IDENTITY_TEXT_PATTERN, maskSpan)
@@ -79,31 +85,17 @@ export function maskSensitiveText(text: string): string {
/**
* `session_recording.maskTextFn`. Runs on EVERY text node because
* `maskTextSelector: '*'` flags them all; this function then decides.
* Default is masked; only chrome shows through, and even chrome is
* pattern-scrubbed.
*
* Explicit tags are resolved FIRST, and only then the th fallback: a single
* closest() over tags-plus-th would let a th nested inside a data-ph-mask
* container win on DOM proximity and unmask it.
*/
export function replayMaskText(text: string, element?: HTMLElement): string {
const tagged = element?.closest(TAG_SELECTOR)
if (tagged) {
return tagged.hasAttribute('data-ph-mask') ? maskAll(text) : text
}
return maskSensitiveText(text)
}
/**
* `session_recording.maskInputFn`. rrweb only invokes this on inputs flagged
* by `maskInputOptions`, so the config sets `maskAllInputs: true` to flag
* every input and this function selectively passes values through. Password
* checks come first: not even `data-ph-unmask` may reveal one.
*/
export function replayMaskInput(text: string, element?: HTMLElement): string {
if ((element as HTMLInputElement | undefined)?.type === 'password') {
return maskAll(text)
}
const tagged = element?.closest?.(TAG_SELECTOR)
if (tagged) {
return tagged.hasAttribute('data-ph-mask') ? maskAll(text) : text
return tagged.hasAttribute('data-ph-mask') ? maskAll(text) : maskSensitiveText(text)
}
if (IDENTITY_INPUT_PATTERN.test(text)) {
return maskAll(text)
}
return text
return element?.closest?.('th') ? maskSensitiveText(text) : maskAll(text)
}