diff --git a/packages/rrweb/src/replay/canvas/2d.ts b/packages/rrweb/src/replay/canvas/2d.ts index 2c38e0ea..71dd77f6 100644 --- a/packages/rrweb/src/replay/canvas/2d.ts +++ b/packages/rrweb/src/replay/canvas/2d.ts @@ -9,12 +9,14 @@ export default async function canvasMutation({ target, imageMap, errorHandler, + enforceCanvasArgAllowlist, }: { event: Parameters[0]; mutations: canvasMutationCommand[]; target: HTMLCanvasElement; imageMap: Replayer['imageMap']; errorHandler: Replayer['warnCanvasMutationFailed']; + enforceCanvasArgAllowlist?: boolean; }): Promise { const ctx = target.getContext('2d'); @@ -29,7 +31,11 @@ export default async function canvasMutation({ // step 1, deserialize args, they may be async const mutationArgsPromises = mutations.map( async (mutation: canvasMutationCommand): Promise => { - return Promise.all(mutation.args.map(deserializeArg(imageMap, ctx))); + return Promise.all( + mutation.args.map( + deserializeArg(imageMap, ctx, undefined, enforceCanvasArgAllowlist), + ), + ); }, ); const args = await Promise.all(mutationArgsPromises); diff --git a/packages/rrweb/src/replay/canvas/deserialize-args.ts b/packages/rrweb/src/replay/canvas/deserialize-args.ts index a690d798..c0ea434c 100644 --- a/packages/rrweb/src/replay/canvas/deserialize-args.ts +++ b/packages/rrweb/src/replay/canvas/deserialize-args.ts @@ -27,6 +27,33 @@ export function variableListFor( return contextMap.get(ctor) as any[]; } +/** + * The `rr_type` values that the canvas recorder rebuilds by calling a + * constructor of that name — see `record/observers/canvas/serialize-args.ts` + * for the matching serialization. A recording is untrusted input at replay + * time, so `rr_type` is matched against this set rather than resolved as an + * arbitrary global. + */ +const canvasArgConstructors = new Set([ + 'Int8Array', + 'Int16Array', + 'Int32Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Uint16Array', + 'Uint32Array', + 'Float32Array', + 'Float64Array', + 'DataView', + 'ImageData', + // normally serialized as base64, but reachable in `args` form through + // recordings made by older clients and through nested `DataView` args. + 'ArrayBuffer', + // wraps nested args in recordings made by older clients — see the + // `preloadAllImages` tests for the shape. + 'Array', +]); + export function isSerializedArg(arg: unknown): arg is SerializedCanvasArg { return Boolean(arg && typeof arg === 'object' && 'rr_type' in arg); } @@ -41,13 +68,19 @@ export function deserializeArg( preload?: { isUnchanged: boolean; }, + enforceCanvasArgAllowlist = false, ): (arg: CanvasArg) => Promise { return async (arg: CanvasArg): Promise => { if (arg && typeof arg === 'object' && 'rr_type' in arg) { if (preload) preload.isUnchanged = false; if (arg.rr_type === 'ImageBitmap' && 'args' in arg) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const args = await deserializeArg(imageMap, ctx, preload)(arg.args); + const args = await deserializeArg( + imageMap, + ctx, + preload, + enforceCanvasArgAllowlist, + )(arg.args); // eslint-disable-next-line prefer-spread return await createImageBitmap.apply(null, args); } else if ('index' in arg) { @@ -57,13 +90,21 @@ export function deserializeArg( return variableListFor(ctx, name)[index]; } else if ('args' in arg) { const { rr_type: name, args } = arg; + if (enforceCanvasArgAllowlist && !canvasArgConstructors.has(name)) { + console.warn( + `[replayer] refusing to construct canvas arg of unknown type: ${name}`, + ); + return null; + } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const ctor = window[name as keyof Window]; // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call return new ctor( ...(await Promise.all( - args.map(deserializeArg(imageMap, ctx, preload)), + args.map( + deserializeArg(imageMap, ctx, preload, enforceCanvasArgAllowlist), + ), )), ); } else if ('base64' in arg) { @@ -80,7 +121,9 @@ export function deserializeArg( } } else if ('data' in arg && arg.rr_type === 'Blob') { const blobContents = await Promise.all( - arg.data.map(deserializeArg(imageMap, ctx, preload)), + arg.data.map( + deserializeArg(imageMap, ctx, preload, enforceCanvasArgAllowlist), + ), ); const blob = new Blob(blobContents, { type: arg.type, @@ -89,7 +132,9 @@ export function deserializeArg( } } else if (Array.isArray(arg)) { const result = await Promise.all( - arg.map(deserializeArg(imageMap, ctx, preload)), + arg.map( + deserializeArg(imageMap, ctx, preload, enforceCanvasArgAllowlist), + ), ); // eslint-disable-next-line @typescript-eslint/no-unsafe-return return result; diff --git a/packages/rrweb/src/replay/canvas/index.ts b/packages/rrweb/src/replay/canvas/index.ts index 63e66c9a..0315759a 100644 --- a/packages/rrweb/src/replay/canvas/index.ts +++ b/packages/rrweb/src/replay/canvas/index.ts @@ -15,6 +15,7 @@ export default async function canvasMutation({ imageMap, canvasEventMap, errorHandler, + enforceCanvasArgAllowlist, }: { event: Parameters[0]; mutation: canvasMutationData; @@ -22,6 +23,7 @@ export default async function canvasMutation({ imageMap: Replayer['imageMap']; canvasEventMap: Replayer['canvasEventMap']; errorHandler: Replayer['warnCanvasMutationFailed']; + enforceCanvasArgAllowlist?: boolean; }): Promise { try { const precomputedMutation: canvasMutationParam = @@ -41,6 +43,7 @@ export default async function canvasMutation({ target, imageMap, errorHandler, + enforceCanvasArgAllowlist, }); } return; @@ -52,6 +55,7 @@ export default async function canvasMutation({ target, imageMap, errorHandler, + enforceCanvasArgAllowlist, }); } catch (error) { errorHandler(mutation, error); diff --git a/packages/rrweb/src/replay/canvas/webgl.ts b/packages/rrweb/src/replay/canvas/webgl.ts index b4faf4c8..60202a69 100644 --- a/packages/rrweb/src/replay/canvas/webgl.ts +++ b/packages/rrweb/src/replay/canvas/webgl.ts @@ -57,12 +57,14 @@ export default async function webglMutation({ type, imageMap, errorHandler, + enforceCanvasArgAllowlist, }: { mutation: canvasMutationCommand; target: HTMLCanvasElement; type: CanvasContext; imageMap: Replayer['imageMap']; errorHandler: Replayer['warnCanvasMutationFailed']; + enforceCanvasArgAllowlist?: boolean; }): Promise { try { const ctx = getContext(target, type); @@ -86,7 +88,9 @@ export default async function webglMutation({ ) => void; const args = await Promise.all( - mutation.args.map(deserializeArg(imageMap, ctx)), + mutation.args.map( + deserializeArg(imageMap, ctx, undefined, enforceCanvasArgAllowlist), + ), ); const result = original.apply(ctx, args); saveToWebGLVarMap(ctx, result); diff --git a/packages/rrweb/src/replay/embedded/protocol.ts b/packages/rrweb/src/replay/embedded/protocol.ts index a19cffd3..79971d37 100644 --- a/packages/rrweb/src/replay/embedded/protocol.ts +++ b/packages/rrweb/src/replay/embedded/protocol.ts @@ -56,6 +56,7 @@ export const SERIALIZABLE_CONFIG_KEYS = [ 'insertStyleRules', 'triggerFocus', 'UNSAFE_replayCanvas', + 'enforceCanvasArgAllowlist', 'cspContent', 'pauseAnimation', 'mouseTail', diff --git a/packages/rrweb/src/replay/index.ts b/packages/rrweb/src/replay/index.ts index d06c4a27..30098a0d 100644 --- a/packages/rrweb/src/replay/index.ts +++ b/packages/rrweb/src/replay/index.ts @@ -204,6 +204,7 @@ export class Replayer { insertStyleRules: [], triggerFocus: true, UNSAFE_replayCanvas: false, + enforceCanvasArgAllowlist: false, pauseAnimation: true, mouseTail: defaultMouseTailConfig, useVirtualDom: true, // Virtual-dom optimization is enabled by default. @@ -243,6 +244,7 @@ export class Replayer { imageMap: this.imageMap, canvasEventMap: this.canvasEventMap, errorHandler: this.warnCanvasMutationFailed.bind(this), + enforceCanvasArgAllowlist: this.config.enforceCanvasArgAllowlist, }); }, applyInput: this.applyInput.bind(this), @@ -1254,7 +1256,14 @@ export class Replayer { const commands = await Promise.all( data.commands.map(async (c) => { const args = await Promise.all( - c.args.map(deserializeArg(this.imageMap, null, status)), + c.args.map( + deserializeArg( + this.imageMap, + null, + status, + this.config.enforceCanvasArgAllowlist, + ), + ), ); return { ...c, args }; }), @@ -1263,7 +1272,14 @@ export class Replayer { this.canvasEventMap.set(event, { ...data, commands }); } else { const args = await Promise.all( - data.args.map(deserializeArg(this.imageMap, null, status)), + data.args.map( + deserializeArg( + this.imageMap, + null, + status, + this.config.enforceCanvasArgAllowlist, + ), + ), ); if (status.isUnchanged === false) this.canvasEventMap.set(event, { ...data, args }); @@ -1515,6 +1531,7 @@ export class Replayer { imageMap: this.imageMap, canvasEventMap: this.canvasEventMap, errorHandler: this.warnCanvasMutationFailed.bind(this), + enforceCanvasArgAllowlist: this.config.enforceCanvasArgAllowlist, }); } break; diff --git a/packages/rrweb/src/types.ts b/packages/rrweb/src/types.ts index 70874f29..9c007b0e 100644 --- a/packages/rrweb/src/types.ts +++ b/packages/rrweb/src/types.ts @@ -217,6 +217,14 @@ export type playerConfig = { insertStyleRules: string[]; triggerFocus: boolean; UNSAFE_replayCanvas: boolean; + /** + * Rebuild canvas mutation args only from the constructors the recorder + * emits, dropping any other `rr_type` instead of resolving it off `window` + * (see `replay/canvas/deserialize-args.ts`). Off by default so the embedding + * app can roll it out; canvas replay of args this list doesn't cover + * degrades to a blank draw rather than failing the session. + */ + enforceCanvasArgAllowlist: boolean; /** * Optional Content-Security-Policy applied to the replay iframe. When set, a * `` carrying this policy is added diff --git a/packages/rrweb/test/replay/deserialize-args.test.ts b/packages/rrweb/test/replay/deserialize-args.test.ts index 239e6fa6..1f27f8f5 100644 --- a/packages/rrweb/test/replay/deserialize-args.test.ts +++ b/packages/rrweb/test/replay/deserialize-args.test.ts @@ -156,6 +156,123 @@ describe('deserializeArg', () => { expect(deserialized.size).toEqual(expected.size); }); + describe('constructor allowlist', () => { + // the allowlist is opt-in; `enforceCanvasArgAllowlist` is the 4th arg + const enforcing = () => deserializeArg(new Map(), context, undefined, true); + + const recorderEmittedTypes = [ + 'Int8Array', + 'Int16Array', + 'Int32Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Uint16Array', + 'Uint32Array', + 'Float32Array', + 'Float64Array', + ]; + + it.each(recorderEmittedTypes)( + 'should deserialize %s values while enforcing', + async (rr_type) => { + expect(await enforcing()({ rr_type, args: [[1, 2, 3, 4]] })).toEqual( + new (window[rr_type as keyof Window] as Uint8ArrayConstructor)([ + 1, 2, 3, 4, + ]), + ); + }, + ); + + it('should deserialize ImageData while enforcing', async () => { + expect( + await enforcing()({ + rr_type: 'ImageData', + args: [{ rr_type: 'Uint8ClampedArray', args: [[1, 2, 3, 4]] }, 1, 1], + }), + ).toEqual(new ImageData(new Uint8ClampedArray([1, 2, 3, 4]), 1, 1)); + }); + + it('should deserialize DataView over a base64 ArrayBuffer while enforcing', async () => { + expect( + await enforcing()({ + rr_type: 'DataView', + args: [ + { rr_type: 'ArrayBuffer', base64: 'AAAAAAAAAAAAAAAAAAAAAA==' }, + 0, + 16, + ], + }), + ).toStrictEqual(new DataView(new ArrayBuffer(16), 0, 16)); + }); + + it('should deserialize the Array wrapper older clients emit', async () => { + expect( + await enforcing()({ + rr_type: 'Array', + args: [{ rr_type: 'Float32Array', args: [[1, 2]] }], + }), + ).toEqual([new Float32Array([1, 2])]); + }); + + it('should leave the src and data branches alone while enforcing', async () => { + const image = new Image(); + image.src = 'http://example.com/image.png'; + expect( + await enforcing()({ + rr_type: 'HTMLImageElement', + src: 'http://example.com/image.png', + }), + ).toStrictEqual(image); + + expect( + await enforcing()({ + rr_type: 'Blob', + data: [{ rr_type: 'ArrayBuffer', base64: 'AQIABA==' }], + type: 'image/png', + }), + ).toEqual( + new Blob([new Uint8Array([1, 2, 0, 4]).buffer], { + type: 'image/png', + }), + ); + }); + + it.each(['Function', 'Promise', 'XMLHttpRequest', 'Object'])( + 'should not construct %s while enforcing', + async (rr_type) => { + expect(await enforcing()({ rr_type, args: [] })).toBeNull(); + }, + ); + + it('should not evaluate a string passed to a type it does not know', async () => { + const canary = '__deserializeArgCanary'; + delete (globalThis as Record)[canary]; + + const deserialized = await enforcing()({ + rr_type: 'Function', + args: [`globalThis[${JSON.stringify(canary)}] = true;`], + }); + + expect(deserialized).toBeNull(); + expect((globalThis as Record)[canary]).toBeUndefined(); + }); + + it('should reject unknown types nested in an arg list', async () => { + expect( + await enforcing()([1, { rr_type: 'Function', args: ['return 1'] }, 3]), + ).toEqual([1, null, 3]); + }); + + it('should not filter anything when enforcement is off (the default)', async () => { + expect( + await deserializeArg( + new Map(), + context, + )({ rr_type: 'Object', args: [] }), + ).toEqual({}); + }); + }); + describe('isUnchanged', () => { it('should set isUnchanged:true when non of the args are changed', async () => { const status = { diff --git a/packages/rrweb/test/replay/embedded.test.ts b/packages/rrweb/test/replay/embedded.test.ts index d988fe56..e87efc30 100644 --- a/packages/rrweb/test/replay/embedded.test.ts +++ b/packages/rrweb/test/replay/embedded.test.ts @@ -51,12 +51,14 @@ describe('embedded replay protocol', () => { speed: 2, skipInactive: true, UNSAFE_replayCanvas: false, + enforceCanvasArgAllowlist: true, cspContent: "script-src 'none'", }); expect(out).toEqual({ speed: 2, skipInactive: true, UNSAFE_replayCanvas: false, + enforceCanvasArgAllowlist: true, cspContent: "script-src 'none'", }); });