Fix cross origin iframe bugs (#1093)

* fix: error data while recording some websites are integrated  with stripe

These websites will usually have an iframe with src "https://js.stripe.com/v3/m-outer-xxx.html"

* add test case for the bug

* fix: recordCrossOriginIframes: true does not work with pack/unpack fn

1. bugfix
2. add test case
3. add rrweb-all.js to the result of command: bundle:browser
4. make puppeteer headless in CI by default to increase the speed and stability
This commit is contained in:
Yun Feng
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 529b2ad266
commit f2888d8694
8 changed files with 540 additions and 34 deletions

View File

@@ -30,7 +30,7 @@ jobs:
run: yarn turbo run check-types
- name: Run tests
run: xvfb-run --server-args="-screen 0 1920x1080x24" yarn test
run: PUPPETEER_HEADLESS=true xvfb-run --server-args="-screen 0 1920x1080x24" yarn test
- name: Upload diff images to GitHub
uses: actions/upload-artifact@v3

View File

@@ -214,6 +214,11 @@ if (process.env.BROWSER_ONLY) {
name: 'rrweb',
pathFn: (p) => p,
},
{
input: './src/entries/all.ts',
name: 'rrweb',
pathFn: toAllPath,
},
{
input: './src/plugins/console/record/index.ts',
name: 'rrwebConsoleRecord',

View File

@@ -83,24 +83,30 @@ export class IframeManager {
);
}
private handleMessage(message: MessageEvent | CrossOriginIframeMessageEvent) {
if ((message as CrossOriginIframeMessageEvent).data.type === 'rrweb') {
const iframeSourceWindow = message.source;
if (!iframeSourceWindow) return;
const crossOriginMessageEvent = message as CrossOriginIframeMessageEvent;
if (
crossOriginMessageEvent.data.type !== 'rrweb' ||
// To filter out the rrweb messages which are forwarded by some sites.
crossOriginMessageEvent.origin !== crossOriginMessageEvent.data.origin
)
return;
const iframeEl = this.crossOriginIframeMap.get(message.source);
if (!iframeEl) return;
const iframeSourceWindow = message.source;
if (!iframeSourceWindow) return;
const transformedEvent = this.transformCrossOriginEvent(
iframeEl,
(message as CrossOriginIframeMessageEvent).data.event,
const iframeEl = this.crossOriginIframeMap.get(message.source);
if (!iframeEl) return;
const transformedEvent = this.transformCrossOriginEvent(
iframeEl,
crossOriginMessageEvent.data.event,
);
if (transformedEvent)
this.wrappedEmit(
transformedEvent,
crossOriginMessageEvent.data.isCheckout,
);
if (transformedEvent)
this.wrappedEmit(
transformedEvent,
(message as CrossOriginIframeMessageEvent).data.isCheckout,
);
}
}
private transformCrossOriginEvent(

View File

@@ -165,7 +165,11 @@ function record<T = eventWithTime>(
e = plugin.eventProcessor(e);
}
}
if (packFn) {
if (
packFn &&
// Disable packing events which will be emitted to parent frames.
!passEmitsToParent
) {
e = (packFn(e) as unknown) as eventWithTime;
}
return (e as unknown) as T;
@@ -190,6 +194,7 @@ function record<T = eventWithTime>(
const message: CrossOriginIframeMessageEventContent<T> = {
type: 'rrweb',
event: eventProcessor(e),
origin: window.location.origin,
isCheckout,
};
window.parent.postMessage(message, '*');

View File

@@ -200,6 +200,8 @@ declare global {
export type CrossOriginIframeMessageEventContent<T = eventWithTime> = {
type: 'rrweb';
event: T;
// The origin of the iframe which originally emits this message. It is used to check the integrity of message and to filter out the rrweb messages which are forwarded by some sites.
origin: string;
isCheckout?: boolean;
};
export type CrossOriginIframeMessageEvent = MessageEvent<CrossOriginIframeMessageEventContent>;

View File

@@ -307,6 +307,418 @@ exports[`cross origin iframes audio.html should emit contents of iframe once 1`]
]"
`;
exports[`cross origin iframes blank.html should filter out forwarded cross origin rrweb messages 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 6
}
],
\\"id\\": 5
}
],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 8
},
{
\\"type\\": 2,
\\"tagName\\": \\"iframe\\",
\\"attributes\\": {
\\"rr_src\\": \\"http://localhost:3030/html/blank.html\\"
},
\\"childNodes\\": [],
\\"id\\": 9
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 10
}
],
\\"id\\": 7
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 15
}
],
\\"id\\": 14
}
],
\\"id\\": 13
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n\\",
\\"id\\": 17
}
],
\\"id\\": 16
}
],
\\"id\\": 12
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 11
}
}
],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": [],
\\"isAttachIframe\\": true
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 16,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"iframe\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 18
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [
{
\\"parentId\\": 18,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 23
}
],
\\"id\\": 22
}
],
\\"id\\": 21
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n\\",
\\"id\\": 25
}
],
\\"id\\": 24
}
],
\\"id\\": 20
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 19
}
}
],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": [],
\\"isAttachIframe\\": true
}
}
]"
`;
exports[`cross origin iframes blank.html should support packFn option in record() 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
},
\\"v\\": \\"v1\\"
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 6
}
],
\\"id\\": 5
}
],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 8
},
{
\\"type\\": 2,
\\"tagName\\": \\"iframe\\",
\\"attributes\\": {
\\"rr_src\\": \\"http://localhost:3030/html/blank.html\\"
},
\\"childNodes\\": [],
\\"id\\": 9
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 10
}
],
\\"id\\": 7
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
},
\\"v\\": \\"v1\\"
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"adds\\": [
{
\\"parentId\\": 9,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 15
}
],
\\"id\\": 14
}
],
\\"id\\": 13
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n\\",
\\"id\\": 17
}
],
\\"id\\": 16
}
],
\\"id\\": 12
}
],
\\"compatMode\\": \\"BackCompat\\",
\\"id\\": 11
}
}
],
\\"removes\\": [],
\\"texts\\": [],
\\"attributes\\": [],
\\"isAttachIframe\\": true
},
\\"v\\": \\"v1\\"
}
]"
`;
exports[`cross origin iframes form.html should map input events correctly 1`] = `
"[
{

View File

@@ -13,9 +13,9 @@ import {
getServerURL,
launchPuppeteer,
startServer,
stripBase64,
waitForRAF,
} from '../utils';
import { unpack } from '../../src/packer/unpack';
import type * as http from 'http';
interface ISuite {
@@ -33,42 +33,61 @@ interface IWindow extends Window {
options: recordOptions<eventWithTime>,
) => listenerHandler | undefined;
addCustomEvent<T>(tag: string, payload: T): void;
pack: (e: eventWithTime) => string;
};
emit: (e: eventWithTime) => undefined;
snapshots: eventWithTime[];
}
type ExtraOptions = {
usePackFn?: boolean;
};
async function injectRecordScript(frame: puppeteer.Frame) {
async function injectRecordScript(
frame: puppeteer.Frame,
options?: ExtraOptions,
) {
await frame.addScriptTag({
path: path.resolve(__dirname, '../../dist/rrweb.js'),
path: path.resolve(__dirname, '../../dist/rrweb-all.js'),
});
await frame.evaluate(() => {
options = options || {};
await frame.evaluate((options) => {
((window as unknown) as IWindow).snapshots = [];
const { record } = ((window as unknown) as IWindow).rrweb;
record({
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);
for (const child of frame.childFrames()) {
await injectRecordScript(child);
await injectRecordScript(child, options);
}
}
const setup = function (this: ISuite, content: string): ISuite {
const ctx = {} as ISuite;
const setup = function (
this: ISuite,
content: string,
options?: ExtraOptions,
): 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);
ctx.serverB = await startServer();
ctx.serverBURL = getServerURL(ctx.serverB);
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.js');
ctx.code = fs.readFileSync(bundlePath, 'utf8');
@@ -90,7 +109,7 @@ const setup = function (this: ISuite, content: string): ISuite {
});
ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
await injectRecordScript(ctx.page.mainFrame());
await injectRecordScript(ctx.page.mainFrame(), options);
});
afterEach(async () => {
@@ -100,7 +119,7 @@ const setup = function (this: ISuite, content: string): ISuite {
afterAll(async () => {
await ctx.browser.close();
ctx.server.close();
// ctx.serverB.close();
ctx.serverB.close();
});
return ctx;
@@ -484,6 +503,61 @@ describe('cross origin iframes', function (this: ISuite) {
assertSnapshot(snapshots);
});
});
describe('blank.html', function (this: ISuite) {
const content = `
<!DOCTYPE html>
<html>
<body>
<iframe src="{SERVER_URL}/html/blank.html"></iframe>
</body>
</html>
`;
const ctx = setup.call(this, content) as ISuite & {
serverBURL: string;
};
it('should filter out forwarded cross origin rrweb messages', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
const iframe2URL = `${ctx.serverBURL}/html/blank.html`;
await frame.evaluate((iframe2URL) => {
// Add a message proxy to forward messages from child frames to its parent frame.
window.addEventListener('message', (event) => {
if (event.source !== window)
window.parent.postMessage(event.data, '*');
});
const iframe2 = document.createElement('iframe');
iframe2.src = iframe2URL;
document.body.appendChild(iframe2);
}, iframe2URL);
// Wait for iframe2 to load
await ctx.page.waitForFrame(iframe2URL);
// Record iframe2
await injectRecordScript(frame.childFrames()[0]);
await waitForRAF(frame.childFrames()[0]);
const snapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
describe('should support packFn option in record()', () => {
const ctx = setup.call(this, content, { usePackFn: true });
it('', async () => {
const frame = ctx.page.mainFrame().childFrames()[0];
await waitForRAF(frame);
const packedSnapshots = (await ctx.page.evaluate(
'window.snapshots',
)) as string[];
const unpackedSnapshots = packedSnapshots.map((packed) =>
unpack(packed),
) as eventWithTime[];
assertSnapshot(unpackedSnapshots);
});
});
});
});
describe('same origin iframes', function (this: ISuite) {

View File

@@ -576,8 +576,10 @@ export const polyfillWebGLGlobals = () => {
global.WebGL2RenderingContext = WebGL2RenderingContext as any;
};
export async function waitForRAF(page: puppeteer.Page) {
return await page.evaluate(() => {
export async function waitForRAF(
pageOrFrame: puppeteer.Page | puppeteer.Frame,
) {
return await pageOrFrame.evaluate(() => {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);