diff --git a/packages/all/test/cross-origin-iframe-packer.test.ts b/packages/all/test/cross-origin-iframe-packer.test.ts index 2623285537..711c22ffc6 100644 --- a/packages/all/test/cross-origin-iframe-packer.test.ts +++ b/packages/all/test/cross-origin-iframe-packer.test.ts @@ -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 = { - 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 = { + recordCrossOriginIframes: !!allowedOrigins, + recordCanvas: true, + allowedIframeOrigins: allowedOrigins, + 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, + allowedOrigins, + ); for (const child of frame.childFrames()) { - await injectRecordScript(child, options); + await injectRecordScript(child, options, allowedOrigins); } } @@ -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) => { @@ -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 () => { @@ -120,6 +135,7 @@ const setup = function ( afterAll(async () => { await ctx.browser.close(); ctx.server.close(); + ctx.serverB.close(); }); return ctx; @@ -137,7 +153,10 @@ describe('cross origin iframes & packer', function (this: ISuite) { `; - 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 () => { diff --git a/packages/rrweb/src/record/cross-origin-utils.ts b/packages/rrweb/src/record/cross-origin-utils.ts new file mode 100644 index 0000000000..00f17c8f81 --- /dev/null +++ b/packages/rrweb/src/record/cross-origin-utils.ts @@ -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 { + 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(); + 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); +} diff --git a/packages/rrweb/src/record/index.ts b/packages/rrweb/src/record/index.ts index fac5359790..38199aac80 100644 --- a/packages/rrweb/src/record/index.ts +++ b/packages/rrweb/src/record/index.ts @@ -39,6 +39,7 @@ import { registerErrorHandler, unregisterErrorHandler, } from './error-handler'; +import { buildAllowedOriginSet } from './cross-origin-utils'; import dom from '@rrweb/utils'; let wrappedEmit!: (e: eventWithoutTime, isCheckout?: boolean) => void; @@ -89,6 +90,7 @@ function record( recordDOM = true, recordCanvas = false, recordCrossOriginIframes = false, + allowedIframeOrigins, recordAfter = options.recordAfter === 'DOMContentLoaded' ? options.recordAfter : 'load', @@ -103,6 +105,18 @@ function record( registerErrorHandler(errorHandler); + let validatedOrigins: ReadonlySet | undefined; + if ( + recordCrossOriginIframes && + allowedIframeOrigins && + allowedIframeOrigins.length > 0 + ) { + validatedOrigins = buildAllowedOriginSet(allowedIframeOrigins); + if (validatedOrigins.size === 0) { + validatedOrigins = undefined; + } + } + const inEmittingFrame = recordCrossOriginIframes ? window.parent === window : true; @@ -207,7 +221,13 @@ function record( 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, '*'); + } } if (e.type === EventType.FullSnapshot) { diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index a03e326b6f..fe77240c68 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -65,6 +65,7 @@ export type recordOptions = { recordDOM?: boolean; recordCanvas?: boolean; recordCrossOriginIframes?: boolean; + allowedIframeOrigins?: string[]; recordAfter?: 'DOMContentLoaded' | 'load'; userTriggeredOnInput?: boolean; collectFonts?: boolean; diff --git a/packages/rrweb/test/record/cross-origin-iframes.test.ts b/packages/rrweb/test/record/cross-origin-iframes.test.ts index cd6738fa78..01ec84d693 100644 --- a/packages/rrweb/test/record/cross-origin-iframes.test.ts +++ b/packages/rrweb/test/record/cross-origin-iframes.test.ts @@ -40,11 +40,13 @@ interface IWindow extends Window { } type ExtraOptions = { usePackFn?: boolean; + crossOrigin?: boolean; }; async function injectRecordScript( frame: puppeteer.Frame, options?: ExtraOptions, + allowedOrigins?: string[], ) { try { await frame.addScriptTag({ @@ -58,26 +60,31 @@ async function injectRecordScript( !e.message.includes('DOM.describeNode') ) throw e; - await injectRecordScript(frame, options); + await injectRecordScript(frame, options, allowedOrigins); return; } options = options || {}; - await frame.evaluate((options) => { - (window as unknown as IWindow).snapshots = []; - const { record } = (window as unknown as IWindow).rrweb; - const config: recordOptions = { - recordCrossOriginIframes: true, - recordCanvas: true, - emit(event) { - (window as unknown as IWindow).snapshots.push(event); - (window as unknown as IWindow).emit(event); - }, - }; - record(config); - }, options); + await frame.evaluate( + (options, allowedOrigins) => { + (window as unknown as IWindow).snapshots = []; + const { record } = (window as unknown as IWindow).rrweb; + const config: recordOptions = { + recordCrossOriginIframes: !!allowedOrigins, + recordCanvas: true, + allowedIframeOrigins: allowedOrigins, + emit(event) { + (window as unknown as IWindow).snapshots.push(event); + (window as unknown as IWindow).emit(event); + }, + }; + record(config); + }, + options, + allowedOrigins, + ); for (const child of frame.childFrames()) { - await injectRecordScript(child, options); + await injectRecordScript(child, options, allowedOrigins); } } @@ -104,11 +111,10 @@ const setup = function ( 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), ); - // await ctx.page.evaluate(ctx.code); ctx.events = []; await ctx.page.exposeFunction('emit', (e: eventWithTime) => { if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) { @@ -118,7 +124,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 () => { @@ -138,7 +147,7 @@ describe('cross origin iframes', function (this: ISuite) { vi.setConfig({ testTimeout: 100_000 }); describe('form.html', function (this: ISuite) { - const ctx: ISuite = setup.call( + const ctx = setup.call( this, ` @@ -148,7 +157,8 @@ describe('cross origin iframes', function (this: ISuite) { `, - ); + { crossOrigin: true }, + ) as ISuite & { serverBURL: string }; it("won't emit events if it isn't in the top level iframe", async () => { const el = (await ctx.page.$( @@ -214,12 +224,16 @@ describe('cross origin iframes', function (this: ISuite) { await ctx.page.evaluate((url) => { const iframe = document.querySelector('iframe') as HTMLIFrameElement; iframe.src = `${url}/html/empty.html`; - }, ctx.serverURL); + }, ctx.serverBURL); await waitForRAF(ctx.page); // should load iframe (but sometimes doesn't) const frame = ctx.page.mainFrame().childFrames()[0]; await frame.waitForSelector('#one'); // ensure frame has changed - await injectRecordScript(ctx.page.mainFrame().childFrames()[0]); // injects script into new iframe + await injectRecordScript( + ctx.page.mainFrame().childFrames()[0], + undefined, + [ctx.serverURL, ctx.serverBURL], + ); // injects script into new iframe const events: eventWithTime[] = await ctx.page.evaluate( () => (window as unknown as IWindow).snapshots, @@ -277,7 +291,7 @@ describe('cross origin iframes', function (this: ISuite) { }); describe('move-node.html', function (this: ISuite) { - const ctx: ISuite = setup.call( + const ctx = setup.call( this, ` @@ -287,7 +301,8 @@ describe('cross origin iframes', function (this: ISuite) { `, - ); + { crossOrigin: true }, + ) as ISuite & { serverBURL: string }; it('should record DOM node movement', async () => { const frame = ctx.page.mainFrame().childFrames()[0]; @@ -494,7 +509,7 @@ describe('cross origin iframes', function (this: ISuite) { describe('audio.html', function (this: ISuite) { vi.setConfig({ testTimeout: 100_000 }); - const ctx: ISuite = setup.call( + const ctx = setup.call( this, ` @@ -504,7 +519,8 @@ describe('cross origin iframes', function (this: ISuite) { `, - ); + { crossOrigin: true }, + ) as ISuite & { serverBURL: string }; it('should emit contents of iframe once', async () => { const frame = ctx.page.mainFrame().childFrames()[0]; @@ -529,7 +545,7 @@ describe('cross origin iframes', function (this: ISuite) { `; - const ctx = setup.call(this, content) as ISuite & { + const ctx = setup.call(this, content, { crossOrigin: true }) as ISuite & { serverBURL: string; }; @@ -553,7 +569,7 @@ describe('cross origin iframes', function (this: ISuite) { it('should filter out forwarded cross origin rrweb messages', async () => { const frame = ctx.page.mainFrame().childFrames()[0]; - const iframe2URL = `${ctx.serverBURL}/html/blank.html`; + const iframe2URL = `${ctx.serverURL}/html/blank.html?iframe2`; await frame.evaluate((iframe2URL) => { // Add a message proxy to forward messages from child frames to its parent frame. window.addEventListener('message', (event) => { @@ -569,9 +585,16 @@ describe('cross origin iframes', function (this: ISuite) { await ctx.page.waitForFrame(iframe2URL); const iframe2 = frame.childFrames()[0]; // Record iframe2 - await injectRecordScript(iframe2); + await injectRecordScript(iframe2, undefined, [ + ctx.serverURL, + ctx.serverBURL, + ]); + // Wait for the 2-hop postMessage chain (iframe2 → iframe1 → parent). + // Each frame has its own event loop, so wait on each in order. await waitForRAF(iframe2); + await waitForRAF(frame); + await waitForRAF(ctx.page); const snapshots = (await ctx.page.evaluate( 'window.snapshots', )) as eventWithTime[]; @@ -583,7 +606,7 @@ describe('cross origin iframes', function (this: ISuite) { describe('same origin iframes', function (this: ISuite) { vi.setConfig({ testTimeout: 100_000 }); - const ctx: ISuite = setup.call( + const ctx = setup.call( this, ` @@ -593,7 +616,8 @@ describe('same origin iframes', function (this: ISuite) { `, - ); + { crossOrigin: true }, + ) as ISuite & { serverBURL: string }; it('should emit contents of iframe once', async () => { const events = await ctx.page.evaluate( @@ -618,10 +642,13 @@ describe('same origin iframes', function (this: ISuite) { return new Promise((resolve) => { crossOriginIframe.onload = resolve; }); - }, ctx.serverURL); + }, ctx.serverBURL); const crossOriginIframe = sameOriginIframe.childFrames()[0]; // Inject recording script into this cross-origin iframe - await injectRecordScript(crossOriginIframe); + await injectRecordScript(crossOriginIframe, undefined, [ + ctx.serverURL, + ctx.serverBURL, + ]); await waitForRAF(ctx.page); const snapshots = (await ctx.page.evaluate( diff --git a/packages/rrweb/test/record/cross-origin-utils.test.ts b/packages/rrweb/test/record/cross-origin-utils.test.ts new file mode 100644 index 0000000000..43be818cfd --- /dev/null +++ b/packages/rrweb/test/record/cross-origin-utils.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { buildAllowedOriginSet } from '../../src/record/cross-origin-utils'; + +describe('buildAllowedOriginSet', () => { + it('should return a frozen Set for valid origins', () => { + const result = buildAllowedOriginSet([ + 'https://example.com', + 'https://app.example.com', + ]); + expect(result).toBeInstanceOf(Set); + expect(result.size).toBe(2); + expect(result.has('https://example.com')).toBe(true); + expect(result.has('https://app.example.com')).toBe(true); + expect(Object.isFrozen(result)).toBe(true); + }); + + it('should normalize URLs to their origins', () => { + const result = buildAllowedOriginSet([ + 'https://example.com/', + 'https://example.com/path/to/page', + 'https://app.example.com?foo=bar', + 'http://localhost:3000#hash', + ]); + expect(result.size).toBe(3); + expect(result.has('https://example.com')).toBe(true); + expect(result.has('https://app.example.com')).toBe(true); + expect(result.has('http://localhost:3000')).toBe(true); + }); + + it('should normalize default ports away', () => { + const result = buildAllowedOriginSet([ + 'https://example.com:443', + 'http://example.com:80', + ]); + expect(result.size).toBe(2); + expect(result.has('https://example.com')).toBe(true); + expect(result.has('http://example.com')).toBe(true); + }); + + it('should throw for empty array', () => { + expect(() => buildAllowedOriginSet([])).toThrow( + 'allowedIframeOrigins must be a non-empty array', + ); + }); + + it('should throw for non-array', () => { + expect(() => buildAllowedOriginSet(null as unknown as string[])).toThrow( + 'allowedIframeOrigins must be a non-empty array', + ); + expect(() => + buildAllowedOriginSet(undefined as unknown as string[]), + ).toThrow('allowedIframeOrigins must be a non-empty array'); + }); + + it('should throw for non-string entries', () => { + expect(() => buildAllowedOriginSet([123 as unknown as string])).toThrow( + 'must be a string', + ); + }); + + it('should skip unparseable strings', () => { + const result = buildAllowedOriginSet(['https://example.com', 'not-a-url']); + expect(result.size).toBe(1); + expect(result.has('https://example.com')).toBe(true); + }); + + it('should deduplicate origins', () => { + const result = buildAllowedOriginSet([ + 'https://example.com', + 'https://example.com', + ]); + expect(result.size).toBe(1); + }); + + it('should deduplicate after normalization', () => { + const result = buildAllowedOriginSet([ + 'https://example.com', + 'https://example.com/path', + 'https://example.com:443', + ]); + expect(result.size).toBe(1); + expect(result.has('https://example.com')).toBe(true); + }); +});