Moderate firefox Logic Error 🔧 Commit mapped

Overview

Moderate
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionMitigation bypass in the DOM: Notifications component
ComponentDOM
Bug ClassLogic Error
Tracker2001265
Fix commitde4391eeaa52 (firefox) +252/-36
CISA KEVNot listed
Creditedtiebuchen
Disclosed2026-09-15

Changed Functions

FunctionChangeNotes
if
netwerk/base/LNAPermissionRequest.cpp
modified
if
netwerk/base/nsNetUtil.cpp
modified
if
netwerk/protocol/http/nsHttpChannel.cpp
modified
add_task
dom/notification/test/mochitest/test_notification_icon_csp.html
modified
add_setup
netwerk/test/browser/browser_test_local_network_access_notification.js
modified
add_task
netwerk/test/browser/browser_test_local_network_access_notification.js
modified

Files Changed

  • dom/notification/test/mochitest/file_notification_icon_csp.html
  • dom/notification/test/mochitest/file_notification_icon_csp_self.html
  • dom/notification/test/mochitest/mochitest.toml
  • dom/notification/test/mochitest/test_notification_icon_csp.html
  • netwerk/base/LNAPermissionRequest.cpp
  • netwerk/base/nsNetUtil.cpp
  • netwerk/base/nsNetUtil.h
  • netwerk/protocol/http/nsHttpChannel.cpp
  • netwerk/test/browser/browser.toml
  • netwerk/test/browser/browser_test_local_network_access_notification.js
  • netwerk/test/browser/page_notification_icon.html
diff --git a/netwerk/base/LNAPermissionRequest.cpp b/netwerk/base/LNAPermissionRequest.cpp
index 25001ee56bf..35fa63ea9b1 100644
--- a/netwerk/base/LNAPermissionRequest.cpp
+++ b/netwerk/base/LNAPermissionRequest.cpp
@@ -15,6 +15,7 @@
 #include "nsContentUtils.h"
 #include "nsGlobalWindowInner.h"
 #include "nsIConsoleService.h"
+#include "nsIContentPolicy.h"
 #include "nsIIOService.h"
 #include "nsIOService.h"
 #include "nsIPermissionManager.h"
@@ -215,15 +216,22 @@ nsresult LNAPermissionRequest::RequestPermission() {
 
   // For shared and service workers, do not show a permission prompt.
   // Only grant access if the origin already has a persistent LNA permission.
+  //
+  // Notification icons are fetched without a requesting node, and possibly
+  // long after the page that created the notification is gone, so there is no
+  // context to prompt in either.
+  bool isNotificationIcon = mLoadInfo->InternalContentPolicyType() ==
+                            nsIContentPolicy::TYPE_INTERNAL_IMAGE_NOTIFICATION;
   Maybe<dom::ClientInfo> clientInfo = mLoadInfo->GetClientInfo();
-  if (clientInfo.isSome() &&
-      (clientInfo->Type() == dom::ClientType::Sharedworker ||
-       clientInfo->Type() == dom::ClientType::Serviceworker)) {
+  if (isNotificationIcon ||
+      (clientInfo.isSome() &&
+       (clientInfo->Type() == dom::ClientType::Sharedworker ||
+        clientInfo->Type() == dom::ClientType::Serviceworker))) {
     nsCOMPtr<nsIPermissionManager> permMgr =
         mozilla::components::PermissionManager::Service();
     if (!permMgr || !mPrincipal) {
       NS_WARNING(
-          "LNA worker permission check failed: no permission manager or "
+          "LNA non-prompting permission check failed: no permission manager or "
           "principal");
       return Cancel();
     }
@@ -234,14 +242,20 @@ nsresult LNAPermissionRequest::RequestPermission() {
       return Allow(JS::UndefinedHandleValue);
     }
     // Log the denial to the browser console so developers can diagnose why
-    // worker fetch requests are being blocked.
+    // these requests are being blocked.
     nsCOMPtr<nsIConsoleService> console =
         do_GetService(NS_CONSOLESERVICE_CONTRACTID);
     if (console && mPrincipal) {
       nsAutoCString origin;
       mPrincipal->GetOrigin(origin);
       nsAutoString msg;
-      msg.AppendLiteral("Local Network Access blocked: worker from origin ");
+      msg.AppendLiteral("Local Network Access blocked: ");
+      if (isNotificationIcon) {
+        msg.AppendLiteral("notification icon load");
+      } else {
+        msg.AppendLiteral("worker");
+      }
+      msg.AppendLiteral(" from origin ");
       msg.Append(NS_ConvertUTF8toUTF16(origin));
       msg.AppendLiteral(" attempted ");
       msg.Append(NS_ConvertUTF8toUTF16(mType));
diff --git a/netwerk/base/nsNetUtil.cpp b/netwerk/base/nsNetUtil.cpp
index c182ce1d7f2..0ffd7651d2d 100644
--- a/netwerk/base/nsNetUtil.cpp
+++ b/netwerk/base/nsNetUtil.cpp
@@ -27,7 +27,9 @@
 #include "mozilla/StoragePrincipalHelper.h"
 #include "mozilla/TaskQueue.h"
 #include "mozilla/dom/BlobURLProtocolHandler.h"
+#include "mozilla/dom/BrowsingContext.h"
 #include "mozilla/dom/Document.h"
+#include "mozilla/dom/PolicyContainer.h"
 #include "mozilla/dom/nsCSPUtils.h"
 #include "mozilla/dom/nsHTTPSOnlyUtils.h"
 #include "mozilla/dom/nsMixedContentBlocker.h"
@@ -4303,6 +4305,31 @@ nsresult AddExtraHeaders(nsIHttpChannel* aHttpChannel,
   return NS_OK;
 }
 
+nsILoadInfo::IPAddressSpace GetParentIPAddressSpace(nsILoadInfo* aLoadInfo) {
+  MOZ_ASSERT(aLoadInfo);
+
+  RefPtr<mozilla::dom::BrowsingContext> bc;
+  aLoadInfo->GetBrowsingContext(getter_AddRefs(bc));
+  if (bc) {
+    return bc->GetCurrentIPAddressSpace();
+  }
+
+  // Loads that are not tied to a browsing context (worker requests, and
+  // notification icon loads which have no requesting node) read the address
+  // space from the policy container propagated from the parent document.
+  nsCOMPtr<nsIPolicyContainer> policyContainer =
+      aLoadInfo->GetPolicyContainer();
+  if (policyContainer) {
+    nsILoadInfo::IPAddressSpace addressSpace =
+        PolicyContainer::Cast(policyContainer)->GetIPAddressSpace();
+    if (addressSpace != nsILoadInfo::Unknown) {
+      return addressSpace;
+    }
+  }
+
+  return aLoadInfo->GetParentIpAddressSpace();
+}
+
 bool IsLocalHostAccess(
     const nsILoadInfo::IPAddressSpace aParentIPAddressSpace,
     const nsILoadInfo::IPAddressSpace aTargetIPAddressSpace) {
diff --git a/netwerk/base/nsNetUtil.h b/netwerk/base/nsNetUtil.h
index 7b332efa051..c6614a657df 100644
--- a/netwerk/base/nsNetUtil.h
+++ b/netwerk/base/nsNetUtil.h
@@ -1204,6 +1204,10 @@ void ParseSimpleURISchemes(const nsACString& schemeList);
 nsresult AddExtraHeaders(nsIHttpChannel* aHttpChannel,
                          const nsACString& aExtraHeaders, bool aMerge = true);
 
+// Returns the IP address space the load is initiated from, which is what a
+// Local Network Access check compares the target address space against.
+nsILoadInfo::IPAddressSpace GetParentIPAddressSpace(nsILoadInfo* aLoadInfo);
+
 bool IsLocalOrPrivateNetworkAccess(
     nsILoadInfo::IPAddressSpace aParentIPAddressSpace,
     nsILoadInfo::IPAddressSpace aTargetIPAddressSpace);
diff --git a/netwerk/protocol/http/nsHttpChannel.cpp b/netwerk/protocol/http/nsHttpChannel.cpp
index b9464cb2189..fe50944ebbb 100644
--- a/netwerk/protocol/http/nsHttpChannel.cpp
+++ b/netwerk/protocol/http/nsHttpChannel.cpp
@@ -2209,22 +2209,7 @@ nsresult nsHttpChannel::InitTransaction() {
   mLoadInfo->GetBrowsingContext(getter_AddRefs(bc));
 
   nsILoadInfo::IPAddressSpace parentAddressSpace =
-      nsILoadInfo::IPAddressSpace::Unknown;
-  // For worker-initiated requests, read IP address space from the policy
-  // container which carries the parent document's address space.
-  Maybe<dom::ClientInfo> clientInfo = mLoadInfo->GetClientInfo();
-  if (clientInfo.isSome() && clientInfo->Type() != dom::ClientType::Window) {
-    nsCOMPtr<nsIPolicyContainer> policyContainer =
-        mLoadInfo->GetPolicyContainer();
-    if (policyContainer) {
-      parentAddressSpace =
-          PolicyContainer::Cast(policyContainer)->GetIPAddressSpace();
-    }
-  } else if (!bc) {
-    parentAddressSpace = mLoadInfo->GetParentIpAddressSpace();
-  } else {
-    parentAddressSpace = bc->GetCurrentIPAddressSpace();
-  }
+      mozilla::net::GetParentIPAddressSpace(mLoadInfo);
 
   // Check if this is a top-level navigation load and grant LNA permissions
   // to skip local network access verification for navigational loads
@@ -10044,20 +10029,7 @@ static void RecordLNATelemetry(nsHttpChannel* aChannel, bool aLoadSuccess) {
   loadInfo->GetBrowsingContext(getter_AddRefs(bc));
 
   nsILoadInfo::IPAddressSpace parentAddressSpace =
-      nsILoadInfo::IPAddressSpace::Unknown;
-  Maybe<dom::ClientInfo> clientInfo = loadInfo->GetClientInfo();
-  if (clientInfo.isSome() && clientInfo->Type() != dom::ClientType::Window) {
-    nsCOMPtr<nsIPolicyContainer> policyContainer =
-        loadInfo->GetPolicyContainer();
-    if (policyContainer) {
-      parentAddressSpace =
-          PolicyContainer::Cast(policyContainer)->GetIPAddressSpace();
-    }
-  } else if (!bc) {
-    parentAddressSpace = loadInfo->GetParentIpAddressSpace();
-  } else {
-    parentAddressSpace = bc->GetCurrentIPAddressSpace();
-  }
+      mozilla::net::GetParentIPAddressSpace(loadInfo);
 
   // Early return if NOT LNA - don't record telemetry or log
   if (!mozilla::net::IsLocalOrPrivateNetworkAccess(
diff --git a/dom/notification/test/mochitest/file_notification_icon_csp.html b/dom/notification/test/mochitest/file_notification_icon_csp.html
new file mode 100644
index 00000000000..b7a4f407993
--- /dev/null
+++ b/dom/notification/test/mochitest/file_notification_icon_csp.html
@@ -0,0 +1,3 @@
+<!DOCTYPE html>
+<meta http-equiv="Content-Security-Policy" content="img-src 'none'">
+<title>Document with an img-src 'none' policy</title>
diff --git a/dom/notification/test/mochitest/file_notification_icon_csp_self.html b/dom/notification/test/mochitest/file_notification_icon_csp_self.html
new file mode 100644
index 00000000000..68ee210f4b9
--- /dev/null
+++ b/dom/notification/test/mochitest/file_notification_icon_csp_self.html
@@ -0,0 +1,3 @@
+<!DOCTYPE html>
+<meta http-equiv="Content-Security-Policy" content="img-src 'self'">
+<title>Document with an img-src 'self' policy</title>
diff --git a/dom/notification/test/mochitest/mochitest.toml b/dom/notification/test/mochitest/mochitest.toml
index 081b76a22be..a6f5af8dad0 100644
--- a/dom/notification/test/mochitest/mochitest.toml
+++ b/dom/notification/test/mochitest/mochitest.toml
@@ -34,6 +34,17 @@ support-files = ["blank.html"]
 ["test_notification_crossorigin_iframe_nested_glean.html"]
 support-files = ["blank.html"]
 
+["test_notification_icon_csp.html"]
+skip-if = [
+  "xorigin", # Bug 1792790
+]
+support-files = [
+  "blank.html",
+  "file_notification_icon_csp.html",
+  "file_notification_icon_csp_self.html",
+  "file_pink.gif",
+]
+
 ["test_notification_image.html"]
 skip-if = [
   "xorigin", # Bug 1792790
diff --git a/dom/notification/test/mochitest/test_notification_icon_csp.html b/dom/notification/test/mochitest/test_notification_icon_csp.html
new file mode 100644
index 00000000000..0024139afb1
--- /dev/null
+++ b/dom/notification/test/mochitest/test_notification_icon_csp.html
@@ -0,0 +1,72 @@
+<!DOCTYPE html>
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<script src="MockAlertsService.js"></script>
+<script src="NotificationTest.js"></script>
+<link rel="stylesheet" href="/tests/SimpleTest/test.css">
+<script>
+
+const SAME_ORIGIN_ICON = new URL("./file_pink.gif", location.href).href;
+const CROSS_ORIGIN_ICON =
+  "https://example.org/tests/dom/notification/test/mochitest/file_pink.gif";
+
+add_setup(async () => {
+  await NotificationTest.allowNotifications();
+  await MockAlertsService.register();
+});
+
+async function getIconImage(win, icon) {
+  let notification = new win.Notification("test", { icon });
+
+  info("Waiting for notification show");
+  await new Promise(resolve => notification.addEventListener("show", resolve, {once: true}));
+
+  let ids = await MockAlertsService.getNotificationIds();
+  is(ids.length, 1, "Got one notification");
+
+  let image = await MockAlertsService.getIconImage(ids[0]);
+  await MockAlertsService.closeNotifications();
+  return image;
+}
+
+async function createIframe(src) {
+  let iframe = document.createElement("iframe");
+  iframe.src = src;
+  document.body.appendChild(iframe);
+  await new Promise(resolve => iframe.addEventListener("load", resolve, {once: true}));
+  SimpleTest.registerCleanupFunction(() => iframe.remove());
+  return iframe.contentWindow;
+}
+
+// The icon load must be subject to the content security policy of the document
+// that created the notification, even though the image is never loaded into
+// that document.
+add_task(async function test_icon_blocked_by_document_csp() {
+  let win = await createIframe("file_notification_icon_csp.html");
+
+  is(await getIconImage(win, SAME_ORIGIN_ICON), null,
+     "img-src 'none' blocks the icon load");
+  is(await getIconImage(win, "data:image/gif;base64,R0lGODlhAQABAPAAAPIu7////yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="), null,
+     "img-src 'none' blocks a data: icon load");
+});
+
+// The policy is enforced per directive value, not as a blanket block.
+add_task(async function test_icon_self_csp() {
+  let win = await createIframe("file_notification_icon_csp_self.html");
+
+  ok(await getIconImage(win, SAME_ORIGIN_ICON),
+     "img-src 'self' allows a same-origin icon");
+  is(await getIconImage(win, CROSS_ORIGIN_ICON), null,
+     "img-src 'self' blocks a cross-origin icon");
+});
+
+// Sanity check that the same loads succeed without a policy, so that the
+// assertions above can't pass for an unrelated reason.
+add_task(async function test_icon_allowed_without_csp() {
+  let win = await createIframe("blank.html");
+
+  ok(await getIconImage(win, SAME_ORIGIN_ICON),
+     "Same-origin icon loads when the document has no policy");
+  ok(await getIconImage(win, CROSS_ORIGIN_ICON),
+     "Cross-origin icon loads when the document has no policy");
+});
+</script>
diff --git a/netwerk/test/browser/browser.toml b/netwerk/test/browser/browser.toml
index d270ab9df6d..24097aa6963 100644
--- a/netwerk/test/browser/browser.toml
+++ b/netwerk/test/browser/browser.toml
@@ -283,6 +283,12 @@ support-files = [
   "page_with_non_trackers.html^headers^",
 ]
 
+["browser_test_local_network_access_notification.js"]
+support-files = [
+  "head_local_network_access.js",
+  "page_notification_icon.html",
+]
+
 ["browser_test_local_network_access_permissions.js"]
 support-files = [
   "head_local_network_access.js",
diff --git a/netwerk/test/browser/browser_test_local_network_access_notification.js b/netwerk/test/browser/browser_test_local_network_access_notification.js
new file mode 100644
index 00000000000..cc408a18fae
--- /dev/null
+++ b/netwerk/test/browser/browser_test_local_network_access_notification.js
@@ -0,0 +1,96 @@
+"use strict";
+
+// Tests that Local Network Access (LNA) checks are enforced for Notification
+// icon loads. Those loads have no requesting node, so the address space of the
+// document that created the notification is read from its policy container.
+
+Services.scriptloader.loadSubScript(
+  new URL("head_local_network_access.js", gTestPath).href,
+  this
+);
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+  "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const TEST_PAGE = `${LNA_BASE_URL}page_notification_icon.html`;
+const TEST_ORIGIN = "https://example.com";
+
+add_setup(async function () {
+  await setupLnaPrefs();
+  await setupLnaServer();
+  PermissionTestUtils.add(
+    TEST_ORIGIN,
+    "desktop-notification",
+    Services.perms.ALLOW_ACTION
+  );
+  registerCleanupFunction(() => {
+    PermissionTestUtils.remove(TEST_ORIGIN, "desktop-notification");
+  });
+});
+
+async function showNotificationWithIcon(rand, expectedStatus, description) {
+  const promise = observeAndCheck("img", rand, expectedStatus, description);
+  const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+
+  await SpecialPowers.spawn(tab.linkedBrowser, [rand], async iconRand => {
+    const notification = new content.Notification("lna", {
+      icon: `http://localhost:21555/?type=img&rand=${iconRand}`,
+    });
+    // The notification is shown whether or not the icon load succeeds.
+    await new Promise(resolve =>
+      notification.addEventListener("show", resolve, { once: true })
+    );
+    notification.close();
+  });
+
+  await promise;
+  gBrowser.removeTab(tab);
+}
+
+add_task(async function test_notification_icon_denied_without_permission() {
+  Services.prefs.setCharPref(
+    "network.lna.address_space.public.override",
+    "127.0.0.1:4443"
+  );
+
+  await showNotificationWithIcon(
+    Math.random(),
+    Cr.NS_ERROR_LOCAL_NETWORK_ACCESS_DENIED,
+    "Notification icon load to localhost is denied without permission"
+  );
+
+  Services.prefs.clearUserPref("network.lna.address_space.public.override");
+});
+
+add_task(async function test_notification_icon_allowed_with_permission() {
+  Services.prefs.setCharPref(
+    "network.lna.address_space.public.override",
+    "127.0.0.1:4443"
+  );
+
+  PermissionTestUtils.add(
+    TEST_ORIGIN,
+    "loopback-network",
+    Services.perms.ALLOW_ACTION
+  );
+
+  await showNotificationWithIcon(
+    Math.random(),
+    Cr.NS_OK,
+    "Notification icon load to localhost is allowed with persistent permission"
+  );
+
+  PermissionTestUtils.remove(TEST_ORIGIN, "loopback-network");
+  Services.prefs.clearUserPref("network.lna.address_space.public.override");
+});
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/dom/notification/test/mochitest/file_notification_icon_csp.html b/dom/notification/test/mochitest/file_notification_icon_csp.html
new file mode 100644
index 00000000000..b7a4f407993
--- /dev/null
+++ b/dom/notification/test/mochitest/file_notification_icon_csp.html
@@ -0,0 +1,3 @@
+<!DOCTYPE html>
+<meta http-equiv="Content-Security-Policy" content="img-src 'none'">
+<title>Document with an img-src 'none' policy</title>
diff --git a/dom/notification/test/mochitest/file_notification_icon_csp_self.html b/dom/notification/test/mochitest/file_notification_icon_csp_self.html
new file mode 100644
index 00000000000..68ee210f4b9
--- /dev/null
+++ b/dom/notification/test/mochitest/file_notification_icon_csp_self.html
@@ -0,0 +1,3 @@
+<!DOCTYPE html>
+<meta http-equiv="Content-Security-Policy" content="img-src 'self'">
+<title>Document with an img-src 'self' policy</title>
diff --git a/dom/notification/test/mochitest/mochitest.toml b/dom/notification/test/mochitest/mochitest.toml
index 081b76a22be..a6f5af8dad0 100644
--- a/dom/notification/test/mochitest/mochitest.toml
+++ b/dom/notification/test/mochitest/mochitest.toml
@@ -34,6 +34,17 @@ support-files = ["blank.html"]
 ["test_notification_crossorigin_iframe_nested_glean.html"]
 support-files = ["blank.html"]
 
+["test_notification_icon_csp.html"]
+skip-if = [
+  "xorigin", # Bug 1792790
+]
+support-files = [
+  "blank.html",
+  "file_notification_icon_csp.html",
+  "file_notification_icon_csp_self.html",
+  "file_pink.gif",
+]
+
 ["test_notification_image.html"]
 skip-if = [
   "xorigin", # Bug 1792790
diff --git a/dom/notification/test/mochitest/test_notification_icon_csp.html b/dom/notification/test/mochitest/test_notification_icon_csp.html
new file mode 100644
index 00000000000..0024139afb1
--- /dev/null
+++ b/dom/notification/test/mochitest/test_notification_icon_csp.html
@@ -0,0 +1,72 @@
+<!DOCTYPE html>
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<script src="MockAlertsService.js"></script>
+<script src="NotificationTest.js"></script>
+<link rel="stylesheet" href="/tests/SimpleTest/test.css">
+<script>
+
+const SAME_ORIGIN_ICON = new URL("./file_pink.gif", location.href).href;
+const CROSS_ORIGIN_ICON =
+  "https://example.org/tests/dom/notification/test/mochitest/file_pink.gif";
+
+add_setup(async () => {
+  await NotificationTest.allowNotifications();
+  await MockAlertsService.register();
+});
+
+async function getIconImage(win, icon) {
+  let notification = new win.Notification("test", { icon });
+
+  info("Waiting for notification show");
+  await new Promise(resolve => notification.addEventListener("show", resolve, {once: true}));
+
+  let ids = await MockAlertsService.getNotificationIds();
+  is(ids.length, 1, "Got one notification");
+
+  let image = await MockAlertsService.getIconImage(ids[0]);
+  await MockAlertsService.closeNotifications();
+  return image;
+}
+
+async function createIframe(src) {
+  let iframe = document.createElement("iframe");
+  iframe.src = src;
+  document.body.appendChild(iframe);
+  await new Promise(resolve => iframe.addEventListener("load", resolve, {once: true}));
+  SimpleTest.registerCleanupFunction(() => iframe.remove());
+  return iframe.contentWindow;
+}
+
+// The icon load must be subject to the content security policy of the document
+// that created the notification, even though the image is never loaded into
+// that document.
+add_task(async function test_icon_blocked_by_document_csp() {
+  let win = await createIframe("file_notification_icon_csp.html");
+
+  is(await getIconImage(win, SAME_ORIGIN_ICON), null,
+     "img-src 'none' blocks the icon load");
+  is(await getIconImage(win, "data:image/gif;base64,R0lGODlhAQABAPAAAPIu7////yH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="), null,
+     "img-src 'none' blocks a data: icon load");
+});
+
+// The policy is enforced per directive value, not as a blanket block.
+add_task(async function test_icon_self_csp() {
+  let win = await createIframe("file_notification_icon_csp_self.html");
+
+  ok(await getIconImage(win, SAME_ORIGIN_ICON),
+     "img-src 'self' allows a same-origin icon");
+  is(await getIconImage(win, CROSS_ORIGIN_ICON), null,
+     "img-src 'self' blocks a cross-origin icon");
+});
+
+// Sanity check that the same loads succeed without a policy, so that the
+// assertions above can't pass for an unrelated reason.
+add_task(async function test_icon_allowed_without_csp() {
+  let win = await createIframe("blank.html");
+
+  ok(await getIconImage(win, SAME_ORIGIN_ICON),
+     "Same-origin icon loads when the document has no policy");
+  ok(await getIconImage(win, CROSS_ORIGIN_ICON),
+     "Cross-origin icon loads when the document has no policy");
+});
+</script>
diff --git a/netwerk/test/browser/browser.toml b/netwerk/test/browser/browser.toml
index d270ab9df6d..24097aa6963 100644
--- a/netwerk/test/browser/browser.toml
+++ b/netwerk/test/browser/browser.toml
@@ -283,6 +283,12 @@ support-files = [
   "page_with_non_trackers.html^headers^",
 ]
 
+["browser_test_local_network_access_notification.js"]
+support-files = [
+  "head_local_network_access.js",
+  "page_notification_icon.html",
+]
+
 ["browser_test_local_network_access_permissions.js"]
 support-files = [
   "head_local_network_access.js",
diff --git a/netwerk/test/browser/browser_test_local_network_access_notification.js b/netwerk/test/browser/browser_test_local_network_access_notification.js
new file mode 100644
index 00000000000..cc408a18fae
--- /dev/null
+++ b/netwerk/test/browser/browser_test_local_network_access_notification.js
@@ -0,0 +1,96 @@
+"use strict";
+
+// Tests that Local Network Access (LNA) checks are enforced for Notification
+// icon loads. Those loads have no requesting node, so the address space of the
+// document that created the notification is read from its policy container.
+
+Services.scriptloader.loadSubScript(
+  new URL("head_local_network_access.js", gTestPath).href,
+  this
+);
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+  "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const TEST_PAGE = `${LNA_BASE_URL}page_notification_icon.html`;
+const TEST_ORIGIN = "https://example.com";
+
+add_setup(async function () {
+  await setupLnaPrefs();
+  await setupLnaServer();
+  PermissionTestUtils.add(
+    TEST_ORIGIN,
+    "desktop-notification",
+    Services.perms.ALLOW_ACTION
+  );
+  registerCleanupFunction(() => {
+    PermissionTestUtils.remove(TEST_ORIGIN, "desktop-notification");
+  });
+});
+
+async function showNotificationWithIcon(rand, expectedStatus, description) {
+  const promise = observeAndCheck("img", rand, expectedStatus, description);
+  const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+
+  await SpecialPowers.spawn(tab.linkedBrowser, [rand], async iconRand => {
+    const notification = new content.Notification("lna", {
+      icon: `http://localhost:21555/?type=img&rand=${iconRand}`,
+    });
+    // The notification is shown whether or not the icon load succeeds.
+    await new Promise(resolve =>
+      notification.addEventListener("show", resolve, { once: true })
+    );
+    notification.close();
+  });
+
+  await promise;
+  gBrowser.removeTab(tab);
+}
+
+add_task(async function test_notification_icon_denied_without_permission() {
+  Services.prefs.setCharPref(
+    "network.lna.address_space.public.override",
+    "127.0.0.1:4443"
+  );
+
+  await showNotificationWithIcon(
+    Math.random(),
+    Cr.NS_ERROR_LOCAL_NETWORK_ACCESS_DENIED,
+    "Notification icon load to localhost is denied without permission"
+  );
+
+  Services.prefs.clearUserPref("network.lna.address_space.public.override");
+});
+
+add_task(async function test_notification_icon_allowed_with_permission() {
+  Services.prefs.setCharPref(
+    "network.lna.address_space.public.override",
+    "127.0.0.1:4443"
+  );
+
+  PermissionTestUtils.add(
+    TEST_ORIGIN,
+    "loopback-network",
+    Services.perms.ALLOW_ACTION
+  );
+
+  await showNotificationWithIcon(
+    Math.random(),
+    Cr.NS_OK,
+    "Notification icon load to localhost is allowed with persistent permission"
+  );
+
+  PermissionTestUtils.remove(TEST_ORIGIN, "loopback-network");
+  Services.prefs.clearUserPref("network.lna.address_space.public.override");
+});
+
+// Without the public override the document itself is loopback, so the icon load
+// is not a local network access and must not be affected.
+add_task(async function test_notification_icon_local_to_local_allowed() {
+  await showNotificationWithIcon(
+    Math.random(),
+    Cr.NS_OK,
+    "Notification icon load from a local document is not an LNA"
+  );
+});
diff --git a/netwerk/test/browser/page_notification_icon.html b/netwerk/test/browser/page_notification_icon.html
new file mode 100644
index 00000000000..bab0cdf861c
--- /dev/null
+++ b/netwerk/test/browser/page_notification_icon.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <title>Notification icon LNA test</title>
+</head>
+<body></body>
+</html>
Loading diff…