feat(studio): mask HTML attributes in session replay (#48818)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Hardening ahead of any decision to enable session replay, plus a
dependency bump. Follow-up to #48515.

### What's inside

- ~50 lines of logic: the callback, the `url()` pattern, and the theme
and SVG-reference gates
([session-replay.ts](https://github.com/supabase/supabase/pull/48818/changes#diff-b7e4f10387ee7a116dd1673f70a55c0ba066687ff2d228bc2320eba75a349fac))
- ~170 lines of allowlist, one attribute name per line, skimmable ([same
file](https://github.com/supabase/supabase/pull/48818/changes#diff-b7e4f10387ee7a116dd1673f70a55c0ba066687ff2d228bc2320eba75a349fac))
- ~150 lines of comments saying why each group is allowlisted, since a
wrong entry is a privacy or a fidelity bug ([same
file](https://github.com/supabase/supabase/pull/48818/changes#diff-b7e4f10387ee7a116dd1673f70a55c0ba066687ff2d228bc2320eba75a349fac))
- ~430 lines of tests, one case per policy decision
([session-replay.test.ts](https://github.com/supabase/supabase/pull/48818/changes#diff-f9feb872ad0136cf87c7e9fb2af72eb3f4019464c06f0b7dd050ffb85373ccb8))
- 1 line of dependency bump, plus its lockfile
([package.json](https://github.com/supabase/supabase/pull/48818/changes#diff-50d7c39a9430d37971aa76858165ab4f7921c4cc4340b28e9b673ce6982e63cf))

## What is the current behavior?

Session replay is disabled in every environment, and no recordings
exist. This is about what a recording *would* contain if it were ever
switched on.

Attributes are the one channel replay masking cannot reach. `maskTextFn`
only sees DOM text nodes, so a component interpolating customer data
into a `placeholder`, `title` or `aria-label` would be captured
verbatim. Before `posthog-js` 1.413.0 there was no hook for it at all,
and the only mitigation was blocking the element, which drops it from
the capture entirely.

Two places in Studio where that would apply:

- `CreateOrUpdateCustomProviderSheet.tsx:506-507` interpolates the
project's API host into both `value` and `placeholder`. The `value` is
masked. The `placeholder` is not.
- `FileExplorerHeader.tsx:185` renders `Search in
${currentFolderName}...`, a customer storage folder name.

The list is not complete. Any component echoing context into a tooltip
reproduces it, and the author has no reason to be thinking about replay.

Linear [GROWTH-1094](https://linear.app/supabase/issue/GROWTH-1094).
Blocks [GROWTH-1073](https://linear.app/supabase/issue/GROWTH-1073).

## What is the new behavior?

`maskAttributeFn` with a default-deny policy: an allowlist of the
attributes replay needs to render, everything else masked.

### Policy edge cases

- **rrweb's `rr_*` layout attributes have to be allowlisted
explicitly.** posthog-js only applies its own exemption for those when
`maskAllElementAttributes` does the masking. A callback does not get the
exemption.
- **HTML `id` is masked. SVG `id` passes.** `AreaChart.tsx:119` emits
`<linearGradient id="colorUv">` and references it as
`fill="url(#colorUv)"`, so masking it breaks the gradient. But Studio
also binds customer-named values to `id` (`bucket.id` is a storage
bucket name). Split on `element.namespaceURI`.
- **SVG reference attributes pass only fragment-only targets.** recharts
clips every series with `clip-path="url(#clipPath-<id>)"`, so
`clip-path`, `mask`, `filter`, `marker-*`, `fill` and `stroke` have to
survive. They accept external URLs too, so the policy checks the target
rather than allowlisting the attribute name.
- **The `url()` pattern consumes escaped delimiters and ignores case.**
A target containing a quote serializes as `\"` and one containing a
bracket as `\)`, so a naive `[^")]*` stops at the backslash and leaves
the tail of the URL recorded. `URL(...)` is the same function as
`url(...)`. A token the pattern cannot parse falls through to a masking
fallback rather than passing.
- **`url()` targets inside `style` are masked, keeping the
declarations.** The feedback widget puts `toPng(document.body)`, a
base64 PNG of the whole dashboard, into a `background-image`, and the
storage preview panes put signed object URLs there. No other masking
path covers those, because they are not text nodes, a canvas, a network
request or an `img src`.

The config also pins `maskAllElementAttributes: false`. Left unset it
resolves from the PostHog UI, and `true` discards `maskAttributeFn`
entirely.

The `posthog-js` floor rises to `^1.416.1`, the first version carrying
both attribute masking and the "coarse option wins" precedence.

This does not enable recording anywhere.

## Additional context

### Verification

Ran on the studio-staging preview against a live session: 817 seconds,
190 clicks, 82 keypresses. Staging has no server-side masking config, so
everything masked came from this code.

| Check | Result |
|---|---|
| Storage folder search placeholder | Asterisked. Pre-fix it read
`Search in <folder>...` |
| Custom auth provider sheet | Fully masked, including the callback URL
field |
| Canary folder name in event properties | 0 hits, with 51 events in the
session as the control |
| Console capture | `console_log_count: 0` despite the project having
`capture_console_log_opt_in: true` |
| Telemetry regression | None: `$pageview` x34, `$pageleave` x5,
`$groupidentify` x4, `$identify` x1 |

Recording was scoped to that one preview by an origin restriction plus a
URL trigger. Both were reverted afterwards along with the project
toggle.

The policy has 175 unit tests. Separately, the config was bundled with
esbuild and applied to a DOM reproducing Studio's serialized output (the
AreaChart gradient, a recharts `clip-path`, a lucide icon, an inline
`background-image`), and the chart, gradient fill and icon come out
pixel-identical.

### Known fidelity costs

- `img src` is masked, so images don't render in replay. Storage object
URLs are signed customer content.
- `ProviderIcon` renders its mark as `maskImage: url(<src>)` and
`normalizeIconPath` accepts absolute URLs, so provider icons don't
render either.

### Out of scope

rrweb records `<style>` element text without calling either masking
function, because its text-node serializer skips masking when the parent
is `STYLE`. This PR does not reach that channel. Fixed separately in
#50270 / [GROWTH-1229](https://linear.app/supabase/issue/GROWTH-1229).

`captureJsonLd` also defaults on as of PostHog's 2026-08-30 defaults,
which is a capture channel masking doesn't reach. Studio renders no
`ld+json`, so it's inert there, and pinning it off was left out to keep
this PR to its scope.

### The allowlist is the weak part

The policy is default-deny over attribute *names*, so its surface is
every attribute any shipped library emits, and that set grows with each
dependency. A miss is also invisible to these tests, which assert what
the function returns rather than whether some selector elsewhere still
matches. Both failure directions are reachable that way: an attribute
carrying customer data, and an attribute a stylesheet needs.

[GROWTH-1232](https://linear.app/supabase/issue/GROWTH-1232) tracks the
mechanism change: scope by namespace instead of by name, since 50 of the
159 entries exist only to serve SVG rendering, plus a conformance test
that derives the expected set from the codebase so a new dependency
fails CI rather than degrading a replay. Deliberately not done here,
since rewriting the mechanism of a privacy control buys maintainability
rather than correctness.
This commit is contained in:
Sean Oliver authored and GitHub committed 2026-09-14 09:48:58 -07:00
1 parent 32f17f994d
commit d439ba57f4
4 files changed
+866 -28

No files matched your search

+429 -1
View File
@@ -1,7 +1,13 @@
import { buildSessionRecordingConfig, type CapturedNetworkRequest } from 'common'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { maskReplayNetworkRequest, maskReplayText, SESSION_REPLAY_CONFIG } from './session-replay'
import {
maskReplayAttribute,
maskReplayCssUrls,
maskReplayNetworkRequest,
maskReplayText,
SESSION_REPLAY_CONFIG,
} from './session-replay'
const elementWith = (attributes: Record<string, string>) => {
const element = document.createElement('span')
@@ -47,6 +53,420 @@ describe('maskReplayText', () => {
})
})
describe('maskReplayAttribute', () => {
it.each([
['placeholder', 'Search in customer-invoices...'],
['title', 'acme-production'],
['aria-label', 'Delete project acme'],
['aria-describedby-text', 'acme'],
['alt', 'Avatar for jane@acme.com'],
['value', 'postgresql://postgres:hunter2@db.abc.supabase.co:5432'],
['label', 'acme-production'],
['name', 'acme'],
['data-project-ref', 'abcdefghijklmnop'],
['data-testid', 'project-acme'],
])('masks %o, which can carry interpolated customer data', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe('*')
})
it.each(['href', 'src', 'srcset', 'action', 'poster', 'formaction', 'data-href'])(
'masks %o, since project and storage paths ride in URLs',
(name) => {
const url = 'https://abc.supabase.co/storage/v1/object/invoices/q4.pdf'
expect(maskReplayAttribute(name, url, elementWith({}))).toBe('*')
}
)
it.each([
['class', 'flex items-center gap-2 text-foreground-light'],
['type', 'checkbox'],
['colspan', '3'],
['dir', 'ltr'],
['role', 'menuitem'],
['tabindex', '-1'],
['aria-hidden', 'true'],
['aria-expanded', 'false'],
['data-state', 'open'],
['data-orientation', 'vertical'],
])('leaves %o alone, since replay needs it to render', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['d', 'M4 6h16M4 12h16M4 18h16'],
['viewBox', '0 0 24 24'],
['stroke-width', '2'],
['fill', 'none'],
['xmlns', 'http://www.w3.org/2000/svg'],
])('leaves SVG geometry attribute %o alone, so icons still render', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['fill', 'url(#colorUv)'],
['clip-path', 'url(#clipPath-recharts-1)'],
['mask', 'url(#mask-1)'],
['filter', 'url(#shadow-1)'],
['marker-end', 'url(#arrow)'],
])('leaves SVG reference %o intact, since masking it drops the effect', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['rr_width', '1280'],
['rr_scrollTop', '240'],
['rr_open_mode', 'closed'],
['_cssText', '.a{color:red}'],
])(
'leaves rrweb-generated attribute %o alone, which posthog-js does not exempt for callbacks',
(name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
}
)
it('masks attributes it has never seen, so a new leak is masked rather than recorded', () => {
expect(maskReplayAttribute('data-some-future-attribute', 'acme')).toBe('*')
expect(maskReplayAttribute('tooltip', 'acme')).toBe('*')
})
it('matches attribute names case-insensitively', () => {
expect(maskReplayAttribute('CLASS', 'flex')).toBe('flex')
expect(maskReplayAttribute('ARIA-LABEL', 'acme')).toBe('*')
})
it('keeps stylesheet URLs, which replay needs for sheets rrweb could not inline', () => {
const href = 'https://supabase.com/dashboard/_next/static/css/main.css'
const link = document.createElement('link')
link.setAttribute('rel', 'stylesheet')
expect(maskReplayAttribute('href', href, link)).toBe(href)
})
it.each(['preload', 'icon', 'preconnect', ''])(
'masks a <link rel=%o> href, which is not a stylesheet',
(rel) => {
const link = document.createElement('link')
link.setAttribute('rel', rel)
expect(
maskReplayAttribute('href', 'https://x.test/customer-avatar.png?token=secret', link)
).toBe('*')
}
)
it('masks a <link> href with no rel at all', () => {
const link = document.createElement('link')
expect(maskReplayAttribute('href', 'https://x.test/a.png?token=secret', link)).toBe('*')
})
it('keeps a stylesheet href in an XHTML document, where tagName is lowercased', () => {
const xhtml = document.implementation.createDocument(
'http://www.w3.org/1999/xhtml',
'html',
null
)
const link = xhtml.createElementNS('http://www.w3.org/1999/xhtml', 'link')
link.setAttribute('rel', 'stylesheet')
expect(link.tagName).toBe('link')
expect(maskReplayAttribute('href', '/app.css', link)).toBe('/app.css')
})
it('masks anchor URLs even though stylesheet URLs are kept', () => {
const href = 'https://supabase.com/dashboard/project/abcdefghijklmnop/editor'
const anchor = document.createElement('a')
expect(maskReplayAttribute('href', href, anchor)).toBe('*')
})
it('masks when no element is given', () => {
const href = 'https://supabase.com/dashboard/project/abc'
expect(maskReplayAttribute('href', href, undefined)).toBe('*')
})
it('masks HTML ids, which Studio binds to storage bucket names', () => {
expect(maskReplayAttribute('id', 'customer-invoices', elementWith({}))).toBe('*')
expect(maskReplayAttribute('for', 'customer-invoices')).toBe('*')
})
it('keeps SVG ids, which fill="url(#id)" references for gradients and clip paths', () => {
const gradient = document.createElementNS('http://www.w3.org/2000/svg', 'linearGradient')
expect(maskReplayAttribute('id', 'recharts-gradient-1', gradient)).toBe('recharts-gradient-1')
})
it('masks the interpolated placeholders found in Studio', () => {
const storageSearch = 'Search in customer-invoices...'
expect(maskReplayAttribute('placeholder', storageSearch)).toBe('*')
const authCallback = 'https://abcdefghijklmnop.supabase.co/auth/v1/callback'
expect(maskReplayAttribute('placeholder', authCallback)).toBe('*')
})
it('keeps CSS that carries no url()', () => {
expect(maskReplayAttribute('style', 'transform: translateX(4px)')).toBe(
'transform: translateX(4px)'
)
})
it.each([
'background-image: url("data:image/png;base64,iVBORw0KGgoAAAA")',
"background-image: url('https://abc.supabase.co/storage/v1/object/sign/invoices/q4.pdf')",
'background-image: url(https://abc.supabase.co/avatars/jane.png)',
])('masks the url() target in %o', (css) => {
const masked = maskReplayAttribute('style', css)
expect(masked).toBe('background-image: url(*)')
})
it('leaves url() inside inlined stylesheets alone, which are our own CSS', () => {
const sheet = '.a{background:url("https://x.com/a.png")}'
expect(maskReplayAttribute('_cssText', sheet)).toBe(sheet)
})
it('keeps the surrounding declaration when masking a url()', () => {
expect(
maskReplayAttribute('style', 'background: url("https://x.com/a.png") no-repeat center')
).toBe('background: url(*) no-repeat center')
})
it('masks every url() in a value, not just the first', () => {
expect(maskReplayCssUrls('a{background:url(x.png)}b{background:url(y.png)}')).toBe(
'a{background:url(*)}b{background:url(*)}'
)
})
it('masks a url() target containing a bracket, legal in a storage object name', () => {
expect(maskReplayCssUrls('background: url("invoices/q4 (final).pdf")')).toBe(
'background: url(*)'
)
})
it('leaves gradients alone, since they carry no URL', () => {
const gradient = 'background: linear-gradient(to right, #fff 0%, #000 100%)'
expect(maskReplayCssUrls(gradient)).toBe(gradient)
})
it('masks to a constant, so value length is not leaked', () => {
expect(maskReplayAttribute('title', 'ab')).toBe('*')
expect(maskReplayAttribute('title', 'abcdefgh')).toBe('*')
expect(maskReplayAttribute('title', 'a'.repeat(500))).toBe('*')
})
it('masks a url() target whose name contains an escaped quote', () => {
expect(maskReplayCssUrls('background: url("invoices/(acme) \\"q4\\".pdf")')).toBe(
'background: url(*)'
)
})
it('masks an unquoted url() target containing an escaped bracket', () => {
expect(maskReplayCssUrls('background: url(invoices/q4\\)secret.pdf)')).toBe(
'background: url(*)'
)
})
it('masks an unterminated url() token rather than letting it through', () => {
expect(maskReplayCssUrls('background: url(trailing\\')).not.toContain('trailing')
expect(maskReplayCssUrls('background: url(secret\\)')).not.toContain('secret')
})
it.each([
'background: URL("https://x.test/secret.png")',
'background: Url(https://x.test/secret.png)',
])('masks url() regardless of case in %o', (css) => {
expect(maskReplayCssUrls(css)).toContain('url(*)')
expect(maskReplayCssUrls(css)).not.toContain('secret')
})
it.each([
['fill', 'URL(https://x.test/secret.svg#paint)'],
['clip-path', 'Url(https://x.test/secret.svg#c)'],
])('masks an uppercase URL() in SVG reference %o', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe('*')
})
it.each([
['fill', 'URL(#colorUv)'],
['clip-path', 'Url(#clipPath-1)'],
])('keeps an uppercase fragment-only URL() in %o', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it('keeps a fragment-only url(), which names a node in the recording', () => {
expect(maskReplayAttribute('style', 'clip-path: url(#clip-1)')).toBe('clip-path: url(#clip-1)')
expect(maskReplayCssUrls('mask: url("#m")')).toBe('mask: url("#m")')
})
it('masks an external url() even when a fragment is appended', () => {
expect(maskReplayCssUrls('fill: url(https://x.test/customer.svg#paint)')).toBe('fill: url(*)')
})
it.each([
['fill', 'url(https://x.test/customer-secret.svg#paint)'],
['stroke', 'url("https://x.test/secret.svg#s")'],
['clip-path', 'url(https://x.test/secret.svg#c)'],
['mask', 'url(https://x.test/secret.svg#m)'],
['filter', 'url(https://x.test/secret.svg#f)'],
['marker-end', 'url(https://x.test/secret.svg#a)'],
])('masks SVG reference %o pointing at an external URL', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe('*')
})
it.each([
['fill', '#24b47e'],
['fill', 'none'],
['stroke', 'currentColor'],
])('keeps plain SVG presentation value %o %o', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each(['dark', 'light', 'classic-dark', 'system'])(
'keeps data-theme=%o, which monaco.css and grid.css select on',
(theme) => {
expect(maskReplayAttribute('data-theme', theme)).toBe(theme)
}
)
it('masks a data-theme value outside the known themes', () => {
expect(maskReplayAttribute('data-theme', 'acme-production')).toBe('*')
})
it.each([
['data-collapsible', 'icon'],
['data-variant', 'destructive'],
['data-sidebar', 'menu-button'],
['data-active', 'true'],
['data-size', 'sm'],
['data-selected', 'true'],
['data-expanded', 'false'],
['data-motion', 'from-start'],
['data-invalid', 'true'],
['data-front', 'true'],
['data-invisible', 'true'],
['data-separator', 'active'],
['data-vaul-drawer-direction', 'right'],
['aria-invalid', 'true'],
['aria-pressed', 'false'],
])('keeps %o, which a Tailwind variant in packages/ui selects on', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['rows', '8'],
['cols', '40'],
])('keeps textarea %o, since masking it collapses the box in replay', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it('masks data-value, which cmdk fills with the item value', () => {
expect(maskReplayAttribute('data-value', 'customer_email')).toBe('*')
})
it('keeps data-chart, which the chart stylesheet selector has to match', () => {
expect(maskReplayAttribute('data-chart', 'chart-r1a')).toBe('chart-r1a')
})
it.each([
['data-index', '0'],
['data-band', '3'],
])('keeps %o, which a shipped stylesheet selects on by value', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['data-sonner-toast', ''],
['data-mounted', 'true'],
['data-visible', 'false'],
['data-x-position', 'right'],
['data-y-position', 'bottom'],
['data-type', 'error'],
['data-styled', 'true'],
['data-removed', 'false'],
])('keeps sonner marker %o, without which every toast is invisible in replay', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['data-vaul-overlay', ''],
['data-vaul-snap-points', 'false'],
['data-vaul-drawer', ''],
])('keeps vaul marker %o', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['data-radix-portal', ''],
['data-radix-popper-content-wrapper', ''],
['data-footnote-ref', ''],
])('keeps %o, which Studio stylesheets and Radix positioning select on', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['text-anchor', 'middle'],
['dominant-baseline', 'central'],
['dy', '0.71em'],
['dx', '-4'],
['gradientUnits', 'userSpaceOnUse'],
['gradientTransform', 'translate(263 73.5) rotate(-143.669)'],
// Names as they appear in the DOM, which is what rrweb reads. `maskUnits` and
// `gradientUnits` are camelCase in the SVG spec; `shape-rendering` is hyphenated,
// and React's `shapeRendering` prop sets that name.
['maskUnits', 'userSpaceOnUse'],
['shape-rendering', 'geometricPrecision'],
])('keeps SVG placement attribute %o, so geometry stays put', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it('masks the React prop spelling, since rrweb never sees it', () => {
// A guard against allowlisting a camelCase prop name that no DOM attribute matches.
expect(maskReplayAttribute('shapeRendering', 'geometricPrecision')).toBe('*')
})
it.each([
['patternUnits', 'userSpaceOnUse'],
['patternTransform', 'translate(-8,-8)'],
['markerUnits', 'strokeWidth'],
['orient', 'auto-start-reverse'],
['refX', '5'],
['font-size', '10'],
])('keeps %o, which a rendering library emits and depends on', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it.each([
['draggable', 'true'],
['align', 'right'],
['data-field', 'instance-details'],
])('keeps %o, selected on by value in Studio stylesheets', (name, value) => {
expect(maskReplayAttribute(name, value)).toBe(value)
})
it('keeps stroke-dashoffset, which draws the fill level of a usage ring', () => {
// Masked it is invalid, so the browser falls back to 0 and the ring replays full.
expect(maskReplayAttribute('stroke-dashoffset', 'calc(75.39822 - 18.8)')).toBe(
'calc(75.39822 - 18.8)'
)
expect(maskReplayAttribute('stroke-dasharray', '75.39822')).toBe('75.39822')
})
it('passes _cssText through, since it carries our own inlined stylesheets', () => {
const fontFace = "@font-face{src:url('../fonts/inter/InterVariable.woff2') format('woff2')}"
expect(maskReplayAttribute('_cssText', fontFace)).toBe(fontFace)
const checkbox =
'.rdg-cell [type=checkbox]:checked{background-image:url("data:image/svg+xml,%3Csvg%3E")}'
expect(maskReplayAttribute('_cssText', checkbox)).toBe(checkbox)
})
it('still masks url() in an inline style attribute', () => {
expect(maskReplayAttribute('style', 'background-image: url("https://x.test/secret.png")')).toBe(
'background-image: url(*)'
)
})
it('keeps the SVG namespace exemption to id only', () => {
const svgAnchor = document.createElementNS('http://www.w3.org/2000/svg', 'a')
expect(maskReplayAttribute('href', 'https://x.test/secret', svgAnchor)).toBe('*')
const svgImage = document.createElementNS('http://www.w3.org/2000/svg', 'image')
expect(maskReplayAttribute('href', 'https://x.test/secret.png', svgImage)).toBe('*')
})
})
describe('maskReplayNetworkRequest', () => {
it('strips query strings', () => {
expect(
@@ -101,6 +521,14 @@ describe('SESSION_REPLAY_CONFIG', () => {
it('strips sensitive URL parts via maskReplayNetworkRequest', () => {
expect(SESSION_REPLAY_CONFIG.maskCapturedNetworkRequestFn).toBe(maskReplayNetworkRequest)
})
it('masks attributes, which maskTextFn cannot reach', () => {
expect(SESSION_REPLAY_CONFIG.maskAttributeFn).toBe(maskReplayAttribute)
})
it('pins maskAllElementAttributes off, since true would discard maskAttributeFn', () => {
expect(SESSION_REPLAY_CONFIG.maskAllElementAttributes).toBe(false)
})
})
describe('buildSessionRecordingConfig', () => {
+382
View File
@@ -21,6 +21,384 @@ export function maskReplayText(text: string, element?: HTMLElement): string {
return '*'.repeat(text.trim().length)
}
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'
/**
* Masked attributes collapse to one asterisk rather than one per character. Attribute
* length has no effect on how replay lays the page out, so preserving it would publish
* the length of every bucket name, title and URL for nothing. Text nodes still mask
* per-character, where the width does affect layout.
*/
function mask(_value: string): string {
return '*'
}
/**
* Attributes whose value is CSS, so `url()` has to be masked inside them.
*
* `_cssText` is deliberately absent. rrweb puts inlined stylesheet text there, which in
* Studio is our own build output: `fonts.css` points at font files and `grid.css` draws
* the data grid's checkbox marks from `data:` URIs, so masking those targets costs the
* replay its fonts and its checkboxes. The customer content this function exists for
* rides in inline `style` attributes, which stay masked.
*
* Allowing `_cssText` through does not widen what gets recorded. rrweb only sets it for a
* `<link>`, or for a `<style>` with no text content; a `<style>` that has text keeps it as
* a text node, and rrweb never passes `<style>` text to `maskTextFn`. CSS written as
* element text is therefore a channel neither this function nor `maskTextFn` covers,
* whatever this set contains. GROWTH-1229 has the one instance Studio had of writing
* customer data through it, a chart keying its CSS variables by column name, and the
* scope of what the channel still exposes.
*/
const CSS_VALUED_ATTRIBUTES = new Set(['style'])
/**
* Quoted forms are matched first and on their own, so a target containing `)` (legal in a
* storage object name) does not end the match early and leave the rest of it unmasked.
*
* Every alternative consumes backslash escapes. A target containing a quote serializes as
* `\"`, and an unquoted target containing a bracket serializes as `\)`; a naive `[^")]*`
* stops at the backslash and leaves the tail of the URL recorded.
*
* The last two alternatives are fallbacks for a token the escape-aware form cannot parse:
* the escape-blind form, then an unterminated `url(` running to the end of the value.
* Without them an input like `url(trailing\` matches nothing and passes through unmasked,
* which is the wrong direction to fail in. CSSOM serialization should never hand us one,
* so these only exist to keep the failure mode closed.
*/
const CSS_URL =
/url\(\s*"(?:[^"\\]|\\.)*"\s*\)|url\(\s*'(?:[^'\\]|\\.)*'\s*\)|url\((?:[^)\\]|\\.)*\)|url\([^)]*\)|url\([^)]*$/gi
/** A `url()` target that is only a fragment, so it names a node in this document. */
const FRAGMENT_ONLY_URL = /^url\(\s*(['"]?)#[^)'"]*\1\s*\)$/i
/** Case-insensitive, because `URL(#id)` and `url(#id)` are the same CSS function. */
const CONTAINS_CSS_URL = /url\(/i
/**
* Replaces the target of every CSS `url()` while leaving the rest of the declaration
* alone, so gradients and transforms still replay. Fragment-only targets are kept,
* because `clip-path: url(#id)` names a node in the recording rather than a resource.
*
* Studio interpolates customer content into inline background images: a screenshot of
* the user's own dashboard in the feedback widget, and signed object URLs in the storage
* preview panes. Neither text masking nor blocking canvas reaches a CSS background.
*/
export function maskReplayCssUrls(css: string): string {
return css.replace(CSS_URL, (match) => (FRAGMENT_ONLY_URL.test(match) ? match : 'url(*)'))
}
/**
* Attribute names replay needs to reconstruct the page. Everything else is masked, so
* an attribute nobody considered is masked rather than recorded. Compared against the
* lowercased attribute name.
*
* Deliberately excludes every attribute that carries free text (`title`, `alt`,
* `placeholder`, `aria-label`, `value`, `label`) or a URL (`href`, `src`, `srcset`,
* `action`, `poster`), which is where interpolated customer data shows up.
*/
const RENDER_CRITICAL_ATTRIBUTES = new Set([
// rrweb's own synthetic attributes: layout it measured, and stylesheets it inlined.
// posthog-js only exempts these when `maskAllElementAttributes` does the masking, so
// the callback has to allow them itself or replay cannot lay out the page.
'rr_width',
'rr_height',
'rr_left',
'rr_top',
'rr_position',
'rr_transform',
'rr_display',
'rr_scrollleft',
'rr_scrolltop',
'rr_mediastate',
'rr_open_mode',
// rrweb's inlined stylesheet text. See CSS_VALUED_ATTRIBUTES above for why this passes
// through rather than having its url() targets masked.
'_csstext',
// Styling and document structure. `style` is absent: CSS_VALUED_ATTRIBUTES handles it,
// keeping the declarations while masking url() targets.
'class',
'type',
'rel',
'media',
'colspan',
'rowspan',
// SVG geometry, which every icon in Studio depends on
'xmlns',
'viewbox',
'preserveaspectratio',
'd',
'points',
'x',
'y',
'x1',
'x2',
'y1',
'y2',
'cx',
'cy',
'r',
'rx',
'ry',
'width',
'height',
'transform',
'fill-rule',
'fill-opacity',
'clip-rule',
'stroke-width',
'stroke-linecap',
'stroke-linejoin',
'stroke-dasharray',
// Pairs with `stroke-dasharray` to draw a partial ring. `PlanUsageCard` and
// `BillingMetric` set it from a usage ratio, and a masked value is invalid, so the
// browser falls back to 0 and every usage ring replays as full.
'stroke-dashoffset',
'stroke-opacity',
'offset',
'stop-color',
'stop-opacity',
// Text and gradient placement. recharts emits these on every axis tick, and masking
// them shifts the labels even though the label text itself is already asterisks.
'text-anchor',
'dominant-baseline',
'dx',
'dy',
'opacity',
'gradientunits',
// Positions the promo toast's radial gradients. Static values in `PromoBg.tsx`.
'gradienttransform',
'stroke-miterlimit',
// Both take a value from a fixed SVG vocabulary. Spelled as the DOM carries them:
// `maskUnits` is camelCase in the SVG spec, `shape-rendering` is hyphenated, and React's
// `shapeRendering` prop sets the hyphenated one. Set on the mask elements in the MCP
// provider icon assets and on the header and breadcrumb separators.
'maskunits',
'shape-rendering',
// `<pattern>` tiling, which `@xyflow/react`'s `Background` uses to draw the dot grid
// behind the schema graph, the infrastructure diagram and the replication diagram.
// Masked, `patternUnits` is invalid and falls back to `objectBoundingBox`, which scales
// the tile to the whole fill rect and leaves a blank canvas.
'patternunits',
'patterntransform',
// `<marker>` geometry, the arrowhead counterpart to the pattern attributes above and
// emitted by the same library. Integers and a fixed vocabulary.
'markerwidth',
'markerheight',
'markerunits',
'orient',
'refx',
'refy',
// Set on SVG text by recharts tick props. Masked it is invalid, so labels inherit the
// surrounding size and the chart relayouts around them.
'font-size',
'font-weight',
'font-family',
// Enumerated state. Values come from a fixed vocabulary rather than user input, and
// Tailwind variants select on the `data-` ones.
'disabled',
'checked',
'selected',
'readonly',
'required',
'multiple',
'open',
'hidden',
'dir',
'lang',
'role',
'tabindex',
'aria-hidden',
'aria-expanded',
'aria-selected',
'aria-checked',
'aria-disabled',
'aria-current',
'aria-haspopup',
'aria-modal',
'aria-orientation',
'aria-live',
'aria-level',
'aria-invalid',
'aria-pressed',
// Sizes a textarea's box, so masking it collapses every textarea in replay to the
// two-row default.
'rows',
'cols',
// Selected on by value in Studio's own stylesheets: `grid.css` sizes the grid header's
// drag wrapper via `div[draggable='true']`, and `markdown-preview.css` pads floated
// images via `img[align='right'|'left']`. Both vocabularies are fixed.
'draggable',
'align',
// Every `data-` attribute anything selects on, derived by grepping rather than from
// memory. Two greps are needed, and the first one alone is what let `data-chart`,
// sonner's toast markers and the Radix portal attributes through as masked:
//
// Tailwind variants: data-\[([a-z-]+) over packages/ui, packages/ui-patterns, apps/studio
// CSS selectors: \[data-[a-z-]+ over apps/studio/styles/*.css and every
// shipped UI lib in node_modules: sonner, vaul,
// cmdk, @xyflow/react, react-data-grid,
// react-medium-image-zoom, monaco-editor, radix
//
// Re-run both when adding one. A masked attribute that a stylesheet selects on is
// invisible in the unit tests and only shows up as broken rendering in a replay.
'data-state',
'data-side',
'data-align',
'data-orientation',
'data-disabled',
'data-highlighted',
'data-placeholder',
'data-slot',
'data-active',
'data-collapsible',
'data-expanded',
'data-front',
'data-invalid',
'data-invisible',
'data-motion',
'data-selected',
'data-separator',
'data-sidebar',
'data-size',
'data-variant',
'data-vaul-drawer-direction',
// `ComputeSizeSelector` hides the price rows inside its closed trigger with
// `[&>span>div>div>[data-field=instance-details]]:hidden`. Masked, the selector stops
// matching and the project creation form replays with two extra rows and a taller
// trigger. One literal value.
'data-field',
// Scopes the chart colour variables. `ChartStyle` writes `[data-chart=<id>]` into a
// `<style>` element, and rrweb records `<style>` text unmasked, so masking the
// attribute leaves a selector that can no longer match and every chart loses its
// series colours. The value is always `chart-${useId()}`; no caller passes `id`.
'data-chart',
// Presence and state markers set by the UI libraries we ship, found by grepping
// `\[data-[a-z-]+` through sonner, vaul and cmdk. Plain CSS attribute selectors, so
// the Tailwind-variant grep above does not see them. sonner's base rule hides every
// toast until `[data-mounted="true"]` matches, so masking these makes toasts and
// drawers invisible in replay rather than merely unstyled.
// `data-` attributes the shipped UI libraries set with a value. Presence-only markers
// are absent on purpose: posthog-js returns an empty attribute before it reaches this
// callback, so allowlisting one does nothing for rendering and only widens the surface
// if a component later writes a value into a generically named attribute.
'data-sonner-toast',
'data-sonner-toaster',
'data-mounted',
'data-visible',
'data-removed',
'data-swiping',
'data-swipe-out',
'data-styled',
'data-type',
'data-x-position',
'data-y-position',
'data-rich-colors',
'data-invert',
'data-promise',
'data-content',
'data-button',
'data-cancel',
'data-close-button',
'data-vaul-drawer',
'data-vaul-drawer-wrapper',
'data-vaul-overlay',
'data-vaul-handle',
'data-vaul-handle-hitarea',
'data-vaul-snap-points',
'data-vaul-snap-points-overlay',
'data-vaul-delayed-snap-points',
'data-vaul-animate',
'data-vaul-no-drag',
'data-vaul-custom-container',
// Integer index, selected on by monaco-editor's quick-input CSS
// (`[data-index="0"] .quick-in...`) to drop the separator border on the first row.
'data-index',
// 0 to 4, selected on by `Select26Promotion.module.css` as `.cell[data-band='N']`.
// The banner ships in Studio through `BannerSelect2026.tsx`.
'data-band',
// Selected on by Studio's own stylesheets and by Radix's portal positioning.
'data-footnote-ref',
'data-radix-portal',
'data-radix-popper-content-wrapper',
'data-rmiz-modal',
'data-rmiz-modal-img',
'data-rmiz-modal-overlay',
])
/**
* `data-value` is deliberately absent from the allowlist above, even though two places
* select on it: `multi-select.tsx` has a `peer-data-[value=true]` variant, and
* `graphiql.module.css` hides sidebar buttons via `[data-value='settings']` and
* `[data-value='short-keys']`. cmdk writes the item's own value into this attribute,
* which in Studio is customer content such as a column name, so it stays masked. The
* cost is a hover background and some GraphiQL buttons reappearing in a replay.
*/
/**
* Themes Studio can be in. `next-themes` writes the active one to `data-theme` on
* `<html>` (its default attribute, which nothing overrides), and monaco.css, grid.css,
* ui.css and markdown-preview.css all select on it, so masking it strips dark styling
* from the SQL editor and the data grid. Gated on the value so the attribute cannot
* carry anything else.
*/
const THEME_VALUES = new Set(['dark', 'light', 'classic-dark', 'system'])
/**
* SVG presentation attributes that take either a plain value (`fill="#fff"`) or a
* reference to another node (`fill="url(#gradient)"`). recharts clips every series with
* `clip-path`, so these have to survive, but only the in-document form: an external
* `url(https://...)` here would be a recorded URL like any other.
*/
const SVG_REFERENCE_ATTRIBUTES = new Set([
'fill',
'stroke',
'clip-path',
'mask',
'filter',
'marker-start',
'marker-mid',
'marker-end',
])
/**
* Returns asterisks for every attribute value except the ones replay needs to render.
*
* Attributes are a separate capture channel from text: `maskTextFn` only sees DOM text
* nodes, so a component interpolating customer data into a `placeholder` or `title`
* records it verbatim without this.
*/
export function maskReplayAttribute(name: string, value: string, element?: Element): string {
const attributeName = name.toLowerCase()
if (CSS_VALUED_ATTRIBUTES.has(attributeName)) return maskReplayCssUrls(value)
if (RENDER_CRITICAL_ATTRIBUTES.has(attributeName)) return value
if (attributeName === 'data-theme') return THEME_VALUES.has(value) ? value : mask(value)
if (SVG_REFERENCE_ATTRIBUTES.has(attributeName)) {
return CONTAINS_CSS_URL.test(value) && !FRAGMENT_ONLY_URL.test(value.trim())
? mask(value)
: value
}
// Stylesheet URLs, for sheets rrweb could not inline. Checked by namespace and
// `localName` because an XHTML document reports `tagName` lowercased, and gated on
// `rel` so a `<link rel="preload" as="image">` pointing at customer content does not
// ride through. Anchor and image URLs stay masked, since project and storage paths
// ride in them.
if (
attributeName === 'href' &&
element?.namespaceURI === HTML_NAMESPACE &&
element.localName === 'link' &&
element.getAttribute('rel') === 'stylesheet'
) {
return value
}
// SVG ids are generated by the charting library, and `fill="url(#id)"` references
// them, so masking them breaks gradients and clip paths. HTML ids stay masked
// because Studio binds customer-named values to them (storage bucket names).
if (attributeName === 'id' && element?.namespaceURI === SVG_NAMESPACE) return value
return mask(value)
}
/**
* Strips query strings and fragments from recorded URLs, which posthog-js applies
* to page URLs as well as network requests. Auth callbacks carry tokens in the
@@ -41,6 +419,10 @@ export const SESSION_REPLAY_CONFIG: SessionRecordingOptions = {
maskAllInputs: true,
maskTextSelector: '*',
maskTextFn: maskReplayText,
maskAttributeFn: maskReplayAttribute,
// posthog-js treats this and `maskAttributeFn` as mutually exclusive, and it resolves
// from the PostHog UI when unset. Pinning it to false keeps the policy above in force.
maskAllElementAttributes: false,
// Keeps network capture to URL, status and timing. Overrides the PostHog UI.
recordHeaders: false,
recordBody: false,
+1 -1
View File
@@ -21,7 +21,7 @@
"flags": "^4.0.0",
"lodash": "catalog:",
"next-themes": "catalog:",
"posthog-js": "^1.391.2",
"posthog-js": "^1.416.1",
"react-use": "^17.4.0",
"valtio": "catalog:"
},
+54 -26
View File
@@ -2335,8 +2335,8 @@ importers:
specifier: 'catalog:'
version: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
posthog-js:
specifier: ^1.391.2
version: 1.391.2
specifier: ^1.416.1
version: 1.428.3(@types/react@19.2.14)(react@19.2.6)
react:
specifier: 'catalog:'
version: 19.2.6
@@ -6219,11 +6219,14 @@ packages:
'@poppinss/exception@1.2.2':
resolution: {integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==}
'@posthog/core@1.35.3':
resolution: {integrity: sha512-EsGPbSLl39Jgo2KZ+kI9UAxFnh5nddaN5bNm2rXvUwF+vGmam9eN1EXeNbxhRU7ulEeIiGdm7XjoU7pzavkgIQ==}
'@posthog/browser-common@0.8.2':
resolution: {integrity: sha512-8g7+ijrx8bfWmpDjmP07e0ZmdRnJoFqZ6PCcMQvfBTUrr422GRXvQWpQn7yD028zIJHIIn/rUTipKgUC5UkWpw==}
'@posthog/types@1.390.2':
resolution: {integrity: sha512-WcfKz2GNn2vfDX8vXmJYbKxegPxVWHuDQ/pHdAn0HoZDXDFnEp/+x3qBQA+fEvtbPjjtjgAt2wIgJMlM7asx7g==}
'@posthog/core@1.51.0':
resolution: {integrity: sha512-LilmmtjcrjV1LgaVNQXrAUt4IXcWIYMrwEtx6VJUUFDeBBdmeI99jshKGtAWugYeB3Wkcp9xQIVCrxIYRpNiRA==}
'@posthog/types@1.409.1':
resolution: {integrity: sha512-lA2sSGQUdVknSQl93uN9eUXDwkCMjAuthq24GONGHzMWwVygsKNHlWCnlJsVv2giDokj2p1NopO4zsE2S7gYjA==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -10647,8 +10650,8 @@ packages:
copy-to-clipboard@3.3.3:
resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==}
core-js@3.44.0:
resolution: {integrity: sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==}
core-js@3.50.0:
resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==}
core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
@@ -15607,15 +15610,28 @@ packages:
postgres-range@1.1.4:
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
posthog-js@1.391.2:
resolution: {integrity: sha512-q0DZN6ljchSnAFJIXf+sQFTPlsLjTlRa+TvrL+QRb6413BGtib/MNiQy1bnwLKt8KR+f6xJYvkqdLyty9s4Aww==}
posthog-js@1.428.3:
resolution: {integrity: sha512-39BloFeL5koaAC8FmbDFx1RQGUwZkqO46M2d1wCi2vVkmiv+TrjC37t5hT9QjAszAeGn7gXeBX7E+MgND5Tygw==}
peerDependencies:
'@types/react': '>=16.8.0'
react: '>=16.8.0'
peerDependenciesMeta:
'@types/react':
optional: true
react:
optional: true
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
preact@10.29.2:
resolution: {integrity: sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==}
preact@10.29.8:
resolution: {integrity: sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==}
peerDependencies:
preact-render-to-string: '>=5'
peerDependenciesMeta:
preact-render-to-string:
optional: true
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
@@ -18225,8 +18241,8 @@ packages:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
web-vitals@5.3.0:
resolution: {integrity: sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==}
web-vitals@6.2.1:
resolution: {integrity: sha512-rLcLXA2sx6+9dE88NHFubwTtGxpK4yYBLj6qHPdFoCaLr0cXGb4efOqtKLlm4loGA4OEKHIQKMKzZkKyOh5ctw==}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -22559,11 +22575,16 @@ snapshots:
'@poppinss/exception@1.2.2': {}
'@posthog/core@1.35.3':
'@posthog/browser-common@0.8.2':
dependencies:
'@posthog/types': 1.390.2
'@posthog/core': 1.51.0
'@posthog/types': 1.409.1
'@posthog/types@1.390.2': {}
'@posthog/core@1.51.0':
dependencies:
'@posthog/types': 1.409.1
'@posthog/types@1.409.1': {}
'@radix-ui/number@1.1.1': {}
@@ -27643,7 +27664,7 @@ snapshots:
dependencies:
toggle-selection: 1.0.6
core-js@3.44.0: {}
core-js@3.50.0: {}
core-util-is@1.0.2: {}
@@ -33788,20 +33809,27 @@ snapshots:
postgres-range@1.1.4: {}
posthog-js@1.391.2:
posthog-js@1.428.3(@types/react@19.2.14)(react@19.2.6):
dependencies:
'@posthog/core': 1.35.3
'@posthog/types': 1.390.2
core-js: 3.44.0
'@posthog/browser-common': 0.8.2
'@posthog/core': 1.51.0
'@posthog/types': 1.409.1
core-js: 3.50.0
dompurify: 3.4.13
fflate: 0.4.8
preact: 10.29.2
preact: 10.29.8
query-selector-shadow-dom: 1.0.1
web-vitals: 5.3.0
web-vitals: 6.2.1
web-vitals-soft-navs: web-vitals@6.2.1
optionalDependencies:
'@types/react': 19.2.14
react: 19.2.6
transitivePeerDependencies:
- preact-render-to-string
powershell-utils@0.1.0: {}
preact@10.29.2: {}
preact@10.29.8: {}
prelude-ls@1.2.1: {}
@@ -36910,7 +36938,7 @@ snapshots:
web-streams-polyfill@4.0.0-beta.3: {}
web-vitals@5.3.0: {}
web-vitals@6.2.1: {}
webidl-conversions@3.0.1: {}