CVE-2026-87481
Overview
Background
- `allow_universal_access_from_file_urls`
- A
WebPreference(enabled by default in Android WebView) that lets documents loaded fromfile:URLs access resources of any origin. - `RenderFrameHostImpl::ValidateURLAndOrigin`
- The browser-side check that decides whether a renderer-supplied URL and origin are legal to commit for a given frame.
- Same-document navigation
- A commit such as
history.pushStatethat changes the document’s URL without loading a new document, and which a renderer can drive by IPC. - `ChildProcessSecurityPolicyImpl`
- The browser-side authority that tracks which origins and URLs each renderer process is permitted to commit, granting per-process exemptions like
HasOriginCheckExemptionForWebView.
Root Cause Analysis
The vulnerable path was RenderFrameHostImpl::ValidateURLAndOrigin, which granted the allow_universal_access_from_file_urls exemption whenever the renderer-supplied origin.scheme() was url::kFileScheme, without checking whether the browser’s own last_committed_origin_ was actually a file: origin. This violated the invariant that a document may only claim a file: origin if it was genuinely loaded from a file: URL, letting a compromised non-file: renderer send a same-document commit asserting a file: origin that passed validation. Compounding this, DidCommitNavigationInternal granted process-wide origin-check exemptions in ChildProcessSecurityPolicyImpl before calling ValidateDidCommitParams, so a renderer could obtain those exemptions from parameters that were never fully validated.
The fix adds the last_committed_origin_.scheme() == url::kFileScheme conjunct so the exemption only applies to documents that were already file: documents, and moves the ValidateDidCommitParams call earlier so commit parameters are fully validated before any exemption is granted. Because legitimate file: documents retain their original file: origin in last_committed_origin_ across same-document navigations, repeated pushState calls still pass the tightened check.
file: universal-access exemption from the renderer-claimed origin alone rather than the browser’s already-committed origin; the fix conditions the exemption on last_committed_origin_ genuinely being a file: origin and validates commit parameters before any exemption is granted.Attack Path
- Compromise a non-file renderer
An attacker gains code execution in a renderer process hosting a non-
file:document (e.g.,foo.com) in a WebView whereallow_universal_access_from_file_urlsis enabled. - Forge a same-document commit
The compromised renderer sends a same-document commit (e.g., a
pushState) whoseparams->originis replaced with afile:origin while keeping the original URL. - Pass the weak validation
ValidateURLAndOriginseesorigin.scheme() == url::kFileSchemeand returnstrueunder the universal-access exemption without checkinglast_committed_origin_. - Prematurely obtain exemptions
Because exemptions were granted before
ValidateDidCommitParams, the process picks up afile:origin-check exemption inChildProcessSecurityPolicyImpl. - Spoof file origin
The non-
file:renderer now holds afile:origin and its associated cross-origin access privileges it was never entitled to.
Impact Assessment
file: renderer process in an affected WebView gains the ability to spoof a file: origin and acquire the broad cross-origin access that allow_universal_access_from_file_urls normally reserves for genuine file: documents, effecting an incorrect-authorization / origin-confusion escalation. This occurs in the renderer/browser boundary of Android WebView embedders where allow_universal_access_from_file_urls is enabled. Preconditions are that the setting is enabled and the attacker already controls a renderer capable of sending a crafted same-document commit IPC.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/render_frame_host_impl.cc |
modified |
Files Changed
content/browser/renderer_host/render_frame_host_impl.cccontent/browser/security_exploit_browsertest.cc
Audit Directions
- Renderer-claimed vs. browser-tracked originFlag any authorization decision that keys off a renderer-supplied
origin/URL without cross-checking the browser’slast_committed_origin_or other trusted browser-side state. - Ordering of validation and privilege grantsVerify that every
ValidateDidCommitParams-style security check runs before any call that grants process-wide exemptions inChildProcessSecurityPolicyImpl, so privileges are never derived from unvalidated commit parameters. - WebPreference-gated exemptionsReview other
WebPreference-gated bypasses (e.g.,allow_universal_access_from_file_urls,LoadDataWithBaseURLpaths) to confirm each is anchored to a verified document provenance rather than a single scheme comparison.
Patch
From c5544d8f4463bad62746cd29acf7f0807c586b7e Mon Sep 17 00:00:00 2001
From: Paulius Trucinskas <ptrucinskas@google.com>
Date: Tue, 04 Aug 2026 09:59:17 -0700
Subject: [PATCH] Fix File Scheme Origin Spoofing
When allow_universal_access_from_file_urls WebPreference is enabled
(e.g., in Android WebView), ValidateURLAndOrigin() previously permitted
any renderer to commit a file: origin without verifying whether the
browser-side document was originally loaded from a file: URL. A
compromised non-file renderer process could perform a same-document
commit claiming a file: origin, passing validation and prematurely
obtaining process origin check exemptions in
ChildProcessSecurityPolicy.
This CL fixes the issue by:
1. Requiring that last_committed_origin_ already has a file: scheme in
RenderFrameHostImpl::ValidateURLAndOrigin() before allowing the
allow_universal_access_from_file_urls exemption.
2. Moving ValidateDidCommitParams() in DidCommitNavigationInternal() so
that commit parameters are fully validated before any process-wide
origin exemptions are granted.
3. Adding regression tests in security_exploit_browsertest.cc verifying
that non-file documents cannot commit file: origins, and that
legitimate file: documents can safely perform multiple same-document
pushState calls without losing their committed file: origin status.
Bug: 498730641
Change-Id: I855cb331547776959423feb3eb409e0734f3e160
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8158742
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Reviewed-by: Ashley Newson <ashleynewson@chromium.org>
Commit-Queue: Paulius Tručinskas <ptrucinskas@google.com>
Cr-Commit-Position: refs/heads/main@{#1673438}
---
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 9fde6e2..7921961 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -15908,7 +15908,16 @@
// the setting is disabled (e.g., due to document.open), they are allowed a
// narrower exemption in ChildProcessSecurityPolicyImpl::CanCommitOriginAndUrl
// due to compatibility requirements for existing apps.
- if (origin.scheme() == url::kFileScheme) {
+ //
+ // This exemption is conditioned on the browser-side last committed origin
+ // already being a file origin, so that documents which were not loaded from
+ // file URLs cannot use it to commit unexpected origins.
+ //
+ // In case of multiple same-document navigations, the last_committed_origin_
+ // is kept as the original file: origin, so each same-document navigation can
+ // still pass this check.
+ if (origin.scheme() == url::kFileScheme &&
+ last_committed_origin_.scheme() == url::kFileScheme) {
auto prefs = GetOrCreateWebPreferences();
if (prefs.allow_universal_access_from_file_urls) {
return true;
@@ -16240,6 +16249,15 @@
return false;
}
+ if (!ValidateDidCommitParams(navigation_request.get(), params.get(),
+ is_same_document_navigation)) {
+ if (navigation_request) {
+ navigation_request->set_navigation_discard_reason(
+ NavigationDiscardReason::kFailedSecurityCheck);
+ }
+ return false;
+ }
+
// Any opaque origin loaded with LoadDataWithBaseURL can bypass some of the
// URL and origin validation checks in unlocked processes, including both the
// original document and any about:blank frames that inherit the same origin.
@@ -16255,6 +16273,10 @@
// setting is later disabled and then a previously-exempted URL is inherited
// by a new same-origin document via document.open.
//
+ // These exemptions are granted only after `params->origin` has been
+ // validated above, so that they are not based on values that the browser
+ // would otherwise reject.
+ //
// TODO(crbug.com/40092527): Move these to UpdatePermissionsForNavigation
// once origin can be reliably computed by NavigationRequest at commit time.
if (navigation_request && navigation_request->IsLoadDataWithBaseURL() &&
@@ -16283,15 +16305,6 @@
base::debug::SetCrashKeyString(crash_key, "true");
}
- if (!ValidateDidCommitParams(navigation_request.get(), params.get(),
- is_same_document_navigation)) {
- if (navigation_request) {
- navigation_request->set_navigation_discard_reason(
- NavigationDiscardReason::kFailedSecurityCheck);
- }
- return false;
- }
-
// TODO(clamy): We should stop having a special case for same-document
// navigation and just put them in the general map of NavigationRequests.
if (navigation_request &&
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index fea640bf..c64bb87 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1103,6 +1103,89 @@
EXPECT_EQ(bad_message::RFH_INVALID_ORIGIN_ON_COMMIT, kill_waiter.Wait());
}
+// Test that same-document navigations cannot claim to commit a file: origin
+// when the last committed origin is not a file URL, even if
+// allow_universal_access_from_file_urls is enabled. The universal access
+// setting allows file: documents to navigate to other origins, but does not
+// allow non-file documents to become file: documents.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ CrossOriginSameDocumentCommitUniversalAccessFileOrigin) {
+ auto prefs = shell()->web_contents()->GetOrCreateWebPreferences();
+ prefs.allow_universal_access_from_file_urls = true;
+ shell()->web_contents()->SetWebPreferences(prefs);
+
+ GURL start_url(embedded_test_server()->GetURL("foo.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ RenderFrameHostImpl* rfh = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ url::Origin file_origin = url::Origin::Create(GURL("file:///"));
+ EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()
+ ->HasOriginCheckExemptionForWebView(
+ rfh->GetProcess()->GetDeprecatedID(), file_origin));
+
+ // Do a same-document navigation, using an interceptor that replaces the
+ // origin with a file: origin while keeping the original URL. The browser
+ // should reject this because the previously committed origin is not file:.
+ PwnCommitIPC(shell()->web_contents(), start_url, start_url, file_origin);
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(rfh->GetProcess());
+ // ExecJs will sometimes finish before the renderer gets killed, so we must
+ // ignore the result.
+ std::ignore = ExecJs(rfh, "history.pushState({}, '', location.href);");
+ EXPECT_EQ(bad_message::RFH_INVALID_ORIGIN_ON_COMMIT, kill_waiter.Wait());
+
+ // The process should not have been granted an origin check exemption based
+ // on the rejected commit's origin.
+ EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()
+ ->HasOriginCheckExemptionForWebView(
+ rfh->GetProcess()->GetDeprecatedID(), file_origin));
+}
+
+// Test that a file: URL document with allow_universal_access_from_file_urls
+// enabled can perform multiple same-document pushState navigations to different
+// cross-origin URLs without losing its file: committed origin or causing
+// browser-side validation failures on subsequent pushState calls.
+// NOTE: Universal access from file scheme behaves differently on macOS, so the
+// test is disabled on macOS (crbug.com/981018).
+#if BUILDFLAG(IS_MAC)
+#define MAYBE_MultipleCrossOriginSameDocumentPushStateFromFileUrl \
+ DISABLED_MultipleCrossOriginSameDocumentPushStateFromFileUrl
+#else
+#define MAYBE_MultipleCrossOriginSameDocumentPushStateFromFileUrl \
+ MultipleCrossOriginSameDocumentPushStateFromFileUrl
+#endif
+IN_PROC_BROWSER_TEST_F(
+ SecurityExploitBrowserTest,
+ MAYBE_MultipleCrossOriginSameDocumentPushStateFromFileUrl) {
+ auto prefs = shell()->web_contents()->GetOrCreateWebPreferences();
+ prefs.allow_universal_access_from_file_urls = true;
+ shell()->web_contents()->SetWebPreferences(prefs);
+
+ GURL file_url = GetTestUrl("", "simple_page.html");
+ ASSERT_TRUE(file_url.SchemeIsFile());
+ ASSERT_TRUE(NavigateToURL(shell(), file_url));
+
+ RenderFrameHostImpl* rfh = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ url::Origin initial_origin = rfh->GetLastCommittedOrigin();
+ EXPECT_EQ(url::kFileScheme, initial_origin.scheme());
+
+ // Perform first pushState to a cross-origin HTTPS URL.
+ EXPECT_TRUE(
+ ExecJs(rfh, "history.pushState({}, '', 'https://example.com/1');"));
+ EXPECT_TRUE(rfh->IsRenderFrameLive());
+ EXPECT_EQ(initial_origin, rfh->GetLastCommittedOrigin());
+
+ // Perform second pushState to a different cross-origin HTTPS URL.
+ // Verify that the browser still retains the file: origin on
+ // last_committed_origin_ and allows the second same-document navigation
+ // to succeed.
+ EXPECT_TRUE(
+ ExecJs(rfh, "history.pushState({}, '', 'https://example.org/2');"));
+ EXPECT_TRUE(rfh->IsRenderFrameLive());
+ EXPECT_EQ(initial_origin, rfh->GetLastCommittedOrigin());
+}
+
// Test that receiving a commit with a URL with an invalid scheme properly
// terminates the renderer process. See https://crbug.com/324934416.
// TODO(crbug.com/40092527): This test can be removed once the browser
Regression Test / PoC
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index fea640bf..c64bb87 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -1103,6 +1103,89 @@
EXPECT_EQ(bad_message::RFH_INVALID_ORIGIN_ON_COMMIT, kill_waiter.Wait());
}
+// Test that same-document navigations cannot claim to commit a file: origin
+// when the last committed origin is not a file URL, even if
+// allow_universal_access_from_file_urls is enabled. The universal access
+// setting allows file: documents to navigate to other origins, but does not
+// allow non-file documents to become file: documents.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ CrossOriginSameDocumentCommitUniversalAccessFileOrigin) {
+ auto prefs = shell()->web_contents()->GetOrCreateWebPreferences();
+ prefs.allow_universal_access_from_file_urls = true;
+ shell()->web_contents()->SetWebPreferences(prefs);
+
+ GURL start_url(embedded_test_server()->GetURL("foo.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ RenderFrameHostImpl* rfh = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ url::Origin file_origin = url::Origin::Create(GURL("file:///"));
+ EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()
+ ->HasOriginCheckExemptionForWebView(
+ rfh->GetProcess()->GetDeprecatedID(), file_origin));
+
+ // Do a same-document navigation, using an interceptor that replaces the
+ // origin with a file: origin while keeping the original URL. The browser
+ // should reject this because the previously committed origin is not file:.
+ PwnCommitIPC(shell()->web_contents(), start_url, start_url, file_origin);
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(rfh->GetProcess());
+ // ExecJs will sometimes finish before the renderer gets killed, so we must
+ // ignore the result.
+ std::ignore = ExecJs(rfh, "history.pushState({}, '', location.href);");
+ EXPECT_EQ(bad_message::RFH_INVALID_ORIGIN_ON_COMMIT, kill_waiter.Wait());
+
+ // The process should not have been granted an origin check exemption based
+ // on the rejected commit's origin.
+ EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()
+ ->HasOriginCheckExemptionForWebView(
+ rfh->GetProcess()->GetDeprecatedID(), file_origin));
+}
+
+// Test that a file: URL document with allow_universal_access_from_file_urls
+// enabled can perform multiple same-document pushState navigations to different
+// cross-origin URLs without losing its file: committed origin or causing
+// browser-side validation failures on subsequent pushState calls.
+// NOTE: Universal access from file scheme behaves differently on macOS, so the
+// test is disabled on macOS (crbug.com/981018).
+#if BUILDFLAG(IS_MAC)
+#define MAYBE_MultipleCrossOriginSameDocumentPushStateFromFileUrl \
+ DISABLED_MultipleCrossOriginSameDocumentPushStateFromFileUrl
+#else
+#define MAYBE_MultipleCrossOriginSameDocumentPushStateFromFileUrl \
+ MultipleCrossOriginSameDocumentPushStateFromFileUrl
+#endif
+IN_PROC_BROWSER_TEST_F(
+ SecurityExploitBrowserTest,
+ MAYBE_MultipleCrossOriginSameDocumentPushStateFromFileUrl) {
+ auto prefs = shell()->web_contents()->GetOrCreateWebPreferences();
+ prefs.allow_universal_access_from_file_urls = true;
+ shell()->web_contents()->SetWebPreferences(prefs);
+
+ GURL file_url = GetTestUrl("", "simple_page.html");
+ ASSERT_TRUE(file_url.SchemeIsFile());
+ ASSERT_TRUE(NavigateToURL(shell(), file_url));
+
+ RenderFrameHostImpl* rfh = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+ url::Origin initial_origin = rfh->GetLastCommittedOrigin();
+ EXPECT_EQ(url::kFileScheme, initial_origin.scheme());
+
+ // Perform first pushState to a cross-origin HTTPS URL.
+ EXPECT_TRUE(
+ ExecJs(rfh, "history.pushState({}, '', 'https://example.com/1');"));
+ EXPECT_TRUE(rfh->IsRenderFrameLive());
+ EXPECT_EQ(initial_origin, rfh->GetLastCommittedOrigin());
+
+ // Perform second pushState to a different cross-origin HTTPS URL.
+ // Verify that the browser still retains the file: origin on
+ // last_committed_origin_ and allows the second same-document navigation
+ // to succeed.
+ EXPECT_TRUE(
+ ExecJs(rfh, "history.pushState({}, '', 'https://example.org/2');"));
+ EXPECT_TRUE(rfh->IsRenderFrameLive());
+ EXPECT_EQ(initial_origin, rfh->GetLastCommittedOrigin());
+}
+
// Test that receiving a commit with a URL with an invalid scheme properly
// terminates the renderer process. See https://crbug.com/324934416.
// TODO(crbug.com/40092527): This test can be removed once the browser
Original Bug Report
Sandbox Escape: File Scheme Origin Spoofing via Same-Document Navigation in WebView
Project Fortify, 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 without the security team.
Overview: A logic error in RenderFrameHostImpl::DidCommitNavigationInternal allows a compromised renderer to spoof its origin during a same-document navigation. When allow_universal_access_from_file_urls is enabled (common in Android WebView), the browser trusts the renderer-provided file:// origin without validation and grants a process-wide security exemption. This allows the compromised renderer to bypass site isolation and read arbitrary local files.
Affected files:
content/browser/renderer_host/render_frame_host_impl.cc
Estimated timestamp from git blame: 2024-12-10
Summary
A potential critical vulnerability exists in RenderFrameHostImpl::DidCommitNavigationInternal where process-wide origin check exemptions are granted to a renderer process based on renderer-supplied parameters before those parameters are rigorously validated. In environments where the allow_universal_access_from_file_urls preference is enabled (frequently used in Android WebView), a compromised renderer can exploit this to gain permanent access to arbitrary local files (file:// origins), effectively escaping the renderer sandbox.
Vulnerability Details
The issue resides in how mojom::DidCommitProvisionalLoadParams are handled during a renderer-initiated same-document navigation.
-
Missing Origin Mismatch Check: When a compromised renderer sends a
DidCommitSameDocumentNavigationIPC, the browser attempts to find a matching pendingNavigationRequest. Since it’s a renderer-initiated same-document navigation, theNavigationRequestisnullptr. InRenderFrameHostImpl::DidCommitNavigationInternal, the check meant to detect origin spoofing is bypassed becausenavigation_requestis null:// content/browser/renderer_host/render_frame_host_impl.cc if (navigation_request && navigation_request->commit_params().navigation_token != params->navigation_token) { // ... crash logic ... } -
Premature Exemption Grant: Further down in
DidCommitNavigationInternal, the browser grants a process-wide origin exemption based entirely on the unvalidated, attacker-controlledparams->origin:if (GetOrCreateWebPreferences().allow_universal_access_from_file_urls && params->origin.scheme() == url::kFileScheme) { ChildProcessSecurityPolicyImpl::GetInstance() ->GrantOriginCheckExemptionForWebView(GetProcess()->GetDeprecatedID(), params->origin); } -
Validation Bypass: The function then calls
ValidateDidCommitParams, which delegates toValidateURLAndOrigin. This function contains an early return that explicitly trustsfile://schemes if universal access is enabled:if (origin.scheme() == url::kFileScheme) { auto prefs = GetOrCreateWebPreferences(); if (prefs.allow_universal_access_from_file_urls) { return true; } }This early return bypasses
CanCommitOriginAndUrl, skipping the strict consistency checks between the URL and the origin. -
State Update: Finally, because
features::IsEnforceSameDocumentOriginInvariantsEnabled()is disabled by default, the browser updates its canonical state (last_committed_origin_) to the attacker’s spoofedfile://origin.
Potential Exploitation Steps
(Note: These are suggested steps; our tooling agent cannot execute a live proof of concept.)
- An attacker gains arbitrary code execution within a sandboxed renderer process in an Android WebView application that has
allow_universal_access_from_file_urlsenabled. - The compromised renderer crafts a
mojom::FrameHost::DidCommitSameDocumentNavigationIPC. - In the payload, the attacker sets
params->originto a target file origin (e.g.,file:///data/data/com.target.app/databases/secret.db). - The browser receives the IPC. Because it’s a same-document navigation, the
NavigationRequestis null, bypassing the token mismatch check. - The browser executes the universal access logic, sees the
file://scheme, and grants the renderer a permanent process-wide exemption for that origin inChildProcessSecurityPolicyImpl. - The browser updates
last_committed_origin_to the spoofedfile://origin. - The renderer can now instantiate a
FileURLLoader(e.g., via afetch()call). Because it holds an exemption and the browser’s state matches the requested origin, security checks pass, allowing the attacker to read local files and escape the sandbox.
Proposed Fix
Do not grant the ChildProcessSecurityPolicyImpl origin check exemption based on unvalidated IPC parameters. The exemption logic in DidCommitNavigationInternal should be deferred until after ValidateDidCommitParams has run, or it should rely on browser-side state rather than renderer-provided claims. Alternatively, the early return in ValidateURLAndOrigin should be restructured to ensure that consistency checks (like params->url matching params->origin) are not completely bypassed even when universal access is enabled.
Evaluated with Chrome root at commit: e9e0fcbb690b1a8c1a26c81c2a9ea23d6e178368
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.