CVE-2026-113054
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
FileSystemURLLoaderFactoryTestcontent/browser/file_system/file_system_url_loader_factory_browsertest.cc |
modified | |
FileSystemURLSubresourceBrowserTestcontent/browser/file_system/file_system_url_loader_factory_browsertest.cc |
modified |
Files Changed
content/browser/file_system/file_system_url_loader_factory_browsertest.cccontent/browser/renderer_host/render_frame_host_impl.cc
Patch
From 7a0ece0c748a4065084d64d991e25b447d5522ce Mon Sep 17 00:00:00 2001 From: Alex Moshchuk <[email protected]> Date: Wed, 16 Sep 2026 22:27:54 -0700 Subject: [PATCH] Don't install FileSystemURLLoaderFactory for PDF documents A PDF document commits a real origin, but its renderer process is deliberately denied access to that origin's stored data (ChildProcessSecurityPolicyImpl::IsAccessAllowedForPdfProcess() returns false for kCanAccessDataForCommittedOrigin). As part of that, a PDF document shouldn't be able to access filesystem: URLs. This CL is a followup to https://crrev.com/c/8411305, which made FileSystemEntryURLLoader::Start() check CanAccessDataForOrigin() instead of CanCommitURL(), so a PDF document's filesystem: request is rejected with net::ERR_INVALID_URL. It provides further hardening such that RenderFrameHostImpl::CommitNavigation() no longer installs a FileSystemURLLoaderFactory for PDF documents in the first place. Two reasons: - Defense in depth. The guarantee no longer rests solely on the loader-side CPSP check. A PDF document simply has no filesystem: factory to use. - Cleaner failure mode. Rather than reaching a browser-side loader that then rejects it, the request fails in the renderer as an unsupported scheme -- the same treatment content:// already receives a few lines above on Android (r1270300). This is not an observable behavior change: from script, the request fails either way. Accordingly, the added browser test (FileSystemURLSubresourceBrowserTest.SubresourceLoadBlockedInPdfDocument) passes with or without the CommitNavigation() change, and is added as end-to-end regression coverage for the overall guarantee, and as the only test that exercises the factory bundle documents actually receive at commit time, rather than a factory constructed by the test. Fixed: 549529981 Change-Id: I1366a3f733934ea9314cb3e35996cd7352abcb1f Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8391596 Reviewed-by: Rakina Zata Amni <[email protected]> Commit-Queue: Alex Moshchuk <[email protected]> Reviewed-by: Eriko Kurimoto <[email protected]> Cr-Commit-Position: refs/heads/main@{#1699529} --- diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc index 61feedc..6e495d4 100644 --- a/content/browser/renderer_host/render_frame_host_impl.cc +++ b/content/browser/renderer_host/render_frame_host_impl.cc @@ -13326,13 +13326,18 @@ } #endif - auto* partition = GetStoragePartition(); - non_network_factories.emplace( - url::kFileSystemScheme, - CreateFileSystemURLLoaderFactory( - GetProcess()->GetDeprecatedID(), GetFrameTreeNodeId(), - partition->GetFileSystemContext(), partition->GetPartitionDomain(), - commit_params->storage_key)); + // Only non-PDF documents can load filesystem: subresources. PDF + // documents may commit an origin, but their processes are not allowed to + // access stored data for it. + if (!navigation_request->GetUrlInfo().embedder_isolation_info.is_pdf()) { + auto* partition = GetStoragePartition(); + non_network_factories.emplace( + url::kFileSystemScheme, + CreateFileSystemURLLoaderFactory( + GetProcess()->GetID().GetUnsafeValue(), GetFrameTreeNodeId(), + partition->GetFileSystemContext(), + partition->GetPartitionDomain(), commit_params->storage_key)); + } non_network_factories.emplace(url::kDataScheme, DataURLLoaderFactory::Create()); diff --git a/content/browser/file_system/file_system_url_loader_factory_browsertest.cc b/content/browser/file_system/file_system_url_loader_factory_browsertest.cc index 91dcf2b..4477056 100644 --- a/content/browser/file_system/file_system_url_loader_factory_browsertest.cc +++ b/content/browser/file_system/file_system_url_loader_factory_browsertest.cc @@ -36,15 +36,18 @@ #include "components/file_access/scoped_file_access.h" #include "components/file_access/test/mock_scoped_file_access_delegate.h" #include "content/browser/process_lock.h" +#include "content/browser/renderer_host/render_frame_host_impl.h" #include "content/browser/security/cpsp/child_process_security_policy_impl.h" #include "content/browser/site_instance_impl.h" #include "content/browser/url_info.h" #include "content/browser/web_contents/web_contents_impl.h" #include "content/public/browser/browser_task_traits.h" #include "content/public/browser/browser_thread.h" +#include "content/public/browser/navigation_controller.h" #include "content/public/test/browser_test.h" #include "content/public/test/browser_test_utils.h" #include "content/public/test/content_browser_test.h" +#include "content/public/test/content_browser_test_utils.h" #include "content/public/test/test_utils.h" #include "content/shell/browser/shell.h" #include "mojo/public/cpp/bindings/remote.h" @@ -181,6 +184,20 @@ return line.find("<script>start(\"") == 0; } +// Commits `url` in `web_contents` as PDF content, so that the resulting +// document runs in a process whose SiteInfo has `is_pdf` set. +RenderFrameHostImpl* NavigateToURLAsPdf(WebContents* web_contents, + const GURL& url) { + NavigationController::LoadURLParams params(url); + params.transition_type = ui::PageTransitionFromInt( + ui::PAGE_TRANSITION_TYPED | ui::PAGE_TRANSITION_FROM_ADDRESS_BAR); + params.is_pdf = true; + NavigateToURLBlockUntilNavigationsComplete( + web_contents, params, 1, + /*ignore_uncommitted_navigations=*/false); + return static_cast<RenderFrameHostImpl*>(web_contents->GetPrimaryMainFrame()); +} + } // namespace class FileSystemURLLoaderFactoryTest @@ -1099,4 +1116,63 @@ kValidExternalMountPoint)); } +// Exercises filesystem: subresource loads through the loader factory that +// documents actually receive at commit time, rather than through a factory +// created directly by the test. +class FileSystemURLSubresourceBrowserTest : public ContentBrowserTest { + protected: + void SetUpOnMainThread() override { + ContentBrowserTest::SetUpOnMainThread(); + ASSERT_TRUE(embedded_test_server()->Start()); + } +}; + +// Verify that a document committed as PDF content cannot fetch filesystem: +// subresources for its origin, while an ordinary document at the same origin +// can. +IN_PROC_BROWSER_TEST_F(FileSystemURLSubresourceBrowserTest, + SubresourceLoadBlockedInPdfDocument) { + const GURL page_url = embedded_test_server()->GetURL("/title1.html"); + EXPECT_TRUE(NavigateToURL(shell(), page_url)); + + // Create a temporary file from an ordinary document. + static constexpr char kWriteScript[] = R"( + new Promise((resolve, reject) => { + webkitRequestFileSystem(TEMPORARY, 1024, fs => { + fs.root.getFile('subresource.txt', {create: true}, entry => { + entry.createWriter(writer => { + writer.onwriteend = () => resolve('written'); + writer.onerror = () => reject(writer.error); + writer.write(new Blob(['fs-data'])); + }, reject); + }, reject); + }, reject); + });)"; + ASSERT_EQ("written", EvalJs(shell(), kWriteScript)); + + static constexpr char kReadScript[] = R"( + new Promise(resolve => { + const request = new XMLHttpRequest(); + request.open('GET', 'filesystem:' + location.origin + + '/temporary/subresource.txt'); + request.onload = () => resolve(request.responseText); + request.onerror = () => resolve('load-error'); + request.send(); + });)"; + + // The ordinary document can read the file back through its filesystem: URL. + ASSERT_EQ("fs-data", EvalJs(shell(), kReadScript)); + + // Commit a same-origin document as PDF content, so that it runs in a + // process whose SiteInfo has `is_pdf` set. + const GURL pdf_url = embedded_test_server()->GetURL("/title2.html"); + RenderFrameHostImpl* frame = + NavigateToURLAsPdf(shell()->web_contents(), pdf_url); + ASSERT_EQ(pdf_url, shell()->web_contents()->GetLastCommittedURL()); + ASSERT_TRUE(frame->GetSiteInstance()->GetSiteInfo().is_pdf()); + + // The PDF document must not receive the file's contents. + EXPECT_EQ("load-error", EvalJs(frame, kReadScript)); +} + } // namespace content
Regression Test / PoC
diff --git a/content/browser/file_system/file_system_url_loader_factory_browsertest.cc b/content/browser/file_system/file_system_url_loader_factory_browsertest.cc
index 91dcf2b..4477056 100644
--- a/content/browser/file_system/file_system_url_loader_factory_browsertest.cc
+++ b/content/browser/file_system/file_system_url_loader_factory_browsertest.cc
@@ -36,15 +36,18 @@
#include "components/file_access/scoped_file_access.h"
#include "components/file_access/test/mock_scoped_file_access_delegate.h"
#include "content/browser/process_lock.h"
+#include "content/browser/renderer_host/render_frame_host_impl.h"
#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
#include "content/browser/site_instance_impl.h"
#include "content/browser/url_info.h"
#include "content/browser/web_contents/web_contents_impl.h"
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
+#include "content/public/browser/navigation_controller.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/content_browser_test.h"
+#include "content/public/test/content_browser_test_utils.h"
#include "content/public/test/test_utils.h"
#include "content/shell/browser/shell.h"
#include "mojo/public/cpp/bindings/remote.h"
@@ -181,6 +184,20 @@
return line.find("<script>start(\"") == 0;
}
+// Commits `url` in `web_contents` as PDF content, so that the resulting
+// document runs in a process whose SiteInfo has `is_pdf` set.
+RenderFrameHostImpl* NavigateToURLAsPdf(WebContents* web_contents,
+ const GURL& url) {
+ NavigationController::LoadURLParams params(url);
+ params.transition_type = ui::PageTransitionFromInt(
+ ui::PAGE_TRANSITION_TYPED | ui::PAGE_TRANSITION_FROM_ADDRESS_BAR);
+ params.is_pdf = true;
+ NavigateToURLBlockUntilNavigationsComplete(
+ web_contents, params, 1,
+ /*ignore_uncommitted_navigations=*/false);
+ return static_cast<RenderFrameHostImpl*>(web_contents->GetPrimaryMainFrame());
+}
+
} // namespace
class FileSystemURLLoaderFactoryTest
@@ -1099,4 +1116,63 @@
kValidExternalMountPoint));
}
+// Exercises filesystem: subresource loads through the loader factory that
+// documents actually receive at commit time, rather than through a factory
+// created directly by the test.
+class FileSystemURLSubresourceBrowserTest : public ContentBrowserTest {
+ protected:
+ void SetUpOnMainThread() override {
+ ContentBrowserTest::SetUpOnMainThread();
+ ASSERT_TRUE(embedded_test_server()->Start());
+ }
+};
+
+// Verify that a document committed as PDF content cannot fetch filesystem:
+// subresources for its origin, while an ordinary document at the same origin
+// can.
+IN_PROC_BROWSER_TEST_F(FileSystemURLSubresourceBrowserTest,
+ SubresourceLoadBlockedInPdfDocument) {
+ const GURL page_url = embedded_test_server()->GetURL("/title1.html");
+ EXPECT_TRUE(NavigateToURL(shell(), page_url));
+
+ // Create a temporary file from an ordinary document.
+ static constexpr char kWriteScript[] = R"(
+ new Promise((resolve, reject) => {
+ webkitRequestFileSystem(TEMPORARY, 1024, fs => {
+ fs.root.getFile('subresource.txt', {create: true}, entry => {
+ entry.createWriter(writer => {
+ writer.onwriteend = () => resolve('written');
+ writer.onerror = () => reject(writer.error);
+ writer.write(new Blob(['fs-data']));
+ }, reject);
+ }, reject);
+ }, reject);
+ });)";
+ ASSERT_EQ("written", EvalJs(shell(), kWriteScript));
+
+ static constexpr char kReadScript[] = R"(
+ new Promise(resolve => {
+ const request = new XMLHttpRequest();
+ request.open('GET', 'filesystem:' + location.origin +
+ '/temporary/subresource.txt');
+ request.onload = () => resolve(request.responseText);
+ request.onerror = () => resolve('load-error');
+ request.send();
+ });)";
+
+ // The ordinary document can read the file back through its filesystem: URL.
+ ASSERT_EQ("fs-data", EvalJs(shell(), kReadScript));
+
+ // Commit a same-origin document as PDF content, so that it runs in a
+ // process whose SiteInfo has `is_pdf` set.
+ const GURL pdf_url = embedded_test_server()->GetURL("/title2.html");
+ RenderFrameHostImpl* frame =
+ NavigateToURLAsPdf(shell()->web_contents(), pdf_url);
+ ASSERT_EQ(pdf_url, shell()->web_contents()->GetLastCommittedURL());
+ ASSERT_TRUE(frame->GetSiteInstance()->GetSiteInfo().is_pdf());
+
+ // The PDF document must not receive the file's contents.
+ EXPECT_EQ("load-error", EvalJs(frame, kReadScript));
+}
+
} // namespace content
Original Bug Report
Restricted PDF renderer can read matching-origin FileSystem data through the `filesystem:` URL loader
Report description
Restricted PDF renderer can read matching-origin FileSystem data through the filesystem: URL loader
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
The problem
Please describe the technical details of the vulnerability
Overview
A renderer process locked as a PDF process is intentionally permitted to commit
the PDF document’s origin, but it is denied access to passwords, storage, and
other data for that origin. The document subresource setup nevertheless gives
PDF documents a filesystem: URL loader. That loader authorizes requests with
CanCommitURL() instead of the stricter CanAccessDataForOrigin() decision.
With code execution in a restricted PDF renderer, an attacker can use this loader to read pre-existing legacy sandboxed FileSystem bytes belonging to the browser-supplied StorageKey. A process- and URL-guarded renderer patch reproduced the read twice from fresh profiles without DevTools, CDP, a browser harness, or unsafe/experimental flags.
Affected files
content/browser/renderer_host/render_frame_host_impl.ccRenderFrameHostImpl::CommitNavigation()installs aFileSystemURLLoaderFactoryfor document subresources without applying the PDF data-access decision.
content/browser/file_system/file_system_url_loader_factory.ccFileSystemEntryURLLoader::Start()checks onlyCanCommitURL()before cracking and serving the requestedfilesystem:URL.
content/browser/security/cpsp/child_process_security_policy_impl.ccIsAccessAllowedForPdfProcess()permits commits but rejectskCanAccessDataForCommittedOrigin, defining the boundary bypassed here.
Tested versions
- Live reproduction: Chromium
154.0.8012.0, revision34a482b3cd98d6d599f43091babae366fb9c3d8e, macOS arm64. - Build configuration:
is_debug=false,is_component_build=true,symbol_level=0; no security-relevant feature flags. - Reliability: 2 successful runs from 2 fresh disposable profiles.
- Current published Mac Stable at validation time: Chrome
152.0.7977.54, Chromium revision24072c1aa400ec4a89dc738b6b6acd12a8589b6f. The affected factory installation andCanCommitURL()check are present in that exact published source revision. The attached patch also applies there, but the recorded live runs used the revision above.
The only source modification used for the live reproduction is the attached
renderer-only patch that models the stated compromised-PDF-renderer
precondition. It is guarded by Chromium’s own --pdf-renderer process switch
and by the exact loopback PDF URL. The browser-process implementation under
test is unchanged.
Vulnerability details
During navigation commit, Chromium installs the factory with the authoritative process ID and browser-computed StorageKey:
non_network_factories.emplace(
url::kFileSystemScheme,
CreateFileSystemURLLoaderFactory(
GetProcess()->GetDeprecatedID(), GetFrameTreeNodeId(),
partition->GetFileSystemContext(), partition->GetPartitionDomain(),
commit_params->storage_key));
The loader later validates the renderer using the weaker commit predicate:
if (params_.render_process_host_id != ChildProcessHost::kInvalidUniqueID &&
(!RenderProcessHost::FromID(params_.render_process_host_id) ||
!ChildProcessSecurityPolicyImpl::GetInstance()->CanCommitURL(
params_.render_process_host_id, request.url))) {
net_error = net::ERR_INVALID_URL;
}
That predicate deliberately succeeds for a PDF process hosting a normal URL. The actual PDF storage policy is different:
case AccessType::kCanCommitNewOrigin:
case AccessType::kHostsOrigin:
return true;
case AccessType::kCanAccessDataForCommittedOrigin:
return false;
After the weak check, StartOnIOThread() calls:
url_ = params_.file_system_context->CrackURL(request.url,
params_.storage_key);
FileSystemURL requires the URL origin to match the StorageKey origin, so this
does not permit an arbitrary-origin key forgery. The issue is that the
restricted PDF process is allowed to use its matching browser-derived key even
though CanAccessDataForOrigin() rejects that process/origin pair.
The inconsistency is corroborated by Chromium change
42d042d4eacdf396f18e106a16b3f2045781ae37 (2026-07-30). That change replaced
the same CanCommitURL() predicate with CanAccessDataForOrigin() in
HasPermissionsForFileSystemFile() specifically because PDF processes may
commit an origin but must not access its data. It did not update the URL-loader
path.
Reproduction
-
Use a clean checkout at
34a482b3cd98d6d599f43091babae366fb9c3d8eand apply the renderer-only compromised-process simulation:git apply --check /path/to/poc.patch git apply /path/to/poc.patch -
Build Chrome normally. For example:
autoninja -C out/vrp-triage chrome -
Start the attached loopback server; it serves all HTML and JavaScript from
/poc.htmland writes the machine-readable result next to the script:python3 poc.py 8129 -
Launch the patched build in a fresh disposable profile, without debugging, experimental, or unsafe flags, and open
http://127.0.0.1:8129/poc.html. -
The final page displays
PASS. The attached result contains:{ "confirmed": true, "ordinary_frame_seeded_file": true, "pdf_renderer_read_exact_secret": true, "pdf_report": { "contentType": "text/html", "isTop": false, "localStorageIsNull": true, "ok": true, "rendererProcessGuard": "--pdf-renderer", "status": 200, "text": "PDF_FILESYSTEM_BOUNDARY_SECRET_8d71c2e4" } }
Actual result
The restricted PDF renderer received HTTP 200 and the exact synthetic bytes previously written by the ordinary Web frame. This reproduced in both fresh profile runs.
Expected result
Chromium should not install or service a filesystem: subresource loader for a
renderer process that fails CanAccessDataForOrigin() for the factory’s
browser-derived StorageKey origin. The PDF renderer’s request should fail
without returning file bytes.
Attachment hashes
| Attachment | SHA-256 |
|---|---|
poc.py |
5a05ed5d07fc89c03009bba623ef0253bf83a27ddcdb853a336098a340f89bb1 |
poc.patch |
b0c06f1d6dad9b9c0d4c5cbf59ebc48148f81011acfa9c1a0573a0dc12f3e402 |
test.pdf |
f56d93933ddf0d137e5711ce8c002bcda194e8874e1043d77a45aeba7edde79b |
verified_result.json |
cb905a0dc3e458b32a17111b7cb2be1fc99c1c5ed07a7b7751d12cbb6cad3457 |
Impact analysis
Security impact
A compromised restricted PDF renderer can read legacy Temporary FileSystem files belonging to an ordinary Web frame when the PDF and frame resolve to the same origin and browser-assigned StorageKey. Depending on the application, such files may contain offline documents, exports, or cached private data.
The proof demonstrates confidentiality loss only:
- an ordinary nested Web frame creates and verifies a synthetic Temporary FileSystem file;
- Chromium loads a PDF with the matching partition topology;
- the renderer patch runs only in the process carrying
--pdf-rendererand only for that exact PDF URL; - the PDF leaf reports
document.contentType === "text/html",window.localStorage === null, andself !== top, confirming the restricted document environment; and - an unchanged production
XMLHttpRequestreceives HTTP 200 and the exact pre-existing marker through the productionFileSystemURLLoaderFactory.
The cause
What version of Chrome have you found the security issue in?
152.0.7977.54 [stable]
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Site Isolation Bypass
How would you like to be publicly acknowledged for your report?
a45hif