diff --git a/packages/instrumentation-tests/test/replay-masking.browser.e2e.ts b/packages/instrumentation-tests/test/replay-masking.browser.e2e.ts
index 9eade8cf..4cc08433 100644
--- a/packages/instrumentation-tests/test/replay-masking.browser.e2e.ts
+++ b/packages/instrumentation-tests/test/replay-masking.browser.e2e.ts
@@ -623,6 +623,230 @@ describe('replay masking — REAL Chromium + REAL rrweb bundle', () => {
});
}
+ // ---------------------------------------------------------------------------------------------------------
+ // Secrets in every place a page keeps them: prefilled and typed input values (text, password, card, e-mail),
+ // textarea, `alt`, `title`, `placeholder`, `aria-label`, `data-*`, element text; both present BEFORE recording
+ // starts (the full snapshot) and ADDED / TYPED into after (the mutation path); existing nodes get typed values and a second section is inserted.
+ // ---------------------------------------------------------------------------------------------------------
+ const ATTR_CATEGORIES = [
+ 'TEXT',
+ 'PASSWORD',
+ 'CC',
+ 'EMAIL',
+ 'TEXTAREA',
+ 'ALT',
+ 'IMGTITLE',
+ 'TITLE',
+ 'DATA',
+ 'ARIA',
+ 'LINK',
+ 'PH',
+ 'DATAUSER',
+ 'DIVTITLE',
+ 'DIV',
+ 'TYPED',
+ 'PWTYPED',
+ 'CCTYPED',
+ ];
+ // the always-masked floor: never visible, whatever the options
+ const FLOOR = ['PASSWORD', 'CC', 'PWTYPED', 'CCTYPED'];
+ const attrSecrets = (phase: string) => ATTR_CATEGORIES.map((c) => `ATTRSMOKE_${c}_${phase}`);
+ const attrLeaks = (events: string) =>
+ attrSecrets('PRE')
+ .concat(attrSecrets('ADDED'))
+ .filter((s) => events.includes(s));
+
+ const insertAttrFixture = (phase: string) =>
+ page.evaluate((p) => {
+ const mount = document.getElementById('mount') as HTMLElement;
+ mount.insertAdjacentHTML(
+ 'beforeend',
+ `
+
+
+
+
+
+
+ATTRSMOKE_LINK_${p}
+
+
ATTRSMOKE_DIV_${p}
+
+`,
+ );
+ }, phase);
+
+ const typeAttrSecret = (phase: string) =>
+ page.evaluate((p) => {
+ const el = document.querySelector(
+ `#smoke_${p} input.typed`,
+ ) as HTMLInputElement;
+ el.value = `ATTRSMOKE_TYPED_${p}`;
+ el.dispatchEvent(new Event('input', { bubbles: true }));
+ for (const [cls, tag] of [
+ ['pw', 'PWTYPED'],
+ ['cc', 'CCTYPED'],
+ ] as const) {
+ const f = document.querySelector(
+ `#smoke_${p} input.${cls}`,
+ ) as HTMLInputElement;
+ f.value = `ATTRSMOKE_${tag}_${p}`;
+ f.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ }, phase);
+
+ const runAttrSmoke = async (mode: Mode, options: Record) => {
+ await open();
+ await insertAttrFixture('PRE');
+ await start(mode, options);
+ await settle();
+ await insertAttrFixture('ADDED');
+ await typeAttrSecret('PRE');
+ await typeAttrSecret('ADDED');
+ await settle(400);
+ return dump();
+ };
+
+ for (const mode of ['raw', 'provider'] as const) {
+ it(`attribute/value smoke (${mode}): no secret in values, text, alt, title, placeholder, aria, data-* at defaults`, async () => {
+ const events = await runAttrSmoke(mode, {});
+ expect(events).toContain('TOP_CONTROL'); // the harness sees the page (control)
+ expect(events).toContain('smoke_PRE'); // ...the snapshot section (ids are not masked)
+ // the password field IS in the snapshot (as masked/ignored inputs), so their absence of secrets below is a
+ // statement about masking, not about the recorder never seeing them. Typed events on them are dropped by the
+ // sensitive-input ignore rule, so the PWTYPED/CCTYPED checks guard that rule and have no failing control by design.
+ expect(events).toContain('"type":"password"');
+ expect(events).toContain('"class":"cc"'); // ...and so is the card field
+ expect(events).toContain('smoke_ADDED'); // ...and the node added after start reached the events
+ expect(attrLeaks(events)).toEqual([]);
+ await page.close();
+ });
+ }
+
+ for (const mode of ['raw', 'provider'] as const) {
+ // At defaults the alt/title are hidden because the image is BLOCKED; with media unblocked the
+ // attribute masking itself must hide them (the control below proves they are detectable then).
+ it(`attribute smoke (${mode}): with blockAllMedia off, attribute masking still hides alt/title/data-*`, async () => {
+ const events = await runAttrSmoke(mode, { blockAllMedia: false });
+ expect(events).toContain('smoke_ADDED');
+ expect(events).toContain('smoke_PRE');
+ expect(events).toContain('R0lGODlhAQABAAAAACw'); // the image IS serialized (not a blocked placeholder)
+ expect(attrLeaks(events)).toEqual([]);
+ await page.close();
+ });
+ }
+
+ for (const mode of ['raw', 'provider'] as const) {
+ it(`control (${mode}): the attribute smoke fixture DOES leak with masking off (every category, both phases)`, async () => {
+ const events = await runAttrSmoke(mode, {
+ maskAllText: false,
+ maskAllInputs: false,
+ blockAllMedia: false,
+ });
+ const found = attrLeaks(events);
+ for (const phase of ['PRE', 'ADDED']) {
+ // the always-masked sensitive inputs (password, card) must NOT appear even here: the hard floor
+ for (const c of FLOOR) expect(found).not.toContain(`ATTRSMOKE_${c}_${phase}`);
+ // everything else was recorded in the clear, so the harness can see each category
+ for (const c of ATTR_CATEGORIES.filter((x) => !FLOOR.includes(x))) {
+ expect(found, `${c} (${phase}) should be visible when masking is off`).toContain(
+ `ATTRSMOKE_${c}_${phase}`,
+ );
+ }
+ }
+ await page.close();
+ });
+ }
+
+ // ---------------------------------------------------------------------------------------------------------
+ // document.open(): rewrites a document in place (replaces a frame's content without a navigation).
+ // ---------------------------------------------------------------------------------------------------------
+ const rewriteFrame = (id: string, html: string) =>
+ page.evaluate(
+ ([frameId, markup]) => {
+ const d = (document.getElementById(frameId as string) as HTMLIFrameElement)
+ .contentDocument as Document;
+ d.open();
+ d.write(markup as string);
+ d.close();
+ },
+ [id, html] as const,
+ );
+
+ const REWRITTEN =
+ '
FRAME4_SECRET_REWRITTEN
CONTROL_REWRITTEN
';
+
+ for (const mode of ['raw', 'provider'] as const) {
+ it(`document.open (${mode}): a blocked iframe rewritten in place contributes nothing`, async () => {
+ await open();
+ await mountFrames({ id: 'a', n: 1 });
+ await start(mode);
+ await settle();
+ await rewriteFrame('a', REWRITTEN);
+ await settle(400);
+ await pokeFrame('a', 'AFTEROPEN');
+ await settle();
+ const events = await dump();
+ expect(events).toContain('TOP_CONTROL');
+ expect(events).not.toContain('CONTROL_REWRITTEN');
+ expect(events).not.toContain('FRAME4_SECRET');
+ expect(events).not.toContain('TYPED_SECRET_REWRITTEN');
+ expect(leaks(events)).toEqual([]);
+ await page.close();
+ });
+
+ it(`document.open (${mode}): an unblocked iframe rewritten in place stays masked`, async () => {
+ await open();
+ await mountFrames({ id: 'a', n: 1, attrs: 'class="bugsee-show"' });
+ await start(mode);
+ await settle(400);
+ await rewriteFrame('a', REWRITTEN);
+ await settle(400);
+ await pokeFrame('a', 'AFTEROPEN');
+ await settle();
+ const events = await dump();
+ expect(events).toContain('CONTROL_FRAME1'); // the unblocked frame was recorded before the rewrite (control)
+ expect(events).toContain('CONTROL_REWRITTEN'); // ...and the rewritten document is still being recorded
+ expect(events).not.toContain('FRAME4_SECRET');
+ expect(events).not.toContain('TYPED_SECRET_REWRITTEN');
+ expect(leaks(events)).toEqual([]);
+ await page.close();
+ });
+ }
+
+ for (const mode of ['raw', 'provider'] as const) {
+ it(`control (${mode}): document.open content and later edits DO leak with masking off; a blocked iframe still does not`, async () => {
+ await open();
+ await mountFrames({ id: 'a', n: 1, attrs: 'class="bugsee-show"' }, { id: 'b', n: 3 });
+ await start(mode, { maskAllText: false, maskAllInputs: false });
+ await settle(400);
+ // distinct markers per frame, so a leak can be attributed to the frame it came from
+ await rewriteFrame('a', REWRITTEN.replaceAll('REWRITTEN', 'REWRITTEN_UNBLOCKED'));
+ await rewriteFrame('b', REWRITTEN.replaceAll('REWRITTEN', 'REWRITTEN_BLOCKED'));
+ await settle(500);
+ await pokeFrame('a', 'AFTEROPEN_UNBLOCKED');
+ await pokeFrame('b', 'AFTEROPEN_BLOCKED');
+ await settle(500);
+ const events = await dump();
+ // visible in the unblocked frame: the harness can fail, for the rewrite and for what changes after it
+ for (const marker of [
+ 'FRAME4_SECRET_REWRITTEN_UNBLOCKED',
+ 'TYPED_SECRET_REWRITTEN_UNBLOCKED',
+ 'CONTROL_REWRITTEN_UNBLOCKED',
+ 'IFB_SECRET_EDIT_AFTEROPEN_UNBLOCKED',
+ 'IFB_SECRET_ADDED_AFTEROPEN_UNBLOCKED',
+ 'TYPED_SECRET_AFTEROPEN_UNBLOCKED',
+ 'IFB_SECRET_PH_AFTEROPEN_UNBLOCKED',
+ ]) {
+ expect(events, marker).toContain(marker);
+ }
+ // ...but nothing at all from the blocked one
+ expect(events).not.toContain('REWRITTEN_BLOCKED');
+ expect(events).not.toContain('AFTEROPEN_BLOCKED');
+ await page.close();
+ });
+ }
+
it('control: with maskAllText and maskAllInputs switched off the same page DOES leak (the harness can fail)', async () => {
await open();
await mountFrames({ id: 'a', n: 3, attrs: 'class="bugsee-show"' });