Firefox · Core
CVE-2026-109764
Logic Error in Core
Overview
Moderate
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifbrowser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs |
modified | |
asyncOnChannelRedirectbrowser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs |
modified | |
shouldLoadbrowser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs |
modified | |
observebrowser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs |
modified | |
forbrowser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.js |
modified | |
add_taskbrowser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.js |
modified | |
ifbrowser/components/enterprisepolicies/tests/browser/cached301.sjs |
modified | |
ifbrowser/components/enterprisepolicies/tests/browser/websitefilter_sw.html |
modified |
Files Changed
browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjsbrowser/components/enterprisepolicies/tests/browser/302exception.sjsbrowser/components/enterprisepolicies/tests/browser/browser.tomlbrowser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.jsbrowser/components/enterprisepolicies/tests/browser/cached301.sjsbrowser/components/enterprisepolicies/tests/browser/websitefilter_sw.htmlbrowser/components/enterprisepolicies/tests/browser/websitefilter_sw.js
Patch
diff --git a/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs b/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs
index 7ece7f52632..3c96834ed51 100644
--- a/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs
+++ b/browser/components/enterprisepolicies/helpers/WebsiteFilter.sys.mjs
@@ -117,16 +117,43 @@ export let WebsiteFilter = {
false,
true
);
+
+ // Backstop for redirects the observers below never see.
+ Services.catMan.addCategoryEntry(
+ "net-channel-event-sinks",
+ this.contractID,
+ this.contractID,
+ false,
+ true
+ );
}
- // We have to do this to catch 30X redirects.
+ // Cancelling here, rather than in the event sink, is what gives the user
+ // the error page.
// See bug 456957.
if (!this._observerAdded) {
this._observerAdded = true;
- // We rely on weak references, so we never remove this observer.
+ // We rely on weak references, so we never remove these observers.
+ // A 30X can also come from the cache, so all three topics matter.
Services.obs.addObserver(this, "http-on-examine-response", true);
+ Services.obs.addObserver(this, "http-on-examine-cached-response", true);
+ Services.obs.addObserver(this, "http-on-examine-merged-response", true);
}
},
+ asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
+ let contentType = newChannel.loadInfo.externalContentPolicyType;
+ if (
+ (contentType == Ci.nsIContentPolicy.TYPE_DOCUMENT ||
+ contentType == Ci.nsIContentPolicy.TYPE_SUBDOCUMENT) &&
+ !this.isAllowed(newChannel.URI.spec)
+ ) {
+ oldChannel.cancel(Cr.NS_ERROR_BLOCKED_BY_POLICY);
+ callback.onRedirectVerifyCallback(Cr.NS_ERROR_BLOCKED_BY_POLICY);
+ return;
+ }
+ callback.onRedirectVerifyCallback(Cr.NS_OK);
+ },
+
shouldLoad(contentLocation, loadInfo) {
let contentType = loadInfo.externalContentPolicyType;
let url = contentLocation.spec.toLowerCase();
@@ -151,10 +178,16 @@ export let WebsiteFilter = {
observe(subject) {
try {
let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ if (channel.responseStatus < 300 || channel.responseStatus >= 400) {
+ return;
+ }
+ // isDocument alone misses document loads whose channel has had
+ // LOAD_DOCUMENT_URI cleared.
+ let contentType = channel.loadInfo.externalContentPolicyType;
if (
- !channel.isDocument ||
- channel.responseStatus < 300 ||
- channel.responseStatus >= 400
+ !channel.isDocument &&
+ contentType != Ci.nsIContentPolicy.TYPE_DOCUMENT &&
+ contentType != Ci.nsIContentPolicy.TYPE_SUBDOCUMENT
) {
return;
}
@@ -174,6 +207,7 @@ export let WebsiteFilter = {
classID: Components.ID("{c0bbb557-813e-4e25-809d-b46a531a258f}"),
QueryInterface: ChromeUtils.generateQI([
"nsIContentPolicy",
+ "nsIChannelEventSink",
"nsIObserver",
"nsISupportsWeakReference",
]),
diff --git a/browser/components/enterprisepolicies/tests/browser/302exception.sjs b/browser/components/enterprisepolicies/tests/browser/302exception.sjs
new file mode 100644
index 00000000000..aa8e255a34f
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/browser/302exception.sjs
@@ -0,0 +1,8 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("Location", "policy_websitefilter_exception.html");
+}
diff --git a/browser/components/enterprisepolicies/tests/browser/browser.toml b/browser/components/enterprisepolicies/tests/browser/browser.toml
index d7047b3e827..5bf21068b24 100644
--- a/browser/components/enterprisepolicies/tests/browser/browser.toml
+++ b/browser/components/enterprisepolicies/tests/browser/browser.toml
@@ -14,7 +14,11 @@ support-files = [
"extensionsettings.html",
"301.sjs",
"302.sjs",
+ "302exception.sjs",
"404.sjs",
+ "cached301.sjs",
+ "websitefilter_sw.html",
+ "websitefilter_sw.js",
]
["browser_policies_about_policies.js"]
diff --git a/browser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.js b/browser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.js
index 0fa0dd4c809..4ad71fa9d92 100644
--- a/browser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.js
+++ b/browser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.js
@@ -8,6 +8,25 @@ const BLOCKED_PAGE = "policy_websitefilter_block.html";
const EXCEPTION_PAGE = "policy_websitefilter_exception.html";
const SAVELINKAS_PAGE = "policy_websitefilter_savelink.html";
+function unregisterAllServiceWorkers() {
+ let swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService(
+ Ci.nsIServiceWorkerManager
+ );
+ let regs = swm.getAllRegistrations();
+ let promises = [];
+ for (let i = 0; i < regs.length; i++) {
+ let reg = regs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo);
+ let { promise, resolve, reject } = Promise.withResolvers();
+ swm.unregister(
+ reg.principal,
+ { unregisterSucceeded: resolve, unregisterFailed: reject },
+ reg.scope
+ );
+ promises.push(promise);
+ }
+ return Promise.all(promises);
+}
+
async function clearWebsiteFilter() {
await setupPolicyEngineWithJson({
policies: {
@@ -52,6 +71,24 @@ add_task(async function test_http() {
await checkBlockedPage(SUPPORT_FILES_PATH + "301.sjs", true);
await checkBlockedPage(SUPPORT_FILES_PATH + "302.sjs", true);
+
+ await checkBlockedPage("view-source:" + SUPPORT_FILES_PATH + "301.sjs", true);
+
+ await checkBlockedPage("view-source:" + SUPPORT_FILES_PATH + "302.sjs", true);
+
+ // A redirect to an allowed destination must still complete.
+ let tab = BrowserTestUtils.addTab(
+ gBrowser,
+ SUPPORT_FILES_PATH + "302exception.sjs"
+ );
+ await BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ SUPPORT_FILES_PATH + EXCEPTION_PAGE
+ );
+ ok(true, "Redirect to an allowed page was not blocked");
+ BrowserTestUtils.removeTab(tab);
+
await clearWebsiteFilter();
});
@@ -189,3 +226,74 @@ add_task(async function test_http_json_policy() {
await checkBlockedPage(SUPPORT_FILES_PATH + "302.sjs", true);
await clearWebsiteFilter();
});
+
+async function redirectHits() {
+ let response = await fetch(SUPPORT_FILES_PATH + "cached301.sjs?hits");
+ return parseInt(await response.text(), 10);
+}
+
+add_task(async function test_cached_redirect() {
+ Services.cache2.clear();
+ let hits = await redirectHits();
+
+ // Prime the HTTP cache with the redirect while no filter is in effect.
+ let tab = BrowserTestUtils.addTab(
+ gBrowser,
+ SUPPORT_FILES_PATH + "cached301.sjs"
+ );
+ await BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ SUPPORT_FILES_PATH + BLOCKED_PAGE
+ );
+ BrowserTestUtils.removeTab(tab);
+ is(await redirectHits(), hits + 1, "Redirect was served from the network");
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ WebsiteFilter: {
+ Block: ["*://mochi.test/*policy_websitefilter_*"],
+ },
+ },
+ });
+
+ await checkBlockedPage(SUPPORT_FILES_PATH + "cached301.sjs", true);
+ is(await redirectHits(), hits + 1, "Redirect was served from the cache");
+
+ await clearWebsiteFilter();
+ Services.cache2.clear();
+});
+
+add_task(async function test_service_worker_redirect() {
+ // Service workers need a secure context, so this one case runs over https.
+ const HTTPS_PATH =
+ "https://example.com/browser/browser/components/enterprisepolicies/tests/browser/";
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ WebsiteFilter: {
+ Block: ["*://example.com/*policy_websitefilter_block*"],
+ },
+ },
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ HTTPS_PATH + "websitefilter_sw.html"
+ );
+ let status = await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("status").textContent != "pending",
+ "service worker registered"
+ );
+ return content.document.getElementById("status").textContent;
+ });
+ is(status, "ready", "Service worker is controlling the page");
+ BrowserTestUtils.removeTab(tab);
+
+ // The worker answers this navigation with a synthesized redirect.
+ await checkBlockedPage(HTTPS_PATH + "sw-redirect", true);
+
+ await unregisterAllServiceWorkers();
+ await clearWebsiteFilter();
+});
diff --git a/browser/components/enterprisepolicies/tests/browser/cached301.sjs b/browser/components/enterprisepolicies/tests/browser/cached301.sjs
new file mode 100644
index 00000000000..ac5d3ff0dd6
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/browser/cached301.sjs
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ let hits = parseInt(getState("hits") || "0", 10);
+
+ if (request.queryString == "hits") {
+ response.setHeader("Cache-Control", "no-store");
+ response.write("" + hits);
+ return;
+ }
+
+ setState("hits", "" + (hits + 1));
+ response.setStatusLine(request.httpVersion, 301, "Moved Permanently");
+ response.setHeader("Cache-Control", "max-age=3600");
+ response.setHeader("Location", "policy_websitefilter_block.html");
+}
diff --git a/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.html b/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.html
new file mode 100644
index 00000000000..eb30c5d9aad
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html><head><meta charset="UTF-8"></head><body>
+<div id="status">pending</div>
+<script>
+(async () => {
+ try {
+ await navigator.serviceWorker.register("websitefilter_sw.js");
+ await navigator.serviceWorker.ready;
+ if (!navigator.serviceWorker.controller) {
+ await new Promise(r =>
+ navigator.serviceWorker.addEventListener("controllerchange", r, { once: true })
+ );
+ }
+ document.getElementById("status").textContent = "ready";
+ } catch (e) {
+ document.getElementById("status").textContent = "error:" + e;
+ }
+})();
+</script>
+</body></html>
diff --git a/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.js b/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.js
new file mode 100644
index 00000000000..6d0e76c577b
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.js
@@ -0,0 +1,9 @@
+self.addEventListener("install", () => self.skipWaiting());
+self.addEventListener("activate", e => e.waitUntil(self.clients.claim()));
+self.addEventListener("fetch", event => {
+ if (event.request.url.includes("sw-redirect")) {
+ event.respondWith(
+ Response.redirect("policy_websitefilter_block.html", 302)
+ );
+ }
+});
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/browser/components/enterprisepolicies/tests/browser/302exception.sjs b/browser/components/enterprisepolicies/tests/browser/302exception.sjs
new file mode 100644
index 00000000000..aa8e255a34f
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/browser/302exception.sjs
@@ -0,0 +1,8 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ response.setStatusLine(request.httpVersion, 302, "Found");
+ response.setHeader("Location", "policy_websitefilter_exception.html");
+}
diff --git a/browser/components/enterprisepolicies/tests/browser/browser.toml b/browser/components/enterprisepolicies/tests/browser/browser.toml
index d7047b3e827..5bf21068b24 100644
--- a/browser/components/enterprisepolicies/tests/browser/browser.toml
+++ b/browser/components/enterprisepolicies/tests/browser/browser.toml
@@ -14,7 +14,11 @@ support-files = [
"extensionsettings.html",
"301.sjs",
"302.sjs",
+ "302exception.sjs",
"404.sjs",
+ "cached301.sjs",
+ "websitefilter_sw.html",
+ "websitefilter_sw.js",
]
["browser_policies_about_policies.js"]
diff --git a/browser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.js b/browser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.js
index 0fa0dd4c809..4ad71fa9d92 100644
--- a/browser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.js
+++ b/browser/components/enterprisepolicies/tests/browser/browser_policy_websitefilter.js
@@ -8,6 +8,25 @@ const BLOCKED_PAGE = "policy_websitefilter_block.html";
const EXCEPTION_PAGE = "policy_websitefilter_exception.html";
const SAVELINKAS_PAGE = "policy_websitefilter_savelink.html";
+function unregisterAllServiceWorkers() {
+ let swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService(
+ Ci.nsIServiceWorkerManager
+ );
+ let regs = swm.getAllRegistrations();
+ let promises = [];
+ for (let i = 0; i < regs.length; i++) {
+ let reg = regs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo);
+ let { promise, resolve, reject } = Promise.withResolvers();
+ swm.unregister(
+ reg.principal,
+ { unregisterSucceeded: resolve, unregisterFailed: reject },
+ reg.scope
+ );
+ promises.push(promise);
+ }
+ return Promise.all(promises);
+}
+
async function clearWebsiteFilter() {
await setupPolicyEngineWithJson({
policies: {
@@ -52,6 +71,24 @@ add_task(async function test_http() {
await checkBlockedPage(SUPPORT_FILES_PATH + "301.sjs", true);
await checkBlockedPage(SUPPORT_FILES_PATH + "302.sjs", true);
+
+ await checkBlockedPage("view-source:" + SUPPORT_FILES_PATH + "301.sjs", true);
+
+ await checkBlockedPage("view-source:" + SUPPORT_FILES_PATH + "302.sjs", true);
+
+ // A redirect to an allowed destination must still complete.
+ let tab = BrowserTestUtils.addTab(
+ gBrowser,
+ SUPPORT_FILES_PATH + "302exception.sjs"
+ );
+ await BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ SUPPORT_FILES_PATH + EXCEPTION_PAGE
+ );
+ ok(true, "Redirect to an allowed page was not blocked");
+ BrowserTestUtils.removeTab(tab);
+
await clearWebsiteFilter();
});
@@ -189,3 +226,74 @@ add_task(async function test_http_json_policy() {
await checkBlockedPage(SUPPORT_FILES_PATH + "302.sjs", true);
await clearWebsiteFilter();
});
+
+async function redirectHits() {
+ let response = await fetch(SUPPORT_FILES_PATH + "cached301.sjs?hits");
+ return parseInt(await response.text(), 10);
+}
+
+add_task(async function test_cached_redirect() {
+ Services.cache2.clear();
+ let hits = await redirectHits();
+
+ // Prime the HTTP cache with the redirect while no filter is in effect.
+ let tab = BrowserTestUtils.addTab(
+ gBrowser,
+ SUPPORT_FILES_PATH + "cached301.sjs"
+ );
+ await BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ SUPPORT_FILES_PATH + BLOCKED_PAGE
+ );
+ BrowserTestUtils.removeTab(tab);
+ is(await redirectHits(), hits + 1, "Redirect was served from the network");
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ WebsiteFilter: {
+ Block: ["*://mochi.test/*policy_websitefilter_*"],
+ },
+ },
+ });
+
+ await checkBlockedPage(SUPPORT_FILES_PATH + "cached301.sjs", true);
+ is(await redirectHits(), hits + 1, "Redirect was served from the cache");
+
+ await clearWebsiteFilter();
+ Services.cache2.clear();
+});
+
+add_task(async function test_service_worker_redirect() {
+ // Service workers need a secure context, so this one case runs over https.
+ const HTTPS_PATH =
+ "https://example.com/browser/browser/components/enterprisepolicies/tests/browser/";
+
+ await setupPolicyEngineWithJson({
+ policies: {
+ WebsiteFilter: {
+ Block: ["*://example.com/*policy_websitefilter_block*"],
+ },
+ },
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ HTTPS_PATH + "websitefilter_sw.html"
+ );
+ let status = await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("status").textContent != "pending",
+ "service worker registered"
+ );
+ return content.document.getElementById("status").textContent;
+ });
+ is(status, "ready", "Service worker is controlling the page");
+ BrowserTestUtils.removeTab(tab);
+
+ // The worker answers this navigation with a synthesized redirect.
+ await checkBlockedPage(HTTPS_PATH + "sw-redirect", true);
+
+ await unregisterAllServiceWorkers();
+ await clearWebsiteFilter();
+});
diff --git a/browser/components/enterprisepolicies/tests/browser/cached301.sjs b/browser/components/enterprisepolicies/tests/browser/cached301.sjs
new file mode 100644
index 00000000000..ac5d3ff0dd6
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/browser/cached301.sjs
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ let hits = parseInt(getState("hits") || "0", 10);
+
+ if (request.queryString == "hits") {
+ response.setHeader("Cache-Control", "no-store");
+ response.write("" + hits);
+ return;
+ }
+
+ setState("hits", "" + (hits + 1));
+ response.setStatusLine(request.httpVersion, 301, "Moved Permanently");
+ response.setHeader("Cache-Control", "max-age=3600");
+ response.setHeader("Location", "policy_websitefilter_block.html");
+}
diff --git a/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.html b/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.html
new file mode 100644
index 00000000000..eb30c5d9aad
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html><head><meta charset="UTF-8"></head><body>
+<div id="status">pending</div>
+<script>
+(async () => {
+ try {
+ await navigator.serviceWorker.register("websitefilter_sw.js");
+ await navigator.serviceWorker.ready;
+ if (!navigator.serviceWorker.controller) {
+ await new Promise(r =>
+ navigator.serviceWorker.addEventListener("controllerchange", r, { once: true })
+ );
+ }
+ document.getElementById("status").textContent = "ready";
+ } catch (e) {
+ document.getElementById("status").textContent = "error:" + e;
+ }
+})();
+</script>
+</body></html>
diff --git a/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.js b/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.js
new file mode 100644
index 00000000000..6d0e76c577b
--- /dev/null
+++ b/browser/components/enterprisepolicies/tests/browser/websitefilter_sw.js
@@ -0,0 +1,9 @@
+self.addEventListener("install", () => self.skipWaiting());
+self.addEventListener("activate", e => e.waitUntil(self.clients.claim()));
+self.addEventListener("fetch", event => {
+ if (event.request.url.includes("sw-redirect")) {
+ event.respondWith(
+ Response.redirect("policy_websitefilter_block.html", 302)
+ );
+ }
+});
Loading diff…
References
On This Page