Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .changeset/tasty-shoes-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@newrelic/rrweb-snapshot": patch
---

Patch image inlining so that images are unaltered in the DOM. Prevents images from breaking when server doesn't support CORS.
77 changes: 52 additions & 25 deletions packages/rrweb-snapshot/src/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,36 +693,63 @@ function serializeElementNode(
canvasCtx = canvasService.getContext('2d');
}
const image = n as HTMLImageElement;
const imageSrc: string =
image.currentSrc || image.getAttribute('src') || '<unknown-src>';
const priorCrossOrigin = image.crossOrigin;
const recordInlineImage = () => {
image.removeEventListener('load', recordInlineImage);

const copyImageToDataURL = (img: HTMLImageElement) : { dataURL?: string; isSecurityError?: boolean; err?: Error; } => {
try {
canvasService!.width = image.naturalWidth;
canvasService!.height = image.naturalHeight;
canvasCtx!.drawImage(image, 0, 0);
attributes.rr_dataURL = canvasService!.toDataURL(
dataURLOptions.type,
dataURLOptions.quality,
);
} catch (err) {
if (image.crossOrigin !== 'anonymous') {
image.crossOrigin = 'anonymous';
if (image.complete && image.naturalWidth !== 0)
recordInlineImage(); // too early due to image reload
else image.addEventListener('load', recordInlineImage);
return;
canvasService!.width = img.naturalWidth;
canvasService!.height = img.naturalHeight;
canvasCtx!.drawImage(img, 0, 0);
return {
dataURL: canvasService!.toDataURL(
dataURLOptions.type,
dataURLOptions.quality,
)
};
} catch(err) {
return {
isSecurityError: (err instanceof DOMException && err.name === 'SecurityError'),
err: err instanceof Error ? err : new Error(String(err)),
}
}
}

// Try with a transient CORS-enabled image (doesn't modify live DOM)
const tryWithCorsImage = (imgSrc: string | undefined | null) => {
if (!imgSrc) {
console.warn('Unknown image src, cannot retry with CORS image.');
return;
}
const corsImage = new Image();
corsImage.crossOrigin = 'anonymous';
corsImage.onload = () => {
const result = copyImageToDataURL(corsImage);
if (typeof result.dataURL === 'string') {
attributes.rr_dataURL = result.dataURL;
} else {
console.warn(
`Cannot inline img src=${imageSrc}! Error: ${err as string}`,
`Cannot inline img src=${imgSrc}! Canvas still tainted after CORS retry.`,
);
}
}
if (image.crossOrigin === 'anonymous') {
priorCrossOrigin
? (attributes.crossOrigin = priorCrossOrigin)
: image.removeAttribute('crossorigin');
};
corsImage.onerror = () => {
console.warn(`Cannot inline img src="${imgSrc}"! CORS request failed.`);
};
corsImage.src = imgSrc;
};

const recordInlineImage = () => {
image.removeEventListener('load', recordInlineImage);
const result = copyImageToDataURL(image);
const imageSrc = image.currentSrc || image.getAttribute('src');
if (typeof result.dataURL === 'string') {
attributes.rr_dataURL = result.dataURL
} else if (result.isSecurityError && image.crossOrigin !== 'anonymous') {
// Canvas is tainted by a cross-origin image loaded without CORS.
// Re-fetch via a detached Image with crossOrigin='anonymous' so the
// original DOM element is never mutated.
tryWithCorsImage(imageSrc);
} else if (result.err) {
console.warn(`Cannot inline img src=${imageSrc ?? '<unknown-src>'}! Error:`, result.err);
}
};
// The image content may not have finished loading yet.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
72 changes: 71 additions & 1 deletion packages/rrweb-snapshot/test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ const startServer = (defaultPort: number = 3030) =>
const data = fs.readFileSync(pathname);
const ext = path.parse(pathname).ext;
res.setHeader('Content-type', mimeType[ext] || 'text/plain');
res.setHeader('Access-Control-Allow-Origin', '*');
if (!sanitizePath.includes('no-cors')) {
res.setHeader('Access-Control-Allow-Origin', '*');
}
res.setHeader('Access-Control-Allow-Methods', 'GET');
res.setHeader('Access-Control-Allow-Headers', 'Content-type');
res.end(data);
Expand Down Expand Up @@ -306,6 +308,74 @@ iframe.contentDocument.querySelector('center').clientHeight
);
});

it('does not break the original image when inlining image fails (CORS is rejected)', async () => {
const page: puppeteer.Page = await browser.newPage();
// null/opaque origins will reject attempts to read the image data, but we should ensure that the original image on the live DOM is unaffected (i.e. doesn't get crossOrigin="anonymous" added, which would break the image loading)
await page.goto('about:blank', {
waitUntil: 'load',
});
await page.setContent(
`
<html xmlns="http://www.w3.org/1999/xhtml">
<body>
<img src="${getServerURL(
server,
)}/images/no-cors/rrweb-favicon-20x20.png" alt="CORS restricted and server does not support CORS" />
</body>
</html>
`,
{
waitUntil: 'load',
},
);

const warnings: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'warning') warnings.push(msg.text());
});

await page.waitForSelector('img', { timeout: 1000 });
await page.evaluate(`${code}var snapshot = rrwebSnapshot.snapshot(document, {
dataURLOptions: { type: "image/webp", quality: 0.8 },
inlineImages: true,
inlineStylesheet: false
})`);
await waitForRAF(page); // wait for the async fetch attempt to complete and fail

const bodyChildren = (await page.evaluate(`
snapshot.childNodes[0].childNodes[1].childNodes.filter((cn) => cn.type === 2);
`)) as any[];

// inlining should have failed silently — snapshot contains the image but no rr_dataURL
expect(bodyChildren[0]).toEqual(
expect.objectContaining({
tagName: 'img',
attributes: expect.objectContaining({
src: getServerURL(server) + '/images/no-cors/rrweb-favicon-20x20.png',
alt: 'CORS restricted and server does not support CORS',
}),
}),
);
expect(bodyChildren[0].attributes.rr_dataURL).toBeUndefined();

// the live DOM image must not have crossOrigin mutated by rrweb
const crossOriginAttr = (await page.evaluate(
`document.querySelector('img').getAttribute('crossorigin')`,
)) as string | null;
expect(crossOriginAttr).toBeNull();

// the image should still be loaded and visible
const naturalWidth = (await page.evaluate(
`document.querySelector('img').naturalWidth`,
)) as number;
expect(naturalWidth).toBeGreaterThan(0);

// a warning should have been logged indicating the CORS failure
expect(warnings.length).toBe(1)
expect(warnings[0]).toBe('Cannot inline img src="' + getServerURL(server) + '/images/no-cors/rrweb-favicon-20x20.png"! CORS request failed.');
});


it('correctly saves blob:images offline', async () => {
const page: puppeteer.Page = await browser.newPage();

Expand Down
Loading