Low firefox Policy Bypass 🔧 Commit mapped

Overview

Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionInformation disclosure in the Networking: DNS component
ComponentNetworking
Bug ClassPolicy Bypass
Tracker2076744
Fix commit6665a0d4abd3 (firefox) +82/-70
CISA KEVNot listed
Creditedeldjiikgull
Disclosed2026-10-27

Background

RFC 7686 / `.onion`
The .onion special-use top-level domain names Tor hidden services. RFC 7686 requires non-Tor resolvers to refuse to look these names up. Firefox enforces this (when network.dns.blockDotOnion is set) so that a .onion name is never leaked to the system DNS resolver, which would reveal the user’s intent to reach a hidden service.
`StringEndsWith` and `nsCaseInsensitiveCStringComparator`
Gecko string helpers. StringEndsWith defaults to a case-sensitive comparison unless a case-insensitive comparator is supplied.
Fully-qualified name / trailing dot
DNS names may be written fully qualified with a trailing dot (e.g. private.onion.); the dot is semantically insignificant for name resolution.

Root Cause Analysis

The .onion block was applied with an under-normalized string comparison, letting trivially-equivalent spellings escape it. The old code was if (StaticPrefs::network_dns_blockDotOnion() && StringEndsWith(aInput, ".onion"_ns)) return NS_ERROR_UNKNOWN_HOST;. Because StringEndsWith here used the default case-sensitive comparison and the raw aInput was tested verbatim, any .onion host that differed only in case (PRIVATE.ONION, Private.Onion) or that carried the DNS trailing dot of its fully-qualified form (private.onion., and private.onion..) did not match ".onion" and therefore skipped the NS_ERROR_UNKNOWN_HOST early return. Such names fell through to the normal resolution path and were handed to the resolver, defeating the RFC 7686 protection and leaking the lookup. DNS hostnames are case-insensitive and the trailing dot is insignificant, so these were all the same hidden-service name that the block was supposed to stop.

The fix normalizes before comparing: it strips any trailing dots (while (length && aInput[length - 1] == '.') --length;) and compares the resulting Substring(aInput, 0, length) against ".onion" using nsCaseInsensitiveCStringComparator. The accompanying test_dns_onion.js rewrite explicitly exercises private.onion, private.onion., PRIVATE.ONION, Private.Onion., and private.onion.., asserting each is refused with NS_ERROR_UNKNOWN_HOST before any lookup. The Mozilla advisory calls this “Information disclosure in Networking: DNS”; the diff substantiates the mechanism precisely as an incomplete input-canonicalization check that bypassed the .onion blocklist, with the information disclosure (leaking the hidden-service lookup to DNS) being the consequence.

Key insight
The .onion lookup block compared the raw hostname case-sensitively and without stripping the DNS trailing dot, so case variants and fully-qualified forms of a .onion name slipped past the filter and were resolved — bypassing RFC 7686 enforcement.

Attack Path

  1. Craft an equivalent spelling An attacker (or a page/link the victim follows) references a hidden service using a non-canonical spelling such as Example.Onion or the fully-qualified example.onion. while network.dns.blockDotOnion is enabled.
  2. Hostname reaches `PreprocessHostname` The resolve request flows to nsDNSService::PreprocessHostname (inferred: standard DNS entry path for any name Firefox resolves).
  3. Block skipped StringEndsWith(aInput, ".onion") fails to match the differently-cased / dot-suffixed input, so the NS_ERROR_UNKNOWN_HOST short-circuit is not taken.
  4. Name sent to resolver The .onion name is forwarded to the configured system/network DNS resolver.
  5. Primitive The user’s attempt to reach a specific Tor hidden service is disclosed to the DNS resolver (and anyone on that path), defeating the RFC 7686 privacy guarantee.

Impact Assessment

The affected code is the DNS service in the networking process; no special privilege is required — ordinary web content or a link can supply the hostname. The gained capability is privacy information disclosure: a .onion name the browser was configured to never resolve is leaked to the DNS resolver and observers, revealing the user’s interest in a particular hidden service. This is not memory corruption and not remote code execution; the impact is confined to deanonymization/metadata leakage, consistent with the advisory’s low rating. Exploitation is deterministic given the pref is enabled and a non-canonical spelling is used.

Changed Functions

FunctionChangeNotes
while
netwerk/dns/nsDNSService2.cpp
modified
if
netwerk/dns/nsDNSService2.cpp
modified
onLookupComplete
netwerk/test/unit/test_dns_onion.js
modified
add_setup
netwerk/test/unit/test_dns_onion.js
modified
add_task
netwerk/test/unit/test_dns_onion.js
modified
for
netwerk/test/unit/test_dns_onion.js
modified

Files Changed

  • netwerk/dns/nsDNSService2.cpp
  • netwerk/test/unit/test_dns_onion.js

Audit Directions

  • Other hostname/suffix filters
    Review every security- or privacy-relevant StringEndsWith/EndsWith on hostnames (local-domain matching, HSTS/HPKP host checks, blocklists, localhost handling) for the same case-sensitivity and trailing-dot gaps.
  • Canonicalization before policy checks
    Look for places that test a raw, un-normalized URI/host string against a policy constant; ensure case folding and FQDN trailing-dot handling happen first.
  • Trailing-dot handling consistency
    Audit how localDomains, cookie domains, and origin comparisons treat trailing dots, since the test notes localDomains matches at most one trailing dot while the block now strips all of them.
  • Special-use TLD enforcement
    Check that other RFC 6761/7686 special names (e.g. .local, .test) are enforced with the same normalization.
diff --git a/netwerk/dns/nsDNSService2.cpp b/netwerk/dns/nsDNSService2.cpp
index 0aca8a5912d..b3d05f1bb29 100644
--- a/netwerk/dns/nsDNSService2.cpp
+++ b/netwerk/dns/nsDNSService2.cpp
@@ -999,10 +999,17 @@ already_AddRefed<nsHostResolver> nsDNSService::GetResolverLocked() {
 nsresult nsDNSService::PreprocessHostname(bool aLocalDomain,
                                           const nsACString& aInput,
                                           nsACString& aACE) {
-  // Enforce RFC 7686
-  if (StaticPrefs::network_dns_blockDotOnion() &&
-      StringEndsWith(aInput, ".onion"_ns)) {
-    return NS_ERROR_UNKNOWN_HOST;
+  // Enforce RFC 7686, whatever the case of the name, and with or without the
+  // trailing dot of its fully qualified form.
+  if (StaticPrefs::network_dns_blockDotOnion()) {
+    uint32_t length = aInput.Length();
+    while (length && aInput[length - 1] == '.') {
+      --length;
+    }
+    if (StringEndsWith(Substring(aInput, 0, length), ".onion"_ns,
+                       nsCaseInsensitiveCStringComparator)) {
+      return NS_ERROR_UNKNOWN_HOST;
+    }
   }
 
   if (aLocalDomain) {
diff --git a/netwerk/test/unit/test_dns_onion.js b/netwerk/test/unit/test_dns_onion.js
index e13974a39e3..56e89438bbf 100644
--- a/netwerk/test/unit/test_dns_onion.js
+++ b/netwerk/test/unit/test_dns_onion.js
@@ -1,76 +1,81 @@
 "use strict";
 
-var mainThread = Services.tm.currentThread;
+// With network.dns.blockDotOnion, .onion names (RFC 7686) are not resolved,
+// in any case and in their fully qualified form ("x.onion.") too. They are
+// all listed in network.dns.localDomains, so that a lookup that is not
+// blocked succeeds with a loopback address.
+const ONION_NAMES = [
+  "private.onion",
+  "private.onion.",
+  "PRIVATE.ONION",
+  "Private.Onion.",
+];
 
-var onionPref;
-var localdomainPref;
-var prefs = Services.prefs;
+// Blocked as well, but only checked when blocked: network.dns.localDomains
+// matches a name with at most one trailing dot.
+const BLOCKED_ONLY_NAMES = ["private.onion.."];
 
-// check that we don't lookup .onion
-var listenerBlock = {
-  onLookupComplete(inRequest, inRecord, inStatus) {
-    Assert.ok(!Components.isSuccessCode(inStatus));
-    do_test_dontBlock();
-  },
-  QueryInterface: ChromeUtils.generateQI(["nsIDNSListener"]),
-};
+// A blocked name is refused before any lookup: asyncResolve throws, whereas a
+// failed lookup is reported to the listener.
 
-// check that we do lookup .onion (via pref)
-var listenerDontBlock = {
-  onLookupComplete(inRequest, inRecord) {
-    inRecord.QueryInterface(Ci.nsIDNSAddrRecord);
-    var answer = inRecord.getNextAddrAsString();
-    Assert.ok(answer == "127.0.0.1" || answer == "::1");
-    all_done();
-  },
-  QueryInterface: ChromeUtils.generateQI(["nsIDNSListener"]),
-};
-
-const defaultOriginAttributes = {};
+function resolve(host) {
+  return new Promise(resolvePromise => {
+    try {
+      Services.dns.asyncResolve(
+        host,
+        Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
+        0,
+        null, // resolverInfo
+        {
+          onLookupComplete(inRequest, inRecord, inStatus) {
+            resolvePromise({
+              status: inStatus,
+              record: inRecord,
+              refused: false,
+            });
+          },
+          QueryInterface: ChromeUtils.generateQI(["nsIDNSListener"]),
+        },
+        Services.tm.currentThread,
+        {} // defaultOriginAttributes
+      );
+    } catch (e) {
+      resolvePromise({ status: e.result, record: null, refused: true });
+    }
+  });
+}
 
-function do_test_dontBlock() {
-  prefs.setBoolPref("network.dns.blockDotOnion", false);
-  Services.dns.asyncResolve(
-    "private.onion",
-    Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
-    0,
-    null, // resolverInfo
-    listenerDontBlock,
-    mainThread,
-    defaultOriginAttributes
+add_setup(function () {
+  Services.prefs.setCharPref(
+    "network.dns.localDomains",
+    "private.onion, PRIVATE.ONION, Private.Onion"
   );
-}
+  registerCleanupFunction(() => {
+    Services.prefs.clearUserPref("network.dns.localDomains");
+    Services.prefs.clearUserPref("network.dns.blockDotOnion");
+  });
+});
 
-function do_test_block() {
-  prefs.setBoolPref("network.dns.blockDotOnion", true);
-  try {
-    Services.dns.asyncResolve(
-      "private.onion",
-      Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
-      0,
-      null, // resolverInfo
-      listenerBlock,
-      mainThread,
-      defaultOriginAttributes
-    );
-  } catch (e) {
-    // it is ok for this negative test to fail fast
-    Assert.ok(true);
-    do_test_dontBlock();
+add_task(async function test_block() {
+  Services.prefs.setBoolPref("network.dns.blockDotOnion", true);
+  for (const host of [...ONION_NAMES, ...BLOCKED_ONLY_NAMES]) {
+    const { status, refused } = await resolve(host);
+    Assert.ok(refused, `${host} is refused before any lookup`);
+    Assert.equal(status, Cr.NS_ERROR_UNKNOWN_HOST, `${host} is not resolved`);
   }
-}
+});
 
-function all_done() {
-  // reset locally modified prefs
-  prefs.setCharPref("network.dns.localDomains", localdomainPref);
-  prefs.setBoolPref("network.dns.blockDotOnion", onionPref);
-  do_test_finished();
-}
-
-function run_test() {
-  onionPref = prefs.getBoolPref("network.dns.blockDotOnion");
-  localdomainPref = prefs.getCharPref("network.dns.localDomains");
-  prefs.setCharPref("network.dns.localDomains", "private.onion");
-  do_test_block();
-  do_test_pending();
-}
+add_task(async function test_dont_block() {
+  Services.prefs.setBoolPref("network.dns.blockDotOnion", false);
+  for (const host of ONION_NAMES) {
+    const { status, record } = await resolve(host);
+    Assert.ok(Components.isSuccessCode(status), `${host} is resolved`);
+    const answer = record
+      .QueryInterface(Ci.nsIDNSAddrRecord)
+      .getNextAddrAsString();
+    Assert.ok(
+      answer == "127.0.0.1" || answer == "::1",
+      `${host} is resolved to a loopback address`
+    );
+  }
+});
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/netwerk/test/unit/test_dns_onion.js b/netwerk/test/unit/test_dns_onion.js
index e13974a39e3..56e89438bbf 100644
--- a/netwerk/test/unit/test_dns_onion.js
+++ b/netwerk/test/unit/test_dns_onion.js
@@ -1,76 +1,81 @@
 "use strict";
 
-var mainThread = Services.tm.currentThread;
+// With network.dns.blockDotOnion, .onion names (RFC 7686) are not resolved,
+// in any case and in their fully qualified form ("x.onion.") too. They are
+// all listed in network.dns.localDomains, so that a lookup that is not
+// blocked succeeds with a loopback address.
+const ONION_NAMES = [
+  "private.onion",
+  "private.onion.",
+  "PRIVATE.ONION",
+  "Private.Onion.",
+];
 
-var onionPref;
-var localdomainPref;
-var prefs = Services.prefs;
+// Blocked as well, but only checked when blocked: network.dns.localDomains
+// matches a name with at most one trailing dot.
+const BLOCKED_ONLY_NAMES = ["private.onion.."];
 
-// check that we don't lookup .onion
-var listenerBlock = {
-  onLookupComplete(inRequest, inRecord, inStatus) {
-    Assert.ok(!Components.isSuccessCode(inStatus));
-    do_test_dontBlock();
-  },
-  QueryInterface: ChromeUtils.generateQI(["nsIDNSListener"]),
-};
+// A blocked name is refused before any lookup: asyncResolve throws, whereas a
+// failed lookup is reported to the listener.
 
-// check that we do lookup .onion (via pref)
-var listenerDontBlock = {
-  onLookupComplete(inRequest, inRecord) {
-    inRecord.QueryInterface(Ci.nsIDNSAddrRecord);
-    var answer = inRecord.getNextAddrAsString();
-    Assert.ok(answer == "127.0.0.1" || answer == "::1");
-    all_done();
-  },
-  QueryInterface: ChromeUtils.generateQI(["nsIDNSListener"]),
-};
-
-const defaultOriginAttributes = {};
+function resolve(host) {
+  return new Promise(resolvePromise => {
+    try {
+      Services.dns.asyncResolve(
+        host,
+        Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
+        0,
+        null, // resolverInfo
+        {
+          onLookupComplete(inRequest, inRecord, inStatus) {
+            resolvePromise({
+              status: inStatus,
+              record: inRecord,
+              refused: false,
+            });
+          },
+          QueryInterface: ChromeUtils.generateQI(["nsIDNSListener"]),
+        },
+        Services.tm.currentThread,
+        {} // defaultOriginAttributes
+      );
+    } catch (e) {
+      resolvePromise({ status: e.result, record: null, refused: true });
+    }
+  });
+}
 
-function do_test_dontBlock() {
-  prefs.setBoolPref("network.dns.blockDotOnion", false);
-  Services.dns.asyncResolve(
-    "private.onion",
-    Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
-    0,
-    null, // resolverInfo
-    listenerDontBlock,
-    mainThread,
-    defaultOriginAttributes
+add_setup(function () {
+  Services.prefs.setCharPref(
+    "network.dns.localDomains",
+    "private.onion, PRIVATE.ONION, Private.Onion"
   );
-}
+  registerCleanupFunction(() => {
+    Services.prefs.clearUserPref("network.dns.localDomains");
+    Services.prefs.clearUserPref("network.dns.blockDotOnion");
+  });
+});
 
-function do_test_block() {
-  prefs.setBoolPref("network.dns.blockDotOnion", true);
-  try {
-    Services.dns.asyncResolve(
-      "private.onion",
-      Ci.nsIDNSService.RESOLVE_TYPE_DEFAULT,
-      0,
-      null, // resolverInfo
-      listenerBlock,
-      mainThread,
-      defaultOriginAttributes
-    );
-  } catch (e) {
-    // it is ok for this negative test to fail fast
-    Assert.ok(true);
-    do_test_dontBlock();
+add_task(async function test_block() {
+  Services.prefs.setBoolPref("network.dns.blockDotOnion", true);
+  for (const host of [...ONION_NAMES, ...BLOCKED_ONLY_NAMES]) {
+    const { status, refused } = await resolve(host);
+    Assert.ok(refused, `${host} is refused before any lookup`);
+    Assert.equal(status, Cr.NS_ERROR_UNKNOWN_HOST, `${host} is not resolved`);
   }
-}
+});
 
-function all_done() {
-  // reset locally modified prefs
-  prefs.setCharPref("network.dns.localDomains", localdomainPref);
-  prefs.setBoolPref("network.dns.blockDotOnion", onionPref);
-  do_test_finished();
-}
-
-function run_test() {
-  onionPref = prefs.getBoolPref("network.dns.blockDotOnion");
-  localdomainPref = prefs.getCharPref("network.dns.localDomains");
-  prefs.setCharPref("network.dns.localDomains", "private.onion");
-  do_test_block();
-  do_test_pending();
-}
+add_task(async function test_dont_block() {
+  Services.prefs.setBoolPref("network.dns.blockDotOnion", false);
+  for (const host of ONION_NAMES) {
+    const { status, record } = await resolve(host);
+    Assert.ok(Components.isSuccessCode(status), `${host} is resolved`);
+    const answer = record
+      .QueryInterface(Ci.nsIDNSAddrRecord)
+      .getNextAddrAsString();
+    Assert.ok(
+      answer == "127.0.0.1" || answer == "::1",
+      `${host} is resolved to a loopback address`
+    );
+  }
+});
Loading diff…