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
63 changes: 41 additions & 22 deletions packages/all/test/cross-origin-iframe-packer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,35 +50,42 @@ interface IWindow extends Window {
}
type ExtraOptions = {
usePackFn?: boolean;
crossOrigin?: boolean;
};

async function injectRecordScript(
frame: puppeteer.Frame,
options?: ExtraOptions,
allowedOrigins?: string[],
) {
await frame.addScriptTag({
path: path.resolve(__dirname, '../dist/all.umd.cjs'),
});
options = options || {};
await frame.evaluate((options) => {
(window as unknown as IWindow).snapshots = [];
const { record, pack } = (window as unknown as IWindow).rrweb;
const config: recordOptions<eventWithTime> = {
recordCrossOriginIframes: true,
recordCanvas: true,
emit(event) {
(window as unknown as IWindow).snapshots.push(event);
(window as unknown as IWindow).emit(event);
},
};
if (options.usePackFn) {
config.packFn = pack;
}
record(config);
}, options);
await frame.evaluate(
(options, allowedOrigins) => {
(window as unknown as IWindow).snapshots = [];
const { record, pack } = (window as unknown as IWindow).rrweb;
const config: recordOptions<eventWithTime> = {
recordCrossOriginIframes: !!allowedOrigins,
Comment on lines +69 to +70
recordCanvas: true,
allowedIframeOrigins: allowedOrigins,
emit(event) {
(window as unknown as IWindow).snapshots.push(event);
Comment on lines +69 to +74

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The record options key here is allowedOrigins, but the API/type added in this PR is allowedIframeOrigins. As written, the allowlist won’t be applied (and TypeScript should flag this as an unknown property on recordOptions). Rename this option to allowedIframeOrigins so the test actually exercises the new behavior.

Copilot uses AI. Check for mistakes.
(window as unknown as IWindow).emit(event);
},
};
if (options.usePackFn) {
config.packFn = pack;
}
record(config);
},
options,
allowedOrigins,
);

for (const child of frame.childFrames()) {
await injectRecordScript(child, options);
await injectRecordScript(child, options, allowedOrigins);
}
}

Expand All @@ -87,19 +94,24 @@ const setup = function (
content: string,
options?: ExtraOptions,
): ISuite {
const ctx = {} as ISuite;
const ctx = {} as ISuite & {
serverB: http.Server;
serverBURL: string;
};

beforeAll(async () => {
ctx.browser = await launchPuppeteer();
ctx.server = await startServer();
ctx.serverURL = getServerURL(ctx.server);
ctx.serverB = await startServer();
ctx.serverBURL = getServerURL(ctx.serverB);
});

beforeEach(async () => {
ctx.page = await ctx.browser.newPage();
await ctx.page.goto('about:blank');
await ctx.page.goto(`${ctx.serverURL}/html/blank.html`);
await ctx.page.setContent(
content.replace(/\{SERVER_URL\}/g, ctx.serverURL),
content.replace(/\{SERVER_URL\}/g, ctx.serverBURL),
);
ctx.events = [];
await ctx.page.exposeFunction('emit', (e: eventWithTime) => {
Expand All @@ -110,7 +122,10 @@ const setup = function (
});

ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
await injectRecordScript(ctx.page.mainFrame(), options);
const allowedOrigins = options?.crossOrigin
? [ctx.serverURL, ctx.serverBURL]
: undefined;
await injectRecordScript(ctx.page.mainFrame(), options, allowedOrigins);
});

afterEach(async () => {
Expand All @@ -120,6 +135,7 @@ const setup = function (
afterAll(async () => {
await ctx.browser.close();
ctx.server.close();
ctx.serverB.close();
});

return ctx;
Expand All @@ -137,7 +153,10 @@ describe('cross origin iframes & packer', function (this: ISuite) {
</body>
</html>
`;
const ctx = setup.call(this, content, { usePackFn: true });
const ctx = setup.call(this, content, {
usePackFn: true,
crossOrigin: true,
});

describe('should support packFn option in record()', () => {
it('', async () => {
Expand Down
32 changes: 32 additions & 0 deletions packages/rrweb/src/record/cross-origin-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
function toOrigin(url: string): string | null {
try {
const origin = new URL(url).origin;
return origin !== 'null' ? origin : null;
} catch {
return null;
}
}

export function buildAllowedOriginSet(origins: string[]): ReadonlySet<string> {
if (!Array.isArray(origins) || origins.length === 0) {
throw new Error(
'[rrweb] allowedIframeOrigins must be a non-empty array of origin strings.',
);
}

const set = new Set<string>();
for (let i = 0; i < origins.length; i++) {
const entry = origins[i];
if (typeof entry !== 'string') {
throw new Error(
`[rrweb] allowedIframeOrigins[${i}] must be a string, got ${typeof entry}.`,
);
}
const origin = toOrigin(entry);
if (origin) {
set.add(origin);
}
}

return Object.freeze(set);
Comment on lines +17 to +31

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Object.freeze(set) does not actually make a Set immutable (callers can still mutate it via add/delete), so this can give a false sense of safety. If the goal is to prevent mutation, consider returning a defensive copy when exposing it, or avoiding the freeze and relying on the ReadonlySet type (and not exporting the mutable instance).

Copilot uses AI. Check for mistakes.
}
22 changes: 21 additions & 1 deletion packages/rrweb/src/record/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
registerErrorHandler,
unregisterErrorHandler,
} from './error-handler';
import { buildAllowedOriginSet } from './cross-origin-utils';
import dom from '@rrweb/utils';

let wrappedEmit!: (e: eventWithoutTime, isCheckout?: boolean) => void;
Expand Down Expand Up @@ -89,6 +90,7 @@
recordDOM = true,
recordCanvas = false,
recordCrossOriginIframes = false,
allowedIframeOrigins,
recordAfter = options.recordAfter === 'DOMContentLoaded'
? options.recordAfter
: 'load',
Expand All @@ -103,6 +105,18 @@

registerErrorHandler(errorHandler);

let validatedOrigins: ReadonlySet<string> | undefined;
if (
recordCrossOriginIframes &&
allowedIframeOrigins &&
allowedIframeOrigins.length > 0
) {
Comment on lines +109 to +113

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildAllowedOriginSet is designed to throw on an empty allowlist, but record() currently skips validation when allowedIframeOrigins.length === 0, which means allowedIframeOrigins: [] silently falls back to wildcard behavior. If an empty array is intended to mean “deny all”, handle it explicitly; otherwise, call the validator whenever the option is present so the configuration error is surfaced consistently.

Copilot uses AI. Check for mistakes.
validatedOrigins = buildAllowedOriginSet(allowedIframeOrigins);
if (validatedOrigins.size === 0) {
validatedOrigins = undefined;
}
}
Comment on lines +109 to +118

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allowedIframeOrigins is treated as an allowlist, but if it normalizes to an empty set you currently set validatedOrigins back to undefined, which re-enables the wildcard ('*') behavior later. This is fail-open: a misconfigured/invalid allowlist ends up allowing all origins. Consider either throwing when allowedIframeOrigins is provided but yields no valid origins, or keeping an empty set and treating it as “deny all” (no postMessage + drop all incoming messages).

Copilot uses AI. Check for mistakes.

const inEmittingFrame = recordCrossOriginIframes
? window.parent === window
: true;
Expand Down Expand Up @@ -207,7 +221,13 @@
origin: window.location.origin,
isCheckout,
};
window.parent.postMessage(message, '*');
if (validatedOrigins) {
for (const targetOrigin of validatedOrigins) {
window.parent.postMessage(message, targetOrigin);
}
} else {
window.parent.postMessage(message, '*');
}
Comment on lines +224 to +230

Copilot AI Mar 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When validatedOrigins is set, this loop sends postMessage to window.parent for each origin. If any targetOrigin doesn't match the actual parent window origin, browsers throw a DOMException and the recording will break (and the loop won't continue). Since allowedIframeOrigins is also used for validating incoming iframe origins, it’s likely to contain origins that are not the parent origin. Consider resolving the parent origin once (e.g., from document.referrer when available) and sending a single postMessage, or wrapping each postMessage in try/catch and continuing on mismatch.

Copilot uses AI. Check for mistakes.
}

if (e.type === EventType.FullSnapshot) {
Expand Down Expand Up @@ -538,7 +558,7 @@
plugins
?.filter((p) => p.observer)
?.map((p) => ({
observer: p.observer!,

Check warning on line 561 in packages/rrweb/src/record/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/record/index.ts#L561

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
options: p.options,
callback: (payload: object) =>
wrappedEmit({
Expand All @@ -556,7 +576,7 @@

iframeManager.addLoadListener((iframeEl) => {
try {
handlers.push(observe(iframeEl.contentDocument!));

Check warning on line 579 in packages/rrweb/src/record/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/rrweb/src/record/index.ts#L579

[@typescript-eslint/no-non-null-assertion] Forbidden non-null assertion.
} catch (error) {
// TODO: handle internal error
console.warn(error);
Expand Down
1 change: 1 addition & 0 deletions packages/rrweb/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export type recordOptions<T> = {
recordDOM?: boolean;
recordCanvas?: boolean;
recordCrossOriginIframes?: boolean;
allowedIframeOrigins?: string[];
recordAfter?: 'DOMContentLoaded' | 'load';
userTriggeredOnInput?: boolean;
collectFonts?: boolean;
Expand Down
Loading
Loading