Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Headless
DescriptionInappropriate implementation in Headless
ComponentHeadless
Bug ClassLogic Error
Tracker523639090
Fix commit7bf55f487410 (chromium/src) +317/-218
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Files Changed

  • chrome/browser/headless/test/headless_mode_protocol_browsertest.cc
  • components/headless/test/data/protocol/shared/window-open-click-opener-id-expected.txt
  • components/headless/test/data/protocol/shared/window-open-click-opener-id.js
  • components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id-expected.txt
  • components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id.js
From 7bf55f487410fdd8df3420aaafdb349e44ab44ab Mon Sep 17 00:00:00 2001
From: Peter Kvitek <kvitekp@chromium.org>
Date: Thu, 25 Jun 2026 09:26:58 -0700
Subject: [PATCH] [headless] Defer WebContents opener handling to //content

http://crrev.com/c/7944804 added opener id propagation to headless
shell, however, it should not be specified for Shift+Click navigation.

This CL fixes this by passing the source SiteInstance to
WebContents::Create() and removing manual opener RenderFrameHost
tracking from HeadlessWebContents::Builder.

Also:
- Convert C++ opener suppression and BlockNewWebContents tests to
  protocol JS tests (window-open-noopener-click-opener-id.js and
  block-new-web-contents.js).
- Add window-open-click-opener-id.js protocol test.
- Remove obsolete open-url-sandbox-privileges.js sanity test.

Bug: 523639090
Change-Id: I73d41c3c584b9f17d9c9e62cf8d31f01b1016c3e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7987972
Reviewed-by: Andrey Kosyakov <caseq@chromium.org>
Commit-Queue: Peter Kvitek <kvitekp@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1652483}
---

diff --git a/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc b/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc
index ce8037ef..5ba7b37 100644
--- a/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc
+++ b/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc
@@ -393,6 +393,15 @@
 HEADLESS_MODE_PROTOCOL_TEST(WindowOpenOnSecondaryScreen,
                             "shared/window-open-on-secondary-screen.js")
 
+HEADLESS_MODE_PROTOCOL_TEST(WindowOpenClickOpenerId,
+                            "shared/window-open-click-opener-id.js")
+
+HEADLESS_MODE_PROTOCOL_TEST(WindowOpenNoopenerClickOpenerId,
+                            "shared/window-open-noopener-click-opener-id.js")
+
+HEADLESS_MODE_PROTOCOL_TEST(WindowOpenShiftClickOpenerId,
+                            "shared/window-open-shift-click-opener-id.js")
+
 // TODO(crbug.com/40283476): CreateTargetSecondaryScreen is failing on Mac
 #if !BUILDFLAG(IS_MAC)
 #define MAYBE_CreateTargetSecondaryScreen CreateTargetSecondaryScreen
diff --git a/components/headless/test/data/protocol/shared/window-open-click-opener-id-expected.txt b/components/headless/test/data/protocol/shared/window-open-click-opener-id-expected.txt
new file mode 100644
index 0000000..0ea92c7
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/window-open-click-opener-id-expected.txt
@@ -0,0 +1,2 @@
+Tests that opener IS specified on a page opened via Click.
+TargetInfo.openerId: initialTargetId
\ No newline at end of file
diff --git a/components/headless/test/data/protocol/shared/window-open-click-opener-id.js b/components/headless/test/data/protocol/shared/window-open-click-opener-id.js
new file mode 100644
index 0000000..fa47747
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/window-open-click-opener-id.js
@@ -0,0 +1,72 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// META: --disable-popup-blocking
+
+(async function(testRunner) {
+  const {session, dp} = await testRunner.startBlank(
+      'Tests that opener IS specified on a page opened via Click.');
+
+  const {sessionId} =
+      (await testRunner.browserP().Target.attachToBrowserTarget({})).result;
+  const bp = (new TestRunner.Session(testRunner, sessionId)).protocol;
+
+  const targetInfoResponse = await dp.Target.getTargetInfo();
+  const initialTargetId =
+      (targetInfoResponse.result || targetInfoResponse).targetInfo.targetId;
+
+  await bp.Target.setAutoAttach(
+      {autoAttach: true, waitForDebuggerOnStart: false, flatten: true});
+
+  const targetAttachedPromise = new Promise(resolve => {
+    bp.Target.onAttachedToTarget(event => {
+      const targetInfo = event.params.targetInfo;
+      if (targetInfo.type === 'page' &&
+          targetInfo.targetId !== initialTargetId) {
+        resolve(targetInfo);
+      }
+    });
+  });
+
+  const HttpInterceptor =
+      await testRunner.loadScriptAbsolute('../resources/http-interceptor.js');
+  const httpInterceptor = await (new HttpInterceptor(testRunner, bp)).init();
+  httpInterceptor.setDisableRequestedUrlsLogging(true);
+
+  httpInterceptor.addResponse('https://example.com/index.html', `
+      <html>
+      <body style="margin: 0; padding: 0;">
+        <a href="https://example.com/page2.html" target="_blank"
+           style="display: block; width: 100px; height: 100px;">Click</a>
+      </body>
+      </html>
+  `);
+  httpInterceptor.addResponse('https://example.com/page2.html', `
+      <html><body>Page 2</body></html>
+  `);
+
+  await session.navigate('https://example.com/index.html');
+
+  await dp.Input.dispatchMouseEvent({
+    type: 'mousePressed',
+    x: 50,
+    y: 50,
+    button: 'left',
+    clickCount: 1,
+  });
+  await dp.Input.dispatchMouseEvent({
+    type: 'mouseReleased',
+    x: 50,
+    y: 50,
+    button: 'left',
+    clickCount: 1,
+  });
+
+  const newTargetInfo = await targetAttachedPromise;
+  testRunner.log(`TargetInfo.openerId: ${
+      newTargetInfo.openerId === initialTargetId ? 'initialTargetId' :
+                                                   newTargetInfo.openerId}`);
+
+  testRunner.completeTest();
+});
diff --git a/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id-expected.txt b/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id-expected.txt
new file mode 100644
index 0000000..e032032
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id-expected.txt
@@ -0,0 +1,2 @@
+Tests that opener is NOT specified on a page opened via Click with rel=noopener.
+PASS
\ No newline at end of file
diff --git a/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id.js b/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id.js
new file mode 100644
index 0000000..2889911
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id.js
@@ -0,0 +1,77 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// META: --disable-popup-blocking
+
+(async function(testRunner) {
+  const {session, dp} = await testRunner.startBlank(
+      'Tests that opener is NOT specified on a page opened ' +
+      'via Click with rel=noopener.');
+
+  const {sessionId} =
+      (await testRunner.browserP().Target.attachToBrowserTarget({})).result;
+  const bp = (new TestRunner.Session(testRunner, sessionId)).protocol;
+
+  const targetInfoResponse = await dp.Target.getTargetInfo();
+  const initialTargetId =
+      (targetInfoResponse.result || targetInfoResponse).targetInfo.targetId;
+
+  await bp.Target.setAutoAttach(
+      {autoAttach: true, waitForDebuggerOnStart: false, flatten: true});
+
+  const targetAttachedPromise = new Promise(resolve => {
+    bp.Target.onAttachedToTarget(event => {
+      const targetInfo = event.params.targetInfo;
+      if (targetInfo.type === 'page' &&
+          targetInfo.targetId !== initialTargetId) {
+        resolve(event.params);
+      }
+    });
+  });
+
+  const HttpInterceptor =
+      await testRunner.loadScriptAbsolute('../resources/http-interceptor.js');
+  const httpInterceptor = await (new HttpInterceptor(testRunner, bp)).init();
+  httpInterceptor.setDisableRequestedUrlsLogging(true);
+
+  httpInterceptor.addResponse('https://example.com/index.html', `
+      <html>
+      <body style="margin: 0; padding: 0;">
+        <a href="https://example.com/page2.html" target="_blank" rel="noopener"
+           style="display: block; width: 100px; height: 100px;">Click</a>
+      </body>
+      </html>
+  `);
+  httpInterceptor.addResponse('https://example.com/page2.html', `
+      <html><body>Page 2</body></html>
+  `);
+
+  await session.navigate('https://example.com/index.html');
+
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc b/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc
index ce8037ef..5ba7b37 100644
--- a/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc
+++ b/chrome/browser/headless/test/headless_mode_protocol_browsertest.cc
@@ -393,6 +393,15 @@
 HEADLESS_MODE_PROTOCOL_TEST(WindowOpenOnSecondaryScreen,
                             "shared/window-open-on-secondary-screen.js")
 
+HEADLESS_MODE_PROTOCOL_TEST(WindowOpenClickOpenerId,
+                            "shared/window-open-click-opener-id.js")
+
+HEADLESS_MODE_PROTOCOL_TEST(WindowOpenNoopenerClickOpenerId,
+                            "shared/window-open-noopener-click-opener-id.js")
+
+HEADLESS_MODE_PROTOCOL_TEST(WindowOpenShiftClickOpenerId,
+                            "shared/window-open-shift-click-opener-id.js")
+
 // TODO(crbug.com/40283476): CreateTargetSecondaryScreen is failing on Mac
 #if !BUILDFLAG(IS_MAC)
 #define MAYBE_CreateTargetSecondaryScreen CreateTargetSecondaryScreen
diff --git a/components/headless/test/data/protocol/shared/window-open-click-opener-id-expected.txt b/components/headless/test/data/protocol/shared/window-open-click-opener-id-expected.txt
new file mode 100644
index 0000000..0ea92c7
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/window-open-click-opener-id-expected.txt
@@ -0,0 +1,2 @@
+Tests that opener IS specified on a page opened via Click.
+TargetInfo.openerId: initialTargetId
\ No newline at end of file
diff --git a/components/headless/test/data/protocol/shared/window-open-click-opener-id.js b/components/headless/test/data/protocol/shared/window-open-click-opener-id.js
new file mode 100644
index 0000000..fa47747
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/window-open-click-opener-id.js
@@ -0,0 +1,72 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// META: --disable-popup-blocking
+
+(async function(testRunner) {
+  const {session, dp} = await testRunner.startBlank(
+      'Tests that opener IS specified on a page opened via Click.');
+
+  const {sessionId} =
+      (await testRunner.browserP().Target.attachToBrowserTarget({})).result;
+  const bp = (new TestRunner.Session(testRunner, sessionId)).protocol;
+
+  const targetInfoResponse = await dp.Target.getTargetInfo();
+  const initialTargetId =
+      (targetInfoResponse.result || targetInfoResponse).targetInfo.targetId;
+
+  await bp.Target.setAutoAttach(
+      {autoAttach: true, waitForDebuggerOnStart: false, flatten: true});
+
+  const targetAttachedPromise = new Promise(resolve => {
+    bp.Target.onAttachedToTarget(event => {
+      const targetInfo = event.params.targetInfo;
+      if (targetInfo.type === 'page' &&
+          targetInfo.targetId !== initialTargetId) {
+        resolve(targetInfo);
+      }
+    });
+  });
+
+  const HttpInterceptor =
+      await testRunner.loadScriptAbsolute('../resources/http-interceptor.js');
+  const httpInterceptor = await (new HttpInterceptor(testRunner, bp)).init();
+  httpInterceptor.setDisableRequestedUrlsLogging(true);
+
+  httpInterceptor.addResponse('https://example.com/index.html', `
+      <html>
+      <body style="margin: 0; padding: 0;">
+        <a href="https://example.com/page2.html" target="_blank"
+           style="display: block; width: 100px; height: 100px;">Click</a>
+      </body>
+      </html>
+  `);
+  httpInterceptor.addResponse('https://example.com/page2.html', `
+      <html><body>Page 2</body></html>
+  `);
+
+  await session.navigate('https://example.com/index.html');
+
+  await dp.Input.dispatchMouseEvent({
+    type: 'mousePressed',
+    x: 50,
+    y: 50,
+    button: 'left',
+    clickCount: 1,
+  });
+  await dp.Input.dispatchMouseEvent({
+    type: 'mouseReleased',
+    x: 50,
+    y: 50,
+    button: 'left',
+    clickCount: 1,
+  });
+
+  const newTargetInfo = await targetAttachedPromise;
+  testRunner.log(`TargetInfo.openerId: ${
+      newTargetInfo.openerId === initialTargetId ? 'initialTargetId' :
+                                                   newTargetInfo.openerId}`);
+
+  testRunner.completeTest();
+});
diff --git a/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id-expected.txt b/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id-expected.txt
new file mode 100644
index 0000000..e032032
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id-expected.txt
@@ -0,0 +1,2 @@
+Tests that opener is NOT specified on a page opened via Click with rel=noopener.
+PASS
\ No newline at end of file
diff --git a/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id.js b/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id.js
new file mode 100644
index 0000000..2889911
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/window-open-noopener-click-opener-id.js
@@ -0,0 +1,77 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// META: --disable-popup-blocking
+
+(async function(testRunner) {
+  const {session, dp} = await testRunner.startBlank(
+      'Tests that opener is NOT specified on a page opened ' +
+      'via Click with rel=noopener.');
+
+  const {sessionId} =
+      (await testRunner.browserP().Target.attachToBrowserTarget({})).result;
+  const bp = (new TestRunner.Session(testRunner, sessionId)).protocol;
+
+  const targetInfoResponse = await dp.Target.getTargetInfo();
+  const initialTargetId =
+      (targetInfoResponse.result || targetInfoResponse).targetInfo.targetId;
+
+  await bp.Target.setAutoAttach(
+      {autoAttach: true, waitForDebuggerOnStart: false, flatten: true});
+
+  const targetAttachedPromise = new Promise(resolve => {
+    bp.Target.onAttachedToTarget(event => {
+      const targetInfo = event.params.targetInfo;
+      if (targetInfo.type === 'page' &&
+          targetInfo.targetId !== initialTargetId) {
+        resolve(event.params);
+      }
+    });
+  });
+
+  const HttpInterceptor =
+      await testRunner.loadScriptAbsolute('../resources/http-interceptor.js');
+  const httpInterceptor = await (new HttpInterceptor(testRunner, bp)).init();
+  httpInterceptor.setDisableRequestedUrlsLogging(true);
+
+  httpInterceptor.addResponse('https://example.com/index.html', `
+      <html>
+      <body style="margin: 0; padding: 0;">
+        <a href="https://example.com/page2.html" target="_blank" rel="noopener"
+           style="display: block; width: 100px; height: 100px;">Click</a>
+      </body>
+      </html>
+  `);
+  httpInterceptor.addResponse('https://example.com/page2.html', `
+      <html><body>Page 2</body></html>
+  `);
+
+  await session.navigate('https://example.com/index.html');
+
+  await dp.Input.dispatchMouseEvent({
+    type: 'mousePressed',
+    x: 50,
+    y: 50,
+    button: 'left',
+    clickCount: 1,
+  });
+  await dp.Input.dispatchMouseEvent({
+    type: 'mouseReleased',
+    x: 50,
+    y: 50,
+    button: 'left',
+    clickCount: 1,
+  });
+
+  const {sessionId: childSessionId} = await targetAttachedPromise;
+  const childSession = new TestRunner.Session(testRunner, childSessionId);
+  const hasOpener = await childSession.evaluate('window.opener !== null');
+  if (!hasOpener) {
+    testRunner.log('PASS');
+  } else {
+    testRunner.log('FAIL: window.opener is not null');
+  }
+
+  testRunner.completeTest();
+});
diff --git a/components/headless/test/data/protocol/shared/window-open-shift-click-opener-id-expected.txt b/components/headless/test/data/protocol/shared/window-open-shift-click-opener-id-expected.txt
new file mode 100644
index 0000000..c817003
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/window-open-shift-click-opener-id-expected.txt
@@ -0,0 +1,2 @@
+Tests that opener is NOT specified on a page opened via Shift+Click.
+TargetInfo.openerId: undefined
\ No newline at end of file
diff --git a/components/headless/test/data/protocol/shared/window-open-shift-click-opener-id.js b/components/headless/test/data/protocol/shared/window-open-shift-click-opener-id.js
new file mode 100644
index 0000000..8be6cf20
--- /dev/null
+++ b/components/headless/test/data/protocol/shared/window-open-shift-click-opener-id.js
@@ -0,0 +1,72 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+//
+// META: --disable-popup-blocking
+
+(async function(testRunner) {
+  const {session, dp} = await testRunner.startBlank(
+      'Tests that opener is NOT specified on a page opened via Shift+Click.');
+
+  const {sessionId} =
+      (await testRunner.browserP().Target.attachToBrowserTarget({})).result;
+  const bp = (new TestRunner.Session(testRunner, sessionId)).protocol;
+
+  const targetInfoResponse = await dp.Target.getTargetInfo();
+  const initialTargetId =
+      (targetInfoResponse.result || targetInfoResponse).targetInfo.targetId;
+
+  await bp.Target.setAutoAttach(
+      {autoAttach: true, waitForDebuggerOnStart: false, flatten: true});
+
+  const targetAttachedPromise = new Promise(resolve => {
+    bp.Target.onAttachedToTarget(event => {
+      const targetInfo = event.params.targetInfo;
+      if (targetInfo.type === 'page' &&
+          targetInfo.targetId !== initialTargetId) {
+        resolve(targetInfo);
+      }
+    });
+  });
+
+  const HttpInterceptor =
+      await testRunner.loadScriptAbsolute('../resources/http-interceptor.js');
+  const httpInterceptor = await (new HttpInterceptor(testRunner, bp)).init();
+  httpInterceptor.setDisableRequestedUrlsLogging(true);
+
+  httpInterceptor.addResponse('https://example.com/index.html', `
+      <html>
+      <body style="margin: 0; padding: 0;">
+        <a href="https://example.com/page2.html" target="_blank"
+           style="display: block; width: 100px; height: 100px;">Click</a>
+      </body>
+      </html>
+  `);
+  httpInterceptor.addResponse('https://example.com/page2.html', `
+      <html><body>Page 2</body></html>
+  `);
+
+  await session.navigate('https://example.com/index.html');
+
+  await dp.Input.dispatchMouseEvent({
+    type: 'mousePressed',
+    x: 50,
+    y: 50,
+    button: 'left',
+    clickCount: 1,
+    modifiers: 8,  // 8 = Shift modifier
+  });
+  await dp.Input.dispatchMouseEvent({
+    type: 'mouseReleased',
+    x: 50,
+    y: 50,
+    button: 'left',
+    clickCount: 1,
+    modifiers: 8,  // 8 = Shift modifier
+  });
+
+  const newTargetInfo = await targetAttachedPromise;
+  testRunner.log(`TargetInfo.openerId: ${newTargetInfo.openerId}`);
+
+  testRunner.completeTest();
+});
diff --git a/headless/test/data/protocol/sanity/block-new-web-contents-expected.txt b/headless/test/data/protocol/sanity/block-new-web-contents-expected.txt
new file mode 100644
index 0000000..6ca8945a
--- /dev/null
+++ b/headless/test/data/protocol/sanity/block-new-web-contents-expected.txt
@@ -0,0 +1,3 @@
+Tests that --block-new-web-contents blocks new web contents creation.
+window.open() result: null
+New page targets count: 0
\ No newline at end of file
diff --git a/headless/test/data/protocol/sanity/block-new-web-contents.js b/headless/test/data/protocol/sanity/block-new-web-contents.js
new file mode 100644
index 0000000..f0f1ef5
--- /dev/null
... (truncated)
Loading diff…

Original Bug Report

reported by rj...@google.com

Sandbox escape in Headless Chrome via OpenURLFromTab dropping the opener

Flapjack, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: Headless Chrome fails to propagate sandbox flags when a navigation opens a new tab via OpenURLFromTab. An attacker in a sandboxed iframe with allow-popups can use a synthetic middle-click to trigger this path, bypassing the sandbox restrictions and the headless popup blocker.

Affected files:

  • headless/lib/browser/headless_web_contents_impl.cc

Estimated timestamp from git blame: Unknown (Google3 checkout)

Description

In Headless Chrome, the HeadlessWebContentsImpl::Delegate::OpenURLFromTab method handles navigations that open in a new tab or window (e.g., via a middle-click). When processing NEW_FOREGROUND_TAB or NEW_BACKGROUND_TAB dispositions, the method creates a new WebContents instance using CreateWebContentsBuilder().Build().

However, this creation path fails to provide an opener reference to the WebContents::CreateParams. In Chromium’s content layer, sandbox flags and security policies are inherited from the opener during the creation of a new WebContents (specifically in WebContentsImpl::CreateWithOpener). Because the opener IDs are left uninitialized (defaulting to kInvalidUniqueID and kRoutingIdNone), the new window is created with a default, unrestricted security context. It fails to inherit the sandbox restrictions of the initiating frame.

Potential Attack Steps

(Note: These are suggested steps; our tooling agent does not yet have the ability to run code to verify them dynamically.)

  1. An attacker embeds a sandboxed iframe in a page loaded by Headless Chrome. The iframe has sandbox="allow-scripts allow-popups" (notably lacking allow-popups-to-escape-sandbox).
  2. Inside the iframe, the attacker executes JavaScript to create an anchor (<a>) and dispatches a synthetic middle-click event on it: a.dispatchEvent(new MouseEvent('click', {button: 1})).
  3. The Blink renderer evaluates the middle-click as an untrusted event targeting a new tab. Because allow-popups is present in the sandbox flags, Blink allows the untrusted event to proceed (FrameLoader::StartNavigation) and routes it to RenderFrameImpl::OpenURL.
  4. The browser process receives the IPC and invokes HeadlessWebContentsImpl::Delegate::OpenURLFromTab with a NEW_FOREGROUND_TAB or NEW_BACKGROUND_TAB disposition.
  5. The new WebContents is created via HeadlessWebContentsImpl::Create, missing the opener metadata.
  6. The new tab loads the attacker’s URL without the sandbox restrictions of the initiating iframe, granting full privileges of the origin.

This attack vector is highly reliable in Headless Chrome because it lacks the standard popup blocker that would typically block synthetic popup events in regular Chrome. Furthermore, this specific code path completely bypasses the Headless block_new_web_contents() setting, which embedders rely on to prevent untrusted popups.

Vulnerable Code

In headless/lib/browser/headless_web_contents_impl.cc, the OpenURLFromTab implementation creates the new contents without specifying an opener:

      case WindowOpenDisposition::NEW_POPUP:
      case WindowOpenDisposition::NEW_WINDOW:
      case WindowOpenDisposition::NEW_BACKGROUND_TAB:
      case WindowOpenDisposition::NEW_FOREGROUND_TAB: {
        HeadlessWebContentsImpl* child_contents = HeadlessWebContentsImpl::From(
            headless_web_contents_->browser_context()
                ->CreateWebContentsBuilder()
                .SetWindowBounds(source->GetContainerBounds())
                .Build());
        target = child_contents->web_contents();
        break;
      }

The Build() call invokes HeadlessWebContentsImpl::Create, which initializes content::WebContents::CreateParams without the opener reference:

std::unique_ptr<HeadlessWebContentsImpl> HeadlessWebContentsImpl::Create(
    HeadlessWebContents::Builder* builder) {
  content::WebContents::CreateParams create_params(builder->browser_context_);
  auto headless_web_contents = base::WrapUnique(
      new HeadlessWebContentsImpl(content::WebContents::Create(create_params)));
  // ...
}

Suggested Fix

  1. Update HeadlessWebContents::Builder to accept an optional opener content::WebContents* reference.
  2. In HeadlessWebContentsImpl::Create, use the provided opener reference to set the opener_render_process_id, opener_render_frame_id, and opener_suppressed fields in content::WebContents::CreateParams.
  3. In HeadlessWebContentsImpl::Delegate::OpenURLFromTab, pass the source WebContents as the opener to the builder before calling Build().

Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker