Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/privacy-at-capture-url-sanitization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"rrweb": minor
"rrweb-snapshot": minor
"@rrweb/types": minor
---

Privacy at Capture: recorded-DOM URL sanitization (**experimental** -- no
vendor precedent, review this hardest).

- Under `balanced`/`strict`, every URL-bearing attribute the serializer emits
and the Meta event's `href` go through `sanitizeUrl`: userinfo is
stripped, sensitive query parameter values are replaced with `*`
(`url.blockedQueryParameters` plus a default list), and the hash is
removed unless `url.removeHash: false`. `strict` blocks every parameter
value unless `url.allowedQueryParameters` names it.
- **EXPERIMENTAL, open design question for upstream:** the Meta event's
`href` (via the new `sanitizeMetaUrl`) is scoped like `balanced` even
under `strict` -- masking only blocked-list parameters -- because it is
the recording's own address, not page-author markup; every DOM URL
attribute keeps `strict`'s normal mask-everything-unless-allowlisted
treatment.
- An unparseable URL fails closed: the attribute is dropped (`null`) rather
than emptied, since an empty `src`/`href` re-resolves to the document URL
at replay. A _relative_ URL in an attribute rrweb does not already
absolutify (`<form action>`, `<video poster>`, ...) is rewritten
root-relative -- `pay/confirm` records as `/pay/confirm` -- because
sanitization parses against an internal base and re-serializes the path.
- The unmask escape cannot reopen a sanitized URL.
- Rebased onto the renamed rule actions (`mask`/`block`/`unmask`) and the
opt-in `vendorCompat` flag; URL sanitization itself keys off the managed
presets and is unaffected by either.
52 changes: 39 additions & 13 deletions guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ you get by default (the `minimal` preset, below):
- `input[type="password"]` will be masked by default.
- Mask options to mask the content in input elements.

For a consistent policy across text, inputs, and attributes, pass a
For a consistent policy across text, inputs, attributes, and URLs, pass a
versioned `privacyPolicy`:

```js
Expand All @@ -240,17 +240,37 @@ record({
action: 'block',
},
],
url: {
blockedQueryParameters: ['token', 'session'],
},
},
});
```

`preset` compiles to the following, on top of the `rules` above:

| preset | behavior |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `minimal` (default) | Inert: only the existing masking options above apply. `rules` still work (below), but the `data-privacy` attribute and cross-vendor class recognition described below are off. |
| `balanced` | Masks every input value (like `maskAllInputs: true`); masks the `title`, `placeholder`, and `aria-label` attributes on every element. Page text is untouched. |
| `strict` | Everything `balanced` does, plus: all page text is masked; media element sources (`<img>`, `<video>`, `<audio>`, `<iframe>`, `<embed>`, `<object>`, `<source>`) are dropped instead of captured, except that an `<img>` source or `<video>` poster on an element with declared integer `width`/`height` attributes is replaced by a neutral same-size placeholder image so the surrounding layout does not collapse; and canvas recording is disabled outright, even with a `canvasMasking` adapter configured. |
| preset | behavior |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `minimal` (default) | Inert: only the existing masking options above apply. `rules` still work (below), but the `data-privacy` attribute and cross-vendor class recognition described below are off. |
| `balanced` | Masks every input value (like `maskAllInputs: true`); masks the `title`, `placeholder`, and `aria-label` attributes on every element; sanitizes URLs -- strips `username`/`password` (userinfo), removes the value of any query parameter in a default sensitive list (`access_token`, `auth`, `code`, `key`, `password`, `secret`, `session`, `token`) plus any configured `url.blockedQueryParameters`, and removes the hash unless `url.removeHash: false`. Query parameter names stay visible. Page text is untouched. |
| `strict` | Everything `balanced` does, plus: all page text is masked; media element sources (`<img>`, `<video>`, `<audio>`, `<iframe>`, `<embed>`, `<object>`, `<source>`) are dropped instead of captured, except that an `<img>` source or `<video>` poster on an element with declared integer `width`/`height` attributes is replaced by a neutral same-size placeholder image so the surrounding layout does not collapse; canvas recording is disabled outright, even with a `canvasMasking` adapter configured; and URL sanitization blocks _every_ query parameter's value unless you also set `url.allowedQueryParameters` to an explicit allow-list. |

> **URL sanitization is experimental.** No session-replay vendor sanitizes
> URLs inside the recorded DOM: PostHog, Highlight, Sentry, Amplitude and
> Mixpanel all record `href`/`src` verbatim and scrub URLs in their ingestion
> pipeline instead, if at all. rrweb's in-DOM `sanitizeUrl` is its own design,
> not an established pattern, and it rewrites attribute values that the replay
> then depends on. An unparseable URL is dropped entirely (the attribute is
> removed) rather than emptied.
>
> One deliberate asymmetry within that design, itself an open question for
> upstream: the Meta event's own `href` -- the recording's address, not
> markup a page author wrote -- is scoped like `balanced` (only the
> blocked-list parameters are masked) even under `strict`, where every DOM
> URL attribute masks every parameter unless explicitly allowlisted.
> Treating the Meta href identically to an arbitrary `<a href>` would make
> `strict` unable to say which page a session happened on, since most apps
> put routing state in their own URL.

`minimal` is a permanent tier, not a transitional one: it is masking you
configure yourself, through the classic options above. Password and
Expand Down Expand Up @@ -503,8 +523,8 @@ covers text and the preset's masked attributes (`title`, `placeholder`,
`aria-label`) on elements inside the matched subtree -- this option, a policy
`unmask` rule and a recognized unmask class are merged into one
selector and behave identically. It cannot unmask input
values, and it cannot override a protected input, a dropped media source
under `strict`, or a `block`.
values or a sanitized URL, and it cannot override a protected input, a
dropped media source under `strict`, or a `block`.

An invalid `maskTextSelector`, `unmaskTextSelector` or `blockSelector` --
either the `record()`-level string option or a policy rule's selector -- is
Expand Down Expand Up @@ -540,11 +560,11 @@ mutually exclusive: if both are supplied, `maskAllElementAttributes` wins and
`maskAttributeFn` is ignored, with a one-time console warning. A throwing
`maskAttributeFn` fails closed to stars rather than leaking the original
value. Under `minimal`, `maskAttributeFn`'s return value is used as-is; under
`balanced`/`strict` the compiled policy still runs on top of it
(`title`/`placeholder`/`aria-label` masking, `strict`'s media-source drop)
and can only
narrow what the callback chose to keep, never restore something the policy
would otherwise mask. `style`/`_cssText` are exempt from all of this.
`balanced`/`strict` the compiled policy still runs on top of it (URL
sanitization, `title`/`placeholder`/`aria-label` masking, `strict`'s
media-source drop) and can only narrow what the callback chose to keep,
never restore something the policy would otherwise mask. `style`/`_cssText`
are exempt from all of this.
Likewise, under `minimal`, `maskInputFn`'s return value is trusted verbatim;
under `balanced`/`strict`, `maskInputFn` output is star-replaced -- the
callback controls length, never content. Every callback fails closed the
Expand Down Expand Up @@ -650,6 +670,12 @@ Breaking changes versus pre-2.0 masking, for anyone upgrading:
and **unstable**: exported for cross-package use and direct unit testing,
not part of the supported API, and free to change or disappear without a
major bump.
- Under URL sanitization (`balanced`/`strict`), a **relative** URL in an
attribute rrweb does not already absolutify -- `<form action>`,
`<video poster>`, and similar -- is rewritten root-relative: `pay/confirm`
is recorded as `/pay/confirm`. Sanitization parses against an internal
base URL and re-serializes the path, which normalizes away the difference
between a document-relative and a root-relative reference.

##### For event consumers

Expand Down
107 changes: 106 additions & 1 deletion packages/rrweb-snapshot/src/privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,18 @@ const RENDERING_METADATA_ATTRIBUTES = new Set([

const OPERATIONAL_ATTRIBUTES = new Set(['data-privacy', 'data-rr-is-password']);

const URL_ATTRIBUTES = new Set([
'action',
'background',
'data',
'formaction',
'href',
'poster',
'rr_src',
'src',
'xlink:href',
]);

/** Attributes that point at media bytes; dropped entirely under `strict`. */
const MEDIA_SOURCE_ATTRIBUTES = new Set([
'background',
Expand Down Expand Up @@ -252,6 +264,17 @@ export const FORM_VALUE_TAGS = new Set([
'TEXTAREA',
]);

const DEFAULT_BLOCKED_QUERY_PARAMETERS = [
'access_token',
'auth',
'code',
'key',
'password',
'secret',
'session',
'token',
];

/** Occludes text content, preserving whitespace so layout survives; contrast `stars`, which occludes to length. */
function starText(value: string): string {
return value.replace(/[\S]/g, '*');
Expand Down Expand Up @@ -416,6 +439,7 @@ export function compilePrivacyPolicy(
);
const maskedAttributes = new Set(managed ? MASKED_ATTRIBUTE_DEFAULTS : []);
const blockMedia = preset === 'strict';
const sanitizeUrls = managed;

const bySelector = {
mask: [] as string[],
Expand Down Expand Up @@ -472,8 +496,22 @@ export function compilePrivacyPolicy(
: null,
maskAllInputs: managed,
maskedAttributes,
attributePolicyInert: !blockMedia && maskedAttributes.size === 0,
attributePolicyInert:
!blockMedia && !sanitizeUrls && maskedAttributes.size === 0,
blockMedia,
sanitizeUrls,
blockedQueryParameters: new Set(
[
...DEFAULT_BLOCKED_QUERY_PARAMETERS,
...(effective.url?.blockedQueryParameters || []),
].map((n) => n.toLowerCase()),
),
allowedQueryParameters: effective.url?.allowedQueryParameters
? new Set(
effective.url.allowedQueryParameters.map((n) => n.toLowerCase()),
)
: null,
removeHash: effective.url?.removeHash !== false,
};
}

Expand Down Expand Up @@ -747,6 +785,7 @@ export function finalizeAttribute({
if (MEDIA_TAGS.has(tagName))
return blockedMediaValue(element, tagName, normalizedName);
}
if (URL_ATTRIBUTES.has(normalizedName)) return sanitizeUrl(current, privacy);
if (privacy.maskedAttributes.has(normalizedName)) {
return isUnmasked(element, privacy, unmaskMemo) ? current : stars(current);
}
Expand Down Expand Up @@ -794,3 +833,69 @@ export function finalizeAttributes(
});
}
}

/**
* EXPERIMENTAL: no session-replay vendor sanitizes URLs in the recorded DOM;
* see the changeset.
*
* `paramsMode` is an internal knob, not part of the public policy surface:
* `'preset'` (the default) is `strict`'s normal mask-every-param-unless-
* allowlisted behavior; `'blocklist'` forces the `balanced` treatment
* (mask only `blockedQueryParameters`/non-`allowedQueryParameters`) even
* under `strict`. `sanitizeMetaUrl` is the one caller that needs it -- see
* its doc comment. This keeps the preset special-case inside the URL layer
* instead of leaking a `strict`-vs-Meta branch into core.
*/
export function sanitizeUrl(
value: string,
privacy: CompiledPrivacyPolicy | undefined,
{ paramsMode = 'preset' }: { paramsMode?: 'preset' | 'blocklist' } = {},
): string | null {
if (!value) return value;
if (!privacy || !privacy.sanitizeUrls) return value;
try {
const url = new URL(value, 'https://rrweb.invalid');
url.username = '';
url.password = '';
const maskAllParams =
paramsMode === 'preset' &&
privacy.preset === 'strict' &&
!privacy.allowedQueryParameters;
for (const [name] of url.searchParams) {
const lower = name.toLowerCase();
if (
maskAllParams ||
(privacy.allowedQueryParameters &&
!privacy.allowedQueryParameters.has(lower)) ||
privacy.blockedQueryParameters.has(lower)
) {
url.searchParams.set(name, '*');
}
}
if (privacy.removeHash) url.hash = '';
if (url.origin === 'https://rrweb.invalid')
return `${url.pathname}${url.search}${url.hash}`;
return url.toString();
} catch {
return null;
}
}

/**
* EXPERIMENTAL, open design question for upstream: sanitizes the Meta
* event's `window.location.href` with blocked-list-only parameter masking
* (the `balanced` treatment), even under `strict`, where every other URL in
* the recorded DOM masks every param unless explicitly allowlisted. The
* Meta event's URL is the recording's own address bar, not markup the page
* author wrote -- treating it identically to an arbitrary `<a href>` would
* make `strict` unusable for reconstructing which page a session happened
* on, since almost every app puts routing state in its own URL. Whether
* that asymmetry is the right default, versus a dedicated option, is not
* settled; see the changeset.
*/
export function sanitizeMetaUrl(
value: string,
privacy: CompiledPrivacyPolicy | undefined,
): string | null {
return sanitizeUrl(value, privacy, { paramsMode: 'blocklist' });
}
1 change: 1 addition & 0 deletions packages/rrweb-snapshot/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export type {
PrivacyRule,
PrivacyTarget,
VendorCompatId,
PrivacyUrlOptions,
CompiledPrivacyPolicy,
} from '@rrweb/types';

Expand Down
Loading
Loading