Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -371,8 +371,13 @@ export function frameSnapshotStreamer(snapshotStreamer: string, removeNoScript:
}
if (removeNoScript && nodeName === 'NOSCRIPT')
return;
if (nodeName === 'META' && (node as HTMLMetaElement).httpEquiv.toLowerCase() === 'content-security-policy')
return;
if (nodeName === 'META') {
const httpEquiv = (node as HTMLMetaElement).httpEquiv.toLowerCase();
// Drop META directives that can navigate, set cookies, or otherwise
// affect the trace viewer when the recorded snapshot is rendered.
if (httpEquiv === 'content-security-policy' || httpEquiv === 'refresh' || httpEquiv === 'set-cookie')
return;
}
// Skip iframes which are inside document's head as they are not visible.
// See https://github.com/microsoft/playwright/issues/12005.
if ((nodeName === 'IFRAME' || nodeName === 'FRAME') && headNesting)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,13 @@ export class SnapshotRenderer {
const isFrame = nodeName === 'IFRAME' || nodeName === 'FRAME';
const isAnchor = nodeName === 'A';
const isImg = nodeName === 'IMG';
const isMeta = nodeName === 'META';
const isImgWithCurrentSrc = isImg && attrs.some(a => a[0] === kCurrentSrcAttribute);
const isSourceInsidePictureWithCurrentSrc = nodeName === 'SOURCE' && parentTag === 'PICTURE' && parentAttrs?.some(a => a[0] === kCurrentSrcAttribute);
// For META, only allow a small whitelist of http-equiv directives so a malicious snapshot
// cannot navigate the snapshot iframe via e.g. <meta http-equiv="refresh"> or otherwise
// affect the trace viewer.
const hasUnsafeHttpEquiv = isMeta && attrs.some(a => a[0].toLowerCase() === 'http-equiv' && !kAllowedMetaHttpEquivs.has(a[1].trim().toLowerCase()));
for (const [attr, value] of attrs) {
let attrName = attr;
if (isFrame && attr.toLowerCase() === 'src') {
Expand All @@ -127,6 +132,10 @@ export class SnapshotRenderer {
// we will be using the currentSrc instead.
attrName = '_' + attrName;
}
if (hasUnsafeHttpEquiv && (attr.toLowerCase() === 'http-equiv' || attr.toLowerCase() === 'content')) {
// Neutralize the META directive by renaming the attribute so the browser ignores it.
attrName = '_' + attr;
}
let attrValue = value;
if (!isAnchor && (attr.toLowerCase() === 'href' || attr.toLowerCase() === 'src' || attr === kCurrentSrcAttribute))
attrValue = rewriteURLForCustomProtocol(value);
Expand Down Expand Up @@ -220,6 +229,10 @@ export class SnapshotRenderer {

const autoClosing = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);

// Whitelist of META http-equiv directives that are safe to render in the trace viewer.
// Notably excludes 'refresh' (auto-navigation), 'set-cookie' and 'content-security-policy'.
const kAllowedMetaHttpEquivs = new Set(['content-type', 'content-language', 'default-style', 'x-ua-compatible']);

function snapshotNodes(snapshot: FrameSnapshot): NodeSnapshot[] {
if (!(snapshot as any)._nodes) {
const nodes: NodeSnapshot[] = [];
Expand Down
2 changes: 1 addition & 1 deletion packages/trace-viewer/snapshot.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<!DOCTYPE html>
<html lang="en">
<body>
<iframe src="about:blank" style="position:absolute;top:0;left:0;right:0;bottom:0;width:100%;height:100%;border:none;"></iframe>
<iframe src="about:blank" sandbox="allow-same-origin allow-scripts" style="position:absolute;top:0;left:0;right:0;bottom:0;width:100%;height:100%;border:none;"></iframe>
<script>
(async () => {
if (!navigator.serviceWorker)
Expand Down
4 changes: 2 additions & 2 deletions packages/trace-viewer/src/ui/snapshotTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,8 @@ export const SnapshotView: React.FunctionComponent<{
iteration={loadingRef.current.iteration} />
<SnapshotWrapper snapshotInfo={snapshotInfo}>
<div className='snapshot-switcher'>
<iframe ref={iframeRef0} name='snapshot' title='DOM Snapshot' className={clsx(loadingRef.current.visibleIframe === 0 && 'snapshot-visible')}></iframe>
<iframe ref={iframeRef1} name='snapshot' title='DOM Snapshot' className={clsx(loadingRef.current.visibleIframe === 1 && 'snapshot-visible')}></iframe>
<iframe ref={iframeRef0} name='snapshot' title='DOM Snapshot' sandbox='allow-same-origin allow-scripts' className={clsx(loadingRef.current.visibleIframe === 0 && 'snapshot-visible')}></iframe>
<iframe ref={iframeRef1} name='snapshot' title='DOM Snapshot' sandbox='allow-same-origin allow-scripts' className={clsx(loadingRef.current.visibleIframe === 1 && 'snapshot-visible')}></iframe>
</div>
</SnapshotWrapper>
</div>;
Expand Down
57 changes: 57 additions & 0 deletions tests/library/trace-viewer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2272,6 +2272,63 @@ test('should replace meta content attr that specifies charset', async ({ runAndT
await expect.poll(() => frame.locator('body').evaluate(() => document.querySelector('meta')?.outerHTML.toLowerCase())).toBe('<meta http-equiv="content-type" content="text/html; charset=utf-8">');
});

test('should drop meta http-equiv refresh during recording', async ({ runAndTrace, page, server }) => {
const traceViewer = await runAndTrace(async () => {
await page.goto(server.EMPTY_PAGE);
// Use a very large delay so the recorded page does not actually navigate before the snapshot is taken.
await page.setContent(`
<head>
<meta http-equiv="refresh" content="999999; url=https://example.com/evil">
</head>
<body><div>safe</div></body>
`);
});
const frame = await traceViewer.snapshotFrame('Set content');
await expect(frame.locator('div')).toHaveText('safe');
// Refresh META must be stripped from the snapshot, so the iframe is not navigated.
await expect.poll(() => frame.locator('head').evaluate(head => head.querySelector('meta[http-equiv]')?.outerHTML.toLowerCase() ?? '')).toBe('');
// The trace viewer top-level title and body must remain untouched.
await expect(traceViewer.page).toHaveTitle(/Playwright Trace Viewer/);
await expect(traceViewer.page.locator('body')).not.toHaveAttribute('data-pwned', /.*/);
});

test('should neutralize meta http-equiv refresh during rendering', async ({ runAndTrace, page, server }) => {
// Inject a META refresh directly via DOM manipulation that bypasses the
// recording-time strip (so this test specifically validates the renderer-side defense).
const traceViewer = await runAndTrace(async () => {
await page.goto(server.EMPTY_PAGE);
await page.evaluate(() => {
const meta = document.createElement('meta');
meta.setAttribute('http-equiv', 'refresh');
meta.setAttribute('content', '999999; url=https://example.com/evil');
document.head.appendChild(meta);
const div = document.createElement('div');
div.textContent = 'safe';
document.body.appendChild(div);
});
});
const frame = await traceViewer.snapshotFrame('Evaluate');
await expect(frame.locator('div')).toHaveText('safe');
// Even if a META refresh is in the recorded snapshot, the renderer must
// neutralize the http-equiv and content attributes so it has no effect.
await expect.poll(() => frame.locator('head').evaluate(head => !!head.querySelector('meta[http-equiv="refresh"]'))).toBe(false);
await expect(traceViewer.page).toHaveTitle(/Playwright Trace Viewer/);
});

test('snapshot iframes should be sandboxed', async ({ runAndTrace, page, server }) => {
const traceViewer = await runAndTrace(async () => {
await page.goto(server.EMPTY_PAGE);
await page.setContent('<div>hello</div>');
});
await traceViewer.snapshotFrame('Set content');
const sandboxValues = await traceViewer.page.locator('iframe[name=snapshot]').evaluateAll(frames => frames.map(f => f.getAttribute('sandbox')));
for (const value of sandboxValues) {
expect(value).toBeTruthy();
expect(value).toContain('allow-same-origin');
expect(value).toContain('allow-scripts');
}
});

test('should capture iframe with srcdoc', async ({ page, server, runAndTrace }) => {
await page.route('**/empty.html', route => {
void route.fulfill({
Expand Down
Loading