Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionMitigation bypass in the Enterprise Policies component
ComponentCore
Bug ClassLogic Error
Tracker2044527
Fix commite5d6e2bb8b50 (firefox) +68/-2
CISA KEVNot listed
CreditedSouma Ohsawa
Disclosed2026-07-21

Background

`WebsiteFilter`
the Enterprise Policies helper module that enforces the admin-configured Block/Exceptions URL lists in Firefox.
`isAllowed`
the method that decides whether a candidate url is permitted, by matching it against _blockPatterns and _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 URL object (or null) 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.

Key insight
The single mistake was treating URL/host equivalence as a byte-for-byte lowercase string comparison rather than a parsed-and-normalized comparison, letting equivalent host spellings (notably trailing-dot FQDNs) evade the block list; the fix canonicalizes the URL through URL.parse and strips trailing hostname dots so all equivalent forms collapse to one matched string.

Attack Path

  1. Identify a blocked host An attacker (or a user seeking to bypass policy) targets a URL an enterprise admin has blocked via WebsiteFilter, such as accounts.firefox.com.
  2. 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 。.
  3. Submit the mutated URL The mutated URL reaches isAllowed, whose raw toLowerCase() string fails to match _blockPatterns, so the method returns true.
  4. 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

An attacker or unmanaged user gains the ability to reach hosts an enterprise administrator explicitly blocked, defeating the 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

FunctionChangeNotes
isAllowed
browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs
modified
if
browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs
modified
normalizeURL
browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs
modified
add_task
browser/components/enterprisepolicies/tests/xpcshell/test_websitefilter.js
modified

Files Changed

  • browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs
  • browser/components/enterprisepolicies/tests/xpcshell/test_websitefilter.js
  • browser/components/enterprisepolicies/tests/xpcshell/xpcshell.toml

Audit Directions

  • String-based URL comparison
    Flag 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 equivalence
    Look 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 failure
    Audit filters and allow/block gates that skip enforcement when input is unparseable instead of denying by default.
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"]
Loading diff…

Regression Test / PoC

shipped with the fix
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"]
Loading diff…