Medium firefox Sandbox Escape 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionSandbox escape in the DOM: Networking component
ComponentDOM
Bug ClassSandbox Escape
Tracker2043845
Fix commitf8b0b712028c (firefox) +46/-3
CISA KEVNot listed
CreditedYaqoub Aldurayhim
Disclosed2026-07-21

Background

`TYPE_INTERNAL_FORCE_ALLOWED_DTD`
an internal nsIContentPolicy load type that unconditionally waives DTD-load security checks so trusted localization code can pull external DTDs.
`nsContentSecurityManager`
the DOM security gatekeeper whose DoCheckLoadURIChecks decides whether a channel’s principal is permitted to load a given URI.
`FetchParent`
the parent-process IPC endpoint that receives RecvFetchOp fetch requests originating in a content process.
`dom.fetch.allow_force_allowed_dtd`
a new RelaxedAtomicBool static pref, defaulting to false (and true only under MOZ_THUNDERBIRD), that gates every DTD-bypass path.

Root Cause Analysis

Firefox honored the TYPE_INTERNAL_FORCE_ALLOWED_DTD (and TYPE_INTERNAL_DTD) content-policy type as an unconditional escape hatch: in DoCheckLoadURIChecks, a load tagged TYPE_INTERNAL_FORCE_ALLOWED_DTD returned NS_OK “no matter what the triggering principal is,” and neither NS_NewChannelInternal nor FetchParent::RecvFetchOp re-validated that a content process was entitled to that type. The violated invariant is that a sandboxed content process must never be able to select a load type that suppresses principal/URI security checks in the parent. Because FetchParent trusted the contentPolicyType() field of an IPC FetchOpArgs sent by content, a compromised renderer could forge a fetch whose type disabled the checks and reach otherwise-blocked resources.

The fix guards all four choke points behind dom.fetch.allow_force_allowed_dtd, which is false in Firefox, so the bypass simply no longer exists outside Thunderbird. It also adds an explicit IPC_FAIL in RecvFetchOp rejecting content-originated TYPE_INTERNAL_FORCE_ALLOWED_DTD requests, and blocks channel creation in NS_NewChannelInternal with NS_ERROR_CONTENT_BLOCKED.

Key insight
The single mistake was treating an IPC-supplied content-policy type as a trusted, unconditional security-check bypass without confirming the caller was privileged; the fix pref-gates the bypass off by default and hard-fails any content process that tries to assert TYPE_INTERNAL_FORCE_ALLOWED_DTD.

Attack Path

  1. Compromise a content process The attacker first achieves code execution in a sandboxed renderer via an unrelated content-process bug.
  2. Forge a fetch op The renderer sends RecvFetchOp a FetchOpArgs whose request().contentPolicyType() is set to TYPE_INTERNAL_FORCE_ALLOWED_DTD.
  3. Bypass principal checks In the parent, DoCheckLoadURIChecks returns NS_OK for that type regardless of the (untrusted) triggering principal, and NS_NewChannelInternal builds the channel unchecked.
  4. Load restricted resources The channel loads a URI the content process’s real principal would normally be blocked from, exercising parent-side loading logic outside the sandbox’s constraints.

Impact Assessment

An attacker who already controls a content process can escalate by forging a privileged DTD load type to defeat parent-process URI/principal checks and fetch resources the sandbox should forbid, a sandbox-escape-class boundary violation of moderate severity. This runs in the privileged parent process on behalf of the request, and the precondition is prior code execution in a content process. In shipping Firefox the pref is false, so the affected paths are now disabled entirely.

Files Changed

  • dom/base/Document.cpp
  • dom/base/Document.h
  • dom/fetch/FetchParent.cpp
  • dom/security/nsContentSecurityManager.cpp
  • modules/libpref/init/StaticPrefList.yaml
  • netwerk/base/nsNetUtil.cpp

Audit Directions

  • IPC-supplied policy/type fields
    Audit every parent-side IPC handler that reads a content-policy type, principal, or load flag from content and treat such fields as attacker-controlled until re-validated.
  • Unconditional security-check bypasses
    Search for content-policy branches that return NS_OK or set skip-check flags “no matter what the principal is” and confirm each is reachable only by trusted callers.
  • Pref-gated security fixes
    Verify that a bypass gated by a new pref (like dom.fetch.allow_force_allowed_dtd) is enforced at every choke point (security manager, channel creation, and IPC entry), not just one.
diff --git a/dom/base/Document.cpp b/dom/base/Document.cpp
index 85d3493fff4..9c7acbe17a0 100644
--- a/dom/base/Document.cpp
+++ b/dom/base/Document.cpp
@@ -1616,6 +1616,13 @@ void Document::ReloadWithHttpsOnlyException() {
   }
 }
 
+void Document::ForceSkipDTDSecurityChecks() {
+  MOZ_ASSERT(StaticPrefs::dom_fetch_allow_force_allowed_dtd(),
+             "Loading DTDs that skip security checks requires "
+             "dom.fetch.allow_force_allowed_dtd to be enabled");
+  mSkipDTDSecurityChecks = true;
+}
+
 // Given an nsresult that is assumed to be synthesized by PSM and describes a
 // certificate or TLS error, attempts to convert it into a string
 // representation of the underlying NSS error.
diff --git a/dom/base/Document.h b/dom/base/Document.h
index 42c105bb323..63c60ffe411 100644
--- a/dom/base/Document.h
+++ b/dom/base/Document.h
@@ -3066,7 +3066,7 @@ class Document : public nsINode,
 
   void ForceEnableXULXBL() { mAllowXULXBL = eTriTrue; }
 
-  void ForceSkipDTDSecurityChecks() { mSkipDTDSecurityChecks = true; }
+  void ForceSkipDTDSecurityChecks();
 
   /**
    * Returns the template content owner document that owns the content of
diff --git a/dom/fetch/FetchParent.cpp b/dom/fetch/FetchParent.cpp
index 512b1305ef4..8c046ee2964 100644
--- a/dom/fetch/FetchParent.cpp
+++ b/dom/fetch/FetchParent.cpp
@@ -15,6 +15,7 @@
 #include "mozilla/dom/ProcessIsolation.h"
 #include "mozilla/dom/ServiceWorkerDescriptor.h"
 #include "mozilla/ipc/BackgroundParent.h"
+#include "nsIContentPolicy.h"
 #include "nsThreadUtils.h"
 
 using namespace mozilla::ipc;
@@ -123,6 +124,14 @@ IPCResult FetchParent::RecvFetchOp(FetchOpArgs&& aArgs) {
     }
   }
 
+  if (contentHandle &&
+      aArgs.request().contentPolicyType() ==
+          nsIContentPolicy::TYPE_INTERNAL_FORCE_ALLOWED_DTD &&
+      !StaticPrefs::dom_fetch_allow_force_allowed_dtd()) {
+    return IPC_FAIL(this,
+                    "RecvFetchOp FORCE_ALLOWED_DTD not allowed from content");
+  }
+
   mRequest = MakeSafeRefPtr<InternalRequest>(std::move(aArgs.request()));
   mIsWorkerFetch = aArgs.isWorkerRequest();
   mPrincipalInfo = std::move(aArgs.principalInfo());
diff --git a/dom/security/nsContentSecurityManager.cpp b/dom/security/nsContentSecurityManager.cpp
index 4278fcf8761..d7af515730f 100644
--- a/dom/security/nsContentSecurityManager.cpp
+++ b/dom/security/nsContentSecurityManager.cpp
@@ -281,7 +281,8 @@ static nsresult DoCheckLoadURIChecks(nsIURI* aURI, nsILoadInfo* aLoadInfo) {
   // In practice, these DTDs are just used for localization, so applying the
   // same principal check as Fluent.
   if (aLoadInfo->InternalContentPolicyType() ==
-      nsIContentPolicy::TYPE_INTERNAL_DTD) {
+          nsIContentPolicy::TYPE_INTERNAL_DTD &&
+      mozilla::StaticPrefs::dom_fetch_allow_force_allowed_dtd()) {
     RefPtr<Document> doc;
     aLoadInfo->GetLoadingDocument(getter_AddRefs(doc));
     bool allowed = false;
@@ -295,7 +296,8 @@ static nsresult DoCheckLoadURIChecks(nsIURI* aURI, nsILoadInfo* aLoadInfo) {
   // that need to access localization DTDs. We just allow through
   // TYPE_INTERNAL_FORCE_ALLOWED_DTD no matter what the triggering principal is.
   if (aLoadInfo->InternalContentPolicyType() ==
-      nsIContentPolicy::TYPE_INTERNAL_FORCE_ALLOWED_DTD) {
+          nsIContentPolicy::TYPE_INTERNAL_FORCE_ALLOWED_DTD &&
+      mozilla::StaticPrefs::dom_fetch_allow_force_allowed_dtd()) {
     return NS_OK;
   }
 
diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml
index 7370b663313..e2fe1e8deb6 100644
--- a/modules/libpref/init/StaticPrefList.yaml
+++ b/modules/libpref/init/StaticPrefList.yaml
@@ -3147,6 +3147,18 @@
   value: true
   mirror: always
 
+# Whether to allow the TYPE_INTERNAL_FORCE_ALLOWED_DTD content policy type on
+# fetch requests received from a content process. Only consumers such as
+# Thunderbird that still rely on DTD loads should enable this.
+- name: dom.fetch.allow_force_allowed_dtd
+  type: RelaxedAtomicBool
+#if defined(MOZ_THUNDERBIRD)
+  value: true
+#else
+  value: false
+#endif
+  mirror: always
+
 - name: dom.fetchObserver.enabled
   type: RelaxedAtomicBool
   value: false
diff --git a/netwerk/base/nsNetUtil.cpp b/netwerk/base/nsNetUtil.cpp
index 7636af5ef8a..9941a85331b 100644
--- a/netwerk/base/nsNetUtil.cpp
+++ b/netwerk/base/nsNetUtil.cpp
@@ -17,6 +17,7 @@
 #include "mozilla/MathAlgorithms.h"
 #include "mozilla/Monitor.h"
 #include "mozilla/StaticPrefs_browser.h"
+#include "mozilla/StaticPrefs_dom.h"
 #include "mozilla/StaticPrefs_extensions.h"
 #include "mozilla/StaticPrefs_network.h"
 #include "mozilla/StaticPrefs_privacy.h"
@@ -286,6 +287,13 @@ nsresult NS_NewChannelInternal(
   // loadinfo attached.
   NS_ENSURE_ARG_POINTER(outChannel);
 
+  if (aLoadInfo &&
+      aLoadInfo->InternalContentPolicyType() ==
+          nsIContentPolicy::TYPE_INTERNAL_FORCE_ALLOWED_DTD &&
+      !mozilla::StaticPrefs::dom_fetch_allow_force_allowed_dtd()) {
+    return NS_ERROR_CONTENT_BLOCKED;
+  }
+
   nsCOMPtr<nsIIOService> grip;
   nsresult rv = net_EnsureIOService(&aIoService, grip);
   NS_ENSURE_SUCCESS(rv, rv);
@@ -452,6 +460,11 @@ nsresult NS_NewChannelInternal(
     uint32_t aSandboxFlags /* = 0 */) {
   NS_ENSURE_ARG_POINTER(outChannel);
 
+  if (aContentPolicyType == nsIContentPolicy::TYPE_INTERNAL_FORCE_ALLOWED_DTD &&
+      !mozilla::StaticPrefs::dom_fetch_allow_force_allowed_dtd()) {
+    return NS_ERROR_CONTENT_BLOCKED;
+  }
+
   nsCOMPtr<nsIIOService> grip;
   nsresult rv = net_EnsureIOService(&aIoService, grip);
   NS_ENSURE_SUCCESS(rv, rv);
Loading diff…