CVE-2026-118003
Overview
Background
- RFC 7686 / `.onion`
- The
.onionspecial-use top-level domain names Tor hidden services. RFC 7686 requires non-Tor resolvers to refuse to look these names up. Firefox enforces this (whennetwork.dns.blockDotOnionis set) so that a.onionname 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.
StringEndsWithdefaults 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.
.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
- 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.Onionor the fully-qualifiedexample.onion.whilenetwork.dns.blockDotOnionis enabled. - Hostname reaches `PreprocessHostname`
The resolve request flows to
nsDNSService::PreprocessHostname(inferred: standard DNS entry path for any name Firefox resolves). - Block skipped
StringEndsWith(aInput, ".onion")fails to match the differently-cased / dot-suffixed input, so theNS_ERROR_UNKNOWN_HOSTshort-circuit is not taken. - Name sent to resolver
The
.onionname is forwarded to the configured system/network DNS resolver. - 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
.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
| Function | Change | Notes |
|---|---|---|
whilenetwerk/dns/nsDNSService2.cpp |
modified | |
ifnetwerk/dns/nsDNSService2.cpp |
modified | |
onLookupCompletenetwerk/test/unit/test_dns_onion.js |
modified | |
add_setupnetwerk/test/unit/test_dns_onion.js |
modified | |
add_tasknetwerk/test/unit/test_dns_onion.js |
modified | |
fornetwerk/test/unit/test_dns_onion.js |
modified |
Files Changed
netwerk/dns/nsDNSService2.cppnetwerk/test/unit/test_dns_onion.js
Audit Directions
- Other hostname/suffix filtersReview every security- or privacy-relevant
StringEndsWith/EndsWithon hostnames (local-domain matching, HSTS/HPKP host checks, blocklists,localhosthandling) for the same case-sensitivity and trailing-dot gaps. - Canonicalization before policy checksLook 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 consistencyAudit how
localDomains, cookie domains, and origin comparisons treat trailing dots, since the test noteslocalDomainsmatches at most one trailing dot while the block now strips all of them. - Special-use TLD enforcementCheck that other RFC 6761/7686 special names (e.g.
.local,.test) are enforced with the same normalization.
Patch
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`
+ );
+ }
+});
Regression Test / PoC
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`
+ );
+ }
+});