CVE-2026-95352
Overview
Background
- `scriptToEvaluateOnLoad`
- an optional DevTools
Page.reloadparameter that injects and runs an attacker-supplied script in the freshly loaded document before its own scripts execute. - `ReadyToCommitNavigation`
- the browser-process navigation lifecycle callback that fires once a response has been received and the destination origin is known, just before the document commits.
- Server redirect
- an HTTP
3xxresponse that sends the navigation to a different URL (potentially a different origin) than the one originally requested byPage.reload. - Last-committed origin
- the security origin (
url::Origin) of the document currently loaded in a frame, used as the trust boundary for deciding what may run in it.
Root Cause Analysis
When Page.reload was issued with scriptToEvaluateOnLoad, PageHandler stashed the script in pending_script_to_evaluate_on_load_ and delivered it unconditionally to whichever document ultimately committed the reload. Because a server redirect can steer that reload to an arbitrary cross-origin destination, the pending script was injected into a document whose origin differed from the page the operator (or extension) had targeted, violating the invariant that a script authorized for origin A must never execute in origin B.
The fix records the frame’s last-committed origin into initiating_origin_ at the moment the reload is requested, then in ReadyToCommitNavigation compares it against the navigation’s actual GetOriginToCommit() when a server redirect occurred and clears the pending script on any origin mismatch. This closes the gap because the origin-to-commit is only known after redirects are resolved, so the check happens exactly when the true destination origin is available and before the script is handed off for injection.
Attack Path
- Obtain DevTools access
A malicious or over-permissioned extension (or other
Pagedomain client) gains the ability to issuePage.reloadagainst a target tab. (inferred) - Reload with an injected script
The client calls
Page.reloadwithscriptToEvaluateOnLoadwhile the tab is on a trusted origin the client is allowed to script, causing the script to be stored inpending_script_to_evaluate_on_load_. - Redirect to a foreign origin
The reloaded request returns a server redirect (
3xx) whoseLocationpoints to a different origin, so the navigation commits into a document the client was never authorized to touch. - Cross-origin script injection
Because the pre-patch code delivered
pending_script_to_evaluate_on_load_regardless of the redirect, the attacker’s script executes in the cross-origin document, running with that origin’s privileges. - Exfiltrate or manipulate victim origin With script running in the victim origin, the attacker can read page state, cookies exposed to script, or drive same-origin requests. (inferred)
Impact Assessment
Page.reload with scriptToEvaluateOnLoad (e.g., an extension with debugging privileges) and a reload target that server-redirects cross-origin. The advisory does not indicate exploitation in the wild, and any escalation beyond cross-origin script execution into broader data theft or further compromise is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
SkBitmapcontent/browser/devtools/protocol/page_handler.h |
modified |
Files Changed
content/browser/devtools/protocol/page_handler.cccontent/browser/devtools/protocol/page_handler.hthird_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script.jsthird_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script-expected.txt
Audit Directions
- Deferred DevTools payloads across navigation boundariesReview any DevTools/
contentstate stashed at request time and applied at commit (auto-attach scripts, header overrides,addScriptToEvaluateOnNewDocument) to confirm it is re-validated against the actual committing origin after redirects. - Redirect-changed origin assumptionsLook for code that trusts the pre-navigation URL or origin without consulting
GetOriginToCommit()/WasServerRedirect(), since server redirects can change the destination origin after authorization was decided. - Extension-reachable debugging surfacesAudit
Page/Runtime/Debuggercommands exposed to extensions for operations that grant one origin’s client the ability to influence another origin’s document.
Patch
From 63514758b188258126adc42aad239c47f895c2df Mon Sep 17 00:00:00 2001 From: Yulun Zeng <[email protected]> Date: Wed, 19 Aug 2026 07:40:22 -0700 Subject: [PATCH] DevTools: Drop scriptToEvaluateOnLoad on cross-origin redirect Remember the last-committed origin when Page.reload is issued and drop the pending script in ReadyToCommitNavigation if it's redirecting to a different origin. This disallows cross-origin script execution from an extension. TAG=agy CONV=095e4c73-db36-48ea-80cd-a4b47ad1cc24 Bug: 513791872 Change-Id: I441690f1e340db3108d03b2ff1e6d1d551ec0dfc Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8266043 Reviewed-by: Danil Somsikov <[email protected]> Commit-Queue: Yulun Zeng <[email protected]> Cr-Commit-Position: refs/heads/main@{#1682289} --- diff --git a/content/browser/devtools/protocol/page_handler.cc b/content/browser/devtools/protocol/page_handler.cc index 431a0e3f..9fc740d 100644 --- a/content/browser/devtools/protocol/page_handler.cc +++ b/content/browser/devtools/protocol/page_handler.cc @@ -862,6 +862,7 @@ have_pending_reload_ = true; pending_script_to_evaluate_on_load_ = script_to_evaluate_on_load.value_or(""); + initiating_origin_ = outermost_main_frame->GetLastCommittedOrigin(); navigation_controller.Reload(reload_type, false); callback->sendSuccess(); } else { @@ -2668,6 +2669,11 @@ have_pending_reload_ = false; pending_script_to_evaluate_on_load_.clear(); } else if (have_pending_reload_) { + if (navigation_request->WasServerRedirect() && + !initiating_origin_.IsSameOriginWith( + navigation_request->GetOriginToCommit().value_or(url::Origin()))) { + pending_script_to_evaluate_on_load_.clear(); + } prepare_for_reload_callback_.Run( std::move(pending_script_to_evaluate_on_load_)); have_pending_reload_ = false; diff --git a/content/browser/devtools/protocol/page_handler.h b/content/browser/devtools/protocol/page_handler.h index 0ceca6f..b00b73d1 100644 --- a/content/browser/devtools/protocol/page_handler.h +++ b/content/browser/devtools/protocol/page_handler.h @@ -32,6 +32,7 @@ #include "content/public/common/javascript_dialog_type.h" #include "third_party/blink/public/mojom/manifest/manifest.mojom-forward.h" #include "url/gurl.h" +#include "url/origin.h" class SkBitmap; @@ -308,6 +309,7 @@ base::RepeatingCallback<void(std::string)> prepare_for_reload_callback_; bool have_pending_reload_ = false; std::string pending_script_to_evaluate_on_load_; + url::Origin initiating_origin_; Response AddScriptToEvaluateOnNewDocumentInternal( const std::string& source, diff --git a/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script-expected.txt b/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script-expected.txt new file mode 100644 index 0000000..2f7a70d1 --- /dev/null +++ b/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script-expected.txt @@ -0,0 +1,11 @@ +Tests that Page.reload with scriptToEvaluateOnLoad drops the script on cross-origin redirects. + +--- Direct reload (no redirect) --- +window.__injected: direct + +--- Same-origin redirect --- +window.__injected: same-origin + +--- Cross-origin redirect --- +window.__injected: undefined + diff --git a/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script.js b/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script.js new file mode 100644 index 0000000..8977a98 --- /dev/null +++ b/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script.js @@ -0,0 +1,82 @@ +(async function(/** @type {import('test_runner').TestRunner} */ testRunner) { + const {session, dp} = await testRunner.startBlank( + `Tests that Page.reload with scriptToEvaluateOnLoad drops the script on cross-origin redirects.`); + + const FetchHelper = + await testRunner.loadScript('../fetch/resources/fetch-test.js'); + const helper = new FetchHelper(testRunner, testRunner.browserP()); + helper.setEnableLogging(false); + await helper.enable(); + + await dp.Page.enable(); + + // 1. Direct reload (no redirect) + testRunner.log('\n--- Direct reload (no redirect) ---'); + helper.onceRequest(/a.test\/direct/).fulfill({ + responseCode: 200, + body: btoa('<html><head></head><body>Direct</body></html>'), + }); + await dp.Page.navigate({url: 'http://a.test/direct'}); + await dp.Page.onceLoadEventFired(); + + helper.onceRequest(/a.test\/direct/).fulfill({ + responseCode: 200, + body: btoa('<html><head></head><body>Direct Reloaded</body></html>'), + }); + dp.Page.reload({scriptToEvaluateOnLoad: 'window.__injected = "direct";'}); + await dp.Page.onceLoadEventFired(); + const directInjected = await session.evaluate('window.__injected'); + testRunner.log(`window.__injected: ${directInjected}`); + + // 2. Same-origin redirect + testRunner.log('\n--- Same-origin redirect ---'); + helper.onceRequest(/a.test\/same-origin-start/).fulfill({ + responseCode: 200, + body: btoa('<html><head></head><body>Same Origin Start</body></html>'), + }); + await dp.Page.navigate({url: 'http://a.test/same-origin-start'}); + await dp.Page.onceLoadEventFired(); + + helper.onceRequest(/a.test\/same-origin-start/).fulfill({ + responseCode: 302, + responseHeaders: [ + {name: 'Location', value: 'http://a.test/same-origin-dest'}, + ], + }); + helper.onceRequest(/a.test\/same-origin-dest/).fulfill({ + responseCode: 200, + body: btoa('<html><head></head><body>Same Origin Dest</body></html>'), + }); + dp.Page.reload( + {scriptToEvaluateOnLoad: 'window.__injected = "same-origin";'}); + await dp.Page.onceLoadEventFired(); + const sameOriginInjected = await session.evaluate('window.__injected'); + testRunner.log(`window.__injected: ${sameOriginInjected}`); + + // 3. Cross-origin redirect + testRunner.log('\n--- Cross-origin redirect ---'); + helper.onceRequest(/a.test\/cross-origin-start/).fulfill({ + responseCode: 200, + body: btoa('<html><head></head><body>Cross Origin Start</body></html>'), + }); + await dp.Page.navigate({url: 'http://a.test/cross-origin-start'}); + await dp.Page.onceLoadEventFired(); + + helper.onceRequest(/a.test\/cross-origin-start/).fulfill({ + responseCode: 302, + responseHeaders: [ + {name: 'Location', value: 'http://b.test/cross-origin-dest'}, + ], + }); + helper.onceRequest(/b.test\/cross-origin-dest/).fulfill({ + responseCode: 200, + body: btoa('<html><head></head><body>Cross Origin Dest</body></html>'), + }); + dp.Page.reload( + {scriptToEvaluateOnLoad: 'window.__injected = "cross-origin";'}); + await dp.Page.onceLoadEventFired(); + const crossOriginInjected = await session.evaluate('window.__injected'); + testRunner.log(`window.__injected: ${crossOriginInjected}`); + + testRunner.completeTest(); +})
Regression Test / PoC
diff --git a/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script-expected.txt b/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script-expected.txt
new file mode 100644
index 0000000..2f7a70d1
--- /dev/null
+++ b/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script-expected.txt
@@ -0,0 +1,11 @@
+Tests that Page.reload with scriptToEvaluateOnLoad drops the script on cross-origin redirects.
+
+--- Direct reload (no redirect) ---
+window.__injected: direct
+
+--- Same-origin redirect ---
+window.__injected: same-origin
+
+--- Cross-origin redirect ---
+window.__injected: undefined
+
diff --git a/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script.js b/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script.js
new file mode 100644
index 0000000..8977a98
--- /dev/null
+++ b/third_party/blink/web_tests/http/tests/inspector-protocol/page/reload-redirect-script.js
@@ -0,0 +1,82 @@
+(async function(/** @type {import('test_runner').TestRunner} */ testRunner) {
+ const {session, dp} = await testRunner.startBlank(
+ `Tests that Page.reload with scriptToEvaluateOnLoad drops the script on cross-origin redirects.`);
+
+ const FetchHelper =
+ await testRunner.loadScript('../fetch/resources/fetch-test.js');
+ const helper = new FetchHelper(testRunner, testRunner.browserP());
+ helper.setEnableLogging(false);
+ await helper.enable();
+
+ await dp.Page.enable();
+
+ // 1. Direct reload (no redirect)
+ testRunner.log('\n--- Direct reload (no redirect) ---');
+ helper.onceRequest(/a.test\/direct/).fulfill({
+ responseCode: 200,
+ body: btoa('<html><head></head><body>Direct</body></html>'),
+ });
+ await dp.Page.navigate({url: 'http://a.test/direct'});
+ await dp.Page.onceLoadEventFired();
+
+ helper.onceRequest(/a.test\/direct/).fulfill({
+ responseCode: 200,
+ body: btoa('<html><head></head><body>Direct Reloaded</body></html>'),
+ });
+ dp.Page.reload({scriptToEvaluateOnLoad: 'window.__injected = "direct";'});
+ await dp.Page.onceLoadEventFired();
+ const directInjected = await session.evaluate('window.__injected');
+ testRunner.log(`window.__injected: ${directInjected}`);
+
+ // 2. Same-origin redirect
+ testRunner.log('\n--- Same-origin redirect ---');
+ helper.onceRequest(/a.test\/same-origin-start/).fulfill({
+ responseCode: 200,
+ body: btoa('<html><head></head><body>Same Origin Start</body></html>'),
+ });
+ await dp.Page.navigate({url: 'http://a.test/same-origin-start'});
+ await dp.Page.onceLoadEventFired();
+
+ helper.onceRequest(/a.test\/same-origin-start/).fulfill({
+ responseCode: 302,
+ responseHeaders: [
+ {name: 'Location', value: 'http://a.test/same-origin-dest'},
+ ],
+ });
+ helper.onceRequest(/a.test\/same-origin-dest/).fulfill({
+ responseCode: 200,
+ body: btoa('<html><head></head><body>Same Origin Dest</body></html>'),
+ });
+ dp.Page.reload(
+ {scriptToEvaluateOnLoad: 'window.__injected = "same-origin";'});
+ await dp.Page.onceLoadEventFired();
+ const sameOriginInjected = await session.evaluate('window.__injected');
+ testRunner.log(`window.__injected: ${sameOriginInjected}`);
+
+ // 3. Cross-origin redirect
+ testRunner.log('\n--- Cross-origin redirect ---');
+ helper.onceRequest(/a.test\/cross-origin-start/).fulfill({
+ responseCode: 200,
+ body: btoa('<html><head></head><body>Cross Origin Start</body></html>'),
+ });
+ await dp.Page.navigate({url: 'http://a.test/cross-origin-start'});
+ await dp.Page.onceLoadEventFired();
+
+ helper.onceRequest(/a.test\/cross-origin-start/).fulfill({
+ responseCode: 302,
+ responseHeaders: [
+ {name: 'Location', value: 'http://b.test/cross-origin-dest'},
+ ],
+ });
+ helper.onceRequest(/b.test\/cross-origin-dest/).fulfill({
+ responseCode: 200,
+ body: btoa('<html><head></head><body>Cross Origin Dest</body></html>'),
+ });
+ dp.Page.reload(
+ {scriptToEvaluateOnLoad: 'window.__injected = "cross-origin";'});
+ await dp.Page.onceLoadEventFired();
+ const crossOriginInjected = await session.evaluate('window.__injected');
+ testRunner.log(`window.__injected: ${crossOriginInjected}`);
+
+ testRunner.completeTest();
+})