CVE-2026-16390
Overview
Background
- `WebsiteFilter`
- the Enterprise Policies helper module that enforces the admin-configured
Block/ExceptionsURL lists in Firefox. - `isAllowed`
- the method that decides whether a candidate
urlis permitted, by matching it against_blockPatternsand_exceptionsPatterns. - FQDN trailing dot
- a fully-qualified domain name written with a trailing
.(e.g.host.com.) that resolves to the same host as the dotless form but is a distinct string. - `URL.parse`
- the WHATWG URL parser that produces a normalized
URLobject (ornull) from a raw URL string.
Root Cause Analysis
The original isAllowed matched the block and exception MatchPatternSet objects against url.toLowerCase(), i.e. the raw request string with only case folding applied. Because host equivalence was never normalized, a URL whose hostname carried a trailing dot, multiple trailing dots, a percent-encoded dot (%2e), or an ideographic full stop (。) failed to match a pattern like *://accounts.firefox.com/*, even though the browser would still resolve and load it as the blocked host. The invariant being violated is that the string fed to the pattern matcher must be the same canonical host/URL that the network stack will actually connect to; string-only case folding did not guarantee that.
The fix routes every URL through a new normalizeURL helper that reparses the URL, strips trailing dots from the hostname, and returns the lowercased canonical href before matching. It also fails closed: if the URL does not parse, isAllowed now returns false instead of silently skipping the filter.
URL.parse and strips trailing hostname dots so all equivalent forms collapse to one matched string.Attack Path
- Identify a blocked host
An attacker (or a user seeking to bypass policy) targets a URL an enterprise admin has blocked via
WebsiteFilter, such asaccounts.firefox.com. - Craft an equivalent host spelling
They rewrite the hostname into an equivalent form the parser would canonicalize but the raw matcher would not, e.g. appending a trailing dot (
accounts.firefox.com.), extra dots,%2e, or。. - Submit the mutated URL
The mutated URL reaches
isAllowed, whose rawtoLowerCase()string fails to match_blockPatterns, so the method returnstrue. - Load the blocked resource The browser resolves the trailing-dot FQDN to the same host and loads the content the policy was meant to block, defeating the mitigation.
Impact Assessment
WebsiteFilter policy in the parent/browser (chrome) context where policy enforcement lives. The precondition is that an enterprise WebsiteFilter Block policy is configured; the impact is a policy/mitigation bypass rather than memory corruption, consistent with the moderate severity rating.Changed Functions
| Function | Change | Notes |
|---|---|---|
isAllowedbrowser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs |
modified | |
ifbrowser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs |
modified | |
normalizeURLbrowser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs |
modified | |
add_taskbrowser/components/enterprisepolicies/tests/xpcshell/test_websitefilter.js |
modified |
Files Changed
browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjsbrowser/components/enterprisepolicies/tests/xpcshell/test_websitefilter.jsbrowser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml
Audit Directions
- String-based URL comparisonFlag any security decision that matches a raw or merely lowercased URL/host string against a pattern list without first parsing and canonicalizing it.
- Trailing-dot and encoding equivalenceLook for host checks that do not account for FQDN trailing dots, repeated dots, percent-encoded dots, or Unicode full-stop folding as equivalent to the bare host.
- Fail-open on parse failureAudit filters and allow/block gates that skip enforcement when input is unparseable instead of denying by default.
Patch
diff --git a/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs b/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs
index cda98f60e1c..8b26f43109b 100644
--- a/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs
+++ b/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs
@@ -165,14 +165,30 @@ export let WebsiteFilter = {
return this.QueryInterface(iid);
},
isAllowed(url) {
- if (this._blockPatterns?.matches(url.toLowerCase())) {
+ let normalizedURL = this.normalizeURL(url);
+ // A URL we are about to load should always parse, so this is unexpected.
+ // Block it rather than let an unparseable URL skip the filter.
+ if (normalizedURL == null) {
+ return false;
+ }
+ if (this._blockPatterns?.matches(normalizedURL)) {
if (
!this._exceptionsPatterns ||
- !this._exceptionsPatterns.matches(url.toLowerCase())
+ !this._exceptionsPatterns.matches(normalizedURL)
) {
return false;
}
}
return true;
},
+ normalizeURL(url) {
+ let parsed = URL.parse(url);
+ if (!parsed) {
+ return null;
+ }
+ if (parsed.hostname.endsWith(".")) {
+ parsed.hostname = parsed.hostname.replace(/\.+$/, "");
+ }
+ return parsed.href.toLowerCase();
+ },
};
diff --git a/browser/components/enterprisepolicies/tests/xpcshell/test_websitefilter.js b/browser/components/enterprisepolicies/tests/xpcshell/test_websitefilter.js
new file mode 100644
index 00000000000..103004c8a82
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/xpcshell/test_websitefilter.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const { WebsiteFilter } = ChromeUtils.importESModule(
+ "resource:///modules/policies/WebsiteFilter.sys.mjs"
+);
+
+add_task(async function setup() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ WebsiteFilter: {
+ Block: ["*://accounts.firefox.com/*"],
+ },
+ },
+ });
+});
+
+add_task(async function test_host_normalization() {
+ ok(
+ !WebsiteFilter.isAllowed("https://accounts.firefox.com/"),
+ "Bare host should be blocked"
+ );
+ ok(
+ !WebsiteFilter.isAllowed("https://accounts.firefox.com./"),
+ "Trailing-dot host should be blocked"
+ );
+ ok(
+ !WebsiteFilter.isAllowed("https://accounts.firefox.com.../"),
+ "Multiple trailing dots should be blocked"
+ );
+ ok(
+ !WebsiteFilter.isAllowed("https://accounts.firefox.com%2e/"),
+ "Percent-encoded trailing dot should be blocked"
+ );
+ ok(
+ !WebsiteFilter.isAllowed("https://accounts.firefox.com。/"),
+ "Ideographic full-stop (folds to a trailing dot) should be blocked"
+ );
+ ok(
+ WebsiteFilter.isAllowed("https://example.org./"),
+ "Unrelated trailing-dot host should not be blocked"
+ );
+ ok(
+ !WebsiteFilter.isAllowed("not a parseable url"),
+ "Unparseable URL should be blocked"
+ );
+});
diff --git a/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml b/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml
index a5532c4b387..67a25157634 100644
--- a/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml
+++ b/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml
@@ -80,3 +80,5 @@ support-files = ["config_popups_cookies_addons.json"]
["test_telemetry.js"]
["test_webserial.js"]
+
+["test_websitefilter.js"]
Regression Test / PoC
diff --git a/browser/components/enterprisepolicies/tests/xpcshell/test_websitefilter.js b/browser/components/enterprisepolicies/tests/xpcshell/test_websitefilter.js
new file mode 100644
index 00000000000..103004c8a82
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/xpcshell/test_websitefilter.js
@@ -0,0 +1,48 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+const { WebsiteFilter } = ChromeUtils.importESModule(
+ "resource:///modules/policies/WebsiteFilter.sys.mjs"
+);
+
+add_task(async function setup() {
+ await setupPolicyEngineWithJson({
+ policies: {
+ WebsiteFilter: {
+ Block: ["*://accounts.firefox.com/*"],
+ },
+ },
+ });
+});
+
+add_task(async function test_host_normalization() {
+ ok(
+ !WebsiteFilter.isAllowed("https://accounts.firefox.com/"),
+ "Bare host should be blocked"
+ );
+ ok(
+ !WebsiteFilter.isAllowed("https://accounts.firefox.com./"),
+ "Trailing-dot host should be blocked"
+ );
+ ok(
+ !WebsiteFilter.isAllowed("https://accounts.firefox.com.../"),
+ "Multiple trailing dots should be blocked"
+ );
+ ok(
+ !WebsiteFilter.isAllowed("https://accounts.firefox.com%2e/"),
+ "Percent-encoded trailing dot should be blocked"
+ );
+ ok(
+ !WebsiteFilter.isAllowed("https://accounts.firefox.com。/"),
+ "Ideographic full-stop (folds to a trailing dot) should be blocked"
+ );
+ ok(
+ WebsiteFilter.isAllowed("https://example.org./"),
+ "Unrelated trailing-dot host should not be blocked"
+ );
+ ok(
+ !WebsiteFilter.isAllowed("not a parseable url"),
+ "Unparseable URL should be blocked"
+ );
+});
diff --git a/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml b/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml
index a5532c4b387..67a25157634 100644
--- a/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml
+++ b/browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml
@@ -80,3 +80,5 @@ support-files = ["config_popups_cookies_addons.json"]
["test_telemetry.js"]
["test_webserial.js"]
+
+["test_websitefilter.js"]