Skip to content

[Bug]: connectOverCDP replaces the page's main frame with a nested sandboxed iframe when its parentFrameId is unknown #42955

Closed

Description

@Elijah-J

Version

1.63.0

Steps to reproduce

Self-contained, Playwright-only (Node's http server plus the bundled Chromium, spawned with --remote-debugging-port so the page is fully loaded before connectOverCDP):

  1. npm i playwright@1.63.0 && npx playwright install chromium
  2. Save the script below as repro.mjs and run node repro.mjs

The page shape that triggers it: top document (127.0.0.1) > cross-site iframe (localhost, out of process) > same-process child iframe > sandbox srcdoc iframe (its own iframe target).

// connectOverCDP to an already-loaded page: the Page's main frame is replaced by a
// nested out-of-process iframe. Page shape: top (127.0.0.1) > cross-site iframe
// (localhost) > same-process child iframe > sandboxed srcdoc iframe.
import http from 'node:http';
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { chromium } from 'playwright';

const server = http.createServer((req, res) => {
  const port = server.address().port;
  const pages = {
    '/': `<h1 id=top>TOP</h1><iframe src="http://localhost:${port}/b"></iframe>`,
    '/b': `<iframe src="/c"></iframe>`,
    '/c': `<iframe sandbox="allow-scripts" srcdoc="<p>D</p>"></iframe>`,
  };
  res.setHeader('content-type', 'text/html');
  res.end(pages[req.url] ?? '');
}).listen(0, '127.0.0.1');
await new Promise(r => server.once('listening', r));
const url = `http://127.0.0.1:${server.address().port}/`;

// Chrome with the page already loaded before Playwright connects.
const chrome = spawn(process.env.CHROME || chromium.executablePath(), [
  '--headless=new', '--remote-debugging-port=9333', '--no-first-run',
  `--user-data-dir=${mkdtempSync(join(tmpdir(), 'pw-repro-'))}`, url,
], { stdio: 'ignore' });
for (let i = 0; i < 100; i++) {
  try { const r = await fetch('http://127.0.0.1:9333/json/list'); if ((await r.json()).some(t => t.url === url)) break; } catch {}
  await new Promise(r => setTimeout(r, 200));
}
await new Promise(r => setTimeout(r, 2000)); // let all frames load

const browser = await chromium.connectOverCDP('http://127.0.0.1:9333');
try {
const pages = browser.contexts().flatMap(c => c.pages());
console.log('expected top url     :', url);
console.log('pages()              :', pages.map(p => p.url()));
const page = pages.find(p => p.url() === url) ?? pages.find(p => !p.url().startsWith('chrome'));
console.log('page.url()           :', page.url());
console.log('evaluate location    :', await page.evaluate(() => location.href));
console.log('window.top === window:', await page.evaluate(() => window.top === window));
console.log('frames()             :', page.frames().map(f => f.url()));
console.log('locator("#top")      :', await page.locator('#top').count());

} catch (e) {
  console.log('ERROR                :', e.message.split('\n')[0]);
} finally {
  await browser.close();
  chrome.kill();
  server.close();
}

Expected behavior

The page returned by connectOverCDP evaluates in its top-level document: location.href is the top URL, window.top === window is true, and locator('#top') finds the element.

Actual behavior

The Page's main frame is replaced by the nested iframe.

  • Playwright 1.63.0 + bundled Chromium 153.0.8010.12 (v1243): page.evaluate throws Execution context was destroyed, most likely because of a navigation in 8 of 8 runs (2 of 3 in an earlier batch), even though nothing navigates.
  • Playwright 1.63.0 (and 1.60.0) through the Python binding, same page shape, Chrome for Testing 149.0.7827.55 (v1228): page.evaluate("location.href") returns about:srcdoc, page.frames lists only the top URL, and locator("#top").count() is 0. Every call runs inside the sandboxed srcdoc frame, and keyboard input doesn't reach the top document.

It first showed up on a real site whose ad stack nests sandboxed srcdoc frames inside cross-site ad iframes. Once the ads had loaded, every fresh connectOverCDP returned a page whose evaluate ran inside an ad frame (location.href === 'about:srcdoc', window.top !== window), while raw Runtime.evaluate on a page-level CDP session still reached the real document.

Additional context

The likely cause is in the iframe branch of FrameSession._onAttachedToTarget (crPage.ts):

if (!frame && event.targetInfo.parentFrameId) {
  frame = this._page.frameManager.frameAttached(targetId, event.targetInfo.parentFrameId);
}

When parentFrameId names a frame the FrameManager has never registered (here, the same-process child of an out-of-process iframe on an already-loaded page), FrameManager.frameAttached (frames.ts) finds no parent and takes the "no parent" path. That path renames the main frame to the iframe's id (this._mainFrame._id = frameId), and the child session's execution contexts then bind to the main frame.

The same file already guards the frame-tree path against an unknown parent (if (parentFrameId && !this._page.frameManager.frame(parentFrameId)) return; in _onFrameAttached). Applying the same check here made the repro pass 8 of 8 runs locally (patched coreBundle.js, 1.63.0, Chromium v1243):

if (!frame && event.targetInfo.parentFrameId && this._page.frameManager.frame(event.targetInfo.parentFrameId)) {

With that guard the nested frames are skipped rather than attached (the same limitation as #17656), but the Page keeps its real main frame.

Related: #17656 (the parentFrameId attach path came from its fix), #36095. Neither covers the main-frame replacement.

Environment

OS: macOS 15.7.7 (x86_64)
Node: 26.3.0
playwright: 1.63.0 (also reproduced with 1.60.0 via playwright-python)
Browsers: Chromium 153.0.8010.12 (playwright v1243); Chrome for Testing 149.0.7827.55 (v1228)

Activity

  1. nyxxbit commented on Sep 29, 2026

    @nyxxbit

    Independent repro on the latest stable Chrome, 154.0.8037.58, so this is not something a newer Chromium has quietly fixed since your 153/149 runs. Playwright 1.63.0, your script verbatim with CHROME pointed at system Chrome:

    expected top url     : http://127.0.0.1:62045/
    pages()              : [ 'http://127.0.0.1:62045/' ]
    page.url()           : http://127.0.0.1:62045/
    ERROR                : page.evaluate: Execution context was destroyed, most likely because of a navigation
    

    5 of 5 runs, nothing navigates. Same shape as your Node + bundled-Chromium row: page.url() still reads the top document, but the first page.evaluate dies because the main frame was swapped out from under it.

    Your root cause holds on 1.63.0. The rename is right there in the shipped bundle (playwright-core/lib/coreBundle.js, the "no parent" branch of frameAttached):

    this._frames.delete(this._mainFrame._id);
    this._mainFrame._id = frameId;

    So when the sandboxed srcdoc target attaches with a parentFrameId the FrameManager never registered (its parent being the same-process child of the out-of-process localhost iframe, which lives on a different session and was never surfaced as a frame at connect time), it falls through to that branch and the top document's id gets reassigned to the ad frame. Every later evaluate then resolves against the wrong world. Clean repro, and the trigger (already-loaded page + OOPIF with a nested sandboxed srcdoc) is exactly the kind of thing a real ad stack produces.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions