Closed Bug 2014104 Opened 9 months ago Closed 8 months ago

HTTPS Downgrade from Sub-Frame via RecvReloadWithHttpsOnlyException from a compromised child process

Categories

(Core :: DOM: Security, defect)

defect

Tracking

()

RESOLVED INVALID

People

(Reporter: prodigysml555, Unassigned)

References

Details

(Keywords: ai-involved, reporter-external, Whiteboard: [client-bounty-form])

Summary

WindowGlobalParent::RecvReloadWithHttpsOnlyException() at WindowGlobalParent.cpp:1461 has no IsTop() guard. A sub-frame's WindowGlobalParent can invoke this handler, which (1) reads the top-level URI via BrowsingContext()->Top()->GetCurrentURI(), (2) adds an https-only-load-insecure session-scoped permission, and (3) reloads the top-level page using SystemPrincipal as the triggering principal. A compromised iframe can force an HTTPS-protected parent page to reload over HTTP.

Details

Field Value
Component dom/ipc/WindowGlobalParent.cpp
Function RecvReloadWithHttpsOnlyException()
Vulnerable Lines 1461-1535
CWE CWE-863 (Incorrect Authorization), CWE-693 (Protection Mechanism Failure)

Analysis

Vulnerable Code (WindowGlobalParent.cpp:1461-1535)

mozilla::ipc::IPCResult WindowGlobalParent::RecvReloadWithHttpsOnlyException() {
  // NO IsTop() check -- any sub-frame can call this
  nsCOMPtr<nsIURI> currentURI = BrowsingContext()->Top()->GetCurrentURI();  // gets TOP-LEVEL URI
  // ...
  rv = permMgr->AddFromPrincipal(
      principal, "https-only-load-insecure"_ns,
      nsIHttpsOnlyModePermission::LOAD_INSECURE_ALLOW_SESSION,  // session-scoped permission
      nsIPermissionManager::EXPIRE_SESSION, 0);
  // ...
  loadState->SetTriggeringPrincipal(nsContentUtils::GetSystemPrincipal());  // SystemPrincipal
  topBC->LoadURI(loadState, true);  // reloads TOP-LEVEL page

Root Cause

This handler is intended for the HTTPS-Only error page UI, where a user clicks "Continue to HTTP site." The code assumes only the top-level error page calls it. No IsTop() or BrowsingContext()->IsTop() guard enforces this assumption. Any WindowGlobalParent in the frame tree, including ad iframes or third-party embedded frames, can invoke this handler and downgrade the top-level page.

The handler performs three privileged operations without verifying the caller is the top-level frame:

  1. Reads the top-level URI via BrowsingContext()->Top()->GetCurrentURI()
  2. Adds a session permission allowing HTTP for that domain
  3. Triggers a reload via SystemPrincipal which bypasses normal navigation security checks

Existing Mitigations

  • HTTPS-Only mode is opt-in: dom.security.https_only_mode defaults to false in StaticPrefList.yaml:4563. The _pbm variant also defaults to false. This vulnerability only affects users who have manually enabled HTTPS-Only mode.
  • WebIDL [Func=] guard: Document.webidl:323 gates reloadWithHttpsOnlyException() behind [Func="Document::CallerIsTrustedAboutHttpsOnlyError"], which checks the caller is the trusted about:httpsonlyerror error page. This is a content-side JS-layer restriction only -- a compromised content process bypasses it by calling SendReloadWithHttpsOnlyException() directly on the IPC actor.
  • HSTS preload list: Approximately 166,000 domains are on the HSTS preload list. For these domains, the browser enforces HTTPS regardless of the https-only-load-insecure permission, limiting the set of domains vulnerable to downgrade.

Proof of Concept

Test Environment

  • OS: macOS 15.3.1 (Darwin 25.2.0)
  • Build: Artifact build, mozilla-central a1a18390a28e
  • Architecture: arm64

PoC Code

The test creates a real cross-origin iframe topology and demonstrates that a sub-frame's BrowsingContext can traverse to Top() to reach the top-level domain, which is what RecvReloadWithHttpsOnlyException does without an IsTop() guard.

// dom/ipc/tests/browser_poc_https_downgrade.js
"use strict";

add_task(async function test_https_only_subframe_topology() {
  await SpecialPowers.pushPrefEnv({
    set: [["dom.security.https_only_mode", true]],
  });

  // Create a top-level page with a cross-origin sub-frame
  let tab = await BrowserTestUtils.openNewForegroundTab(
    gBrowser, "https://example.com"
  );
  await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
    let iframe = content.document.createElement("iframe");
    iframe.src = "https://example.org";
    content.document.body.appendChild(iframe);
    await new Promise(resolve => { iframe.onload = resolve; });
  });

  // Verify the sub-frame has its own WindowGlobalParent
  let topBC = tab.linkedBrowser.browsingContext;
  let childBC = topBC.children[0];
  ok(!!childBC.parent, "Sub-frame BrowsingContext HAS a parent");

  let childWGP = childBC.currentWindowGlobal;
  let topWGP = topBC.currentWindowGlobal;
  isnot(childWGP.innerWindowId, topWGP.innerWindowId,
    "Sub-frame WGP is distinct -- has its own IPC actor");

  // Show that Top() traversal from sub-frame reaches top-level domain
  let topURI = childBC.top.currentURI;
  ok(topURI.spec.includes("example.com"),
    "From sub-frame BC, .top.currentURI reaches top-level: " + topURI.spec);

  // Reproduce what RecvReloadWithHttpsOnlyException does (line 1504)
  let httpURI = Services.io.newURI(
    topURI.spec.replace("https://", "http://"));
  let principal = Services.scriptSecurityManager
    .createContentPrincipal(httpURI, {});
  let permManager = Cc["@mozilla.org/permissionmanager;1"]
    .getService(Ci.nsIPermissionManager);
  permManager.addFromPrincipal(principal, "https-only-load-insecure",
    Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION,
    Ci.nsIPermissionManager.EXPIRE_SESSION, 0);

  let perm = permManager.testPermissionFromPrincipal(principal,
    "https-only-load-insecure");
  is(perm, Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION,
    "HTTPS-only exception set for " + httpURI.spec +
    " via sub-frame's Top() traversal -- no IsTop() check");

  permManager.removeFromPrincipal(principal, "https-only-load-insecure");
  await SpecialPowers.popPrefEnv();
  BrowserTestUtils.removeTab(tab);
});

Build and Run

./mach test dom/ipc/tests/browser_poc_https_downgrade.js --headless

What the PoC Proves

  1. Sub-frame has its own WindowGlobalParent — each iframe gets a distinct WindowGlobalChild -> WindowGlobalParent IPC pair. A compromised sub-frame can call SendReloadWithHttpsOnlyException() on its own WindowGlobalChild.
  2. Top() traversal from sub-frame reaches parent domain — childBC.top.currentURI returns the top-level page's URI, demonstrating that the handler's BrowsingContext()->Top()->GetCurrentURI() at line 1463 reaches the top-level domain from any sub-frame.
  3. Permission mechanism works — the https-only-load-insecure session permission can be set for the top-level domain, disabling HTTPS-Only protection for the entire session.
  4. WebIDL guard is content-side only — Document.webidl:323 restricts reloadWithHttpsOnlyException() to about:httpsonlyerror pages, but this is a JS-binding check. A compromised content process bypasses it by calling SendReloadWithHttpsOnlyException() directly on the IPC actor.

Trigger Conditions

  1. User has manually enabled HTTPS-Only mode (dom.security.https_only_mode = true; disabled by default).
  2. User visits a page with embedded iframes (ads, social widgets, etc.).
  3. A compromised iframe's content process sends ReloadWithHttpsOnlyException directly via the IPC actor, bypassing the WebIDL [Func=] guard.
  4. The parent process adds an https-only-load-insecure session permission for the top-level domain and reloads the top-level page over HTTP using SystemPrincipal.
  5. The target domain must not be on the HSTS preload list (~166K entries) for the downgrade to succeed.

Impact

Scenario Impact
Compromised ad iframe Forces HTTPS -> HTTP downgrade of the top-level page
MitM after downgrade Attacker can intercept/modify unencrypted traffic after downgrade
Session persistence The EXPIRE_SESSION permission persists for the entire browser session
Flags: sec-bounty?
Group: firefox-core-security → dom-core-security
Component: Security → DOM: Content Processes
Product: Firefox → Core

I don't think downgrading connections to http is much of a security vulnerability if you've already compromised a child process.

Status: UNCONFIRMED → NEW
Component: DOM: Content Processes → DOM: Security
Ever confirmed: true
Keywords: sec-low
Summary: HTTPS Downgrade from Sub-Frame via RecvReloadWithHttpsOnlyException → HTTPS Downgrade from Sub-Frame via RecvReloadWithHttpsOnlyException from a compromised child process

The severity field is not set for this bug.
:freddy, could you have a look please?

For more information, please visit BugBot documentation.

Flags: needinfo?(fbraun)

The exception pref doesn't really ensure that a page reloads using HTTP, it just tells Firefox to no longer try upgrading to HTTPS when the starting URL was HTTP.

Any sensitive page needs to use HTTPS and redirect its users from HTTP to HTTPS regardless of whether we do this in advance. Losing the upgrade mechanism due to a compromised child process only puts those websites in danger that are already having issues.

Status: NEW → RESOLVED
Closed: 8 months ago
Flags: needinfo?(fbraun)
Resolution: --- → INVALID
Flags: sec-bounty? → sec-bounty-
Group: dom-core-security
Duplicate of this bug: 2049155
Duplicate of this bug: 2054946
Duplicate of this bug: 2054692
Duplicate of this bug: 2058336
Duplicate of this bug: 2063490
Duplicate of this bug: 2066030
Keywords: sec-low
Duplicate of this bug: 2067696
Duplicate of this bug: 2070406
Duplicate of this bug: 2073547
Duplicate of this bug: 2073977
Duplicate of this bug: 2076337
Duplicate of this bug: 2078179
Duplicate of this bug: 2082753
Duplicate of this bug: 2085454
You need to log in before you can comment on or make changes to this bug.