Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionMitigation bypass in the DOM: Networking component
ComponentDOM
Bug ClassLogic Error
Tracker1996495
Fix commitdea366bd49ec (firefox) +536/-23
CISA KEVNot listed
Creditedtiebuchen
Disclosed2026-07-21

Background

Local Network Access (LNA)
a mitigation that blocks a request originating from a less-private context from reaching a more-private IP address space (loopback or local network).
`PolicyContainer`
a per-document security container holding inheritable policies (CSP, integrity policy, and now IP address space) that child execution contexts adopt.
`nsILoadInfo::IPAddressSpace`
an enum classifying a resource’s network location (e.g. Unknown, public, private, local) that LNA checks compare against.
Service Worker
a worker script with no associated browsing context, so it cannot read its parent document’s address space directly.

Root Cause Analysis

Local Network Access decisions require knowing the initiator’s IPAddressSpace, which a document normally reads from its browsing context, but a Service Worker has no browsing context to read it from. Because the PolicyContainer inherited by workers carried CSP and integrity policy but not the document’s IP address space, worker-initiated fetches were evaluated with an effectively Unknown address space, silently skipping the LNA gate. The invariant violated is that a worker’s requests must be subject to the same address-space-based LNA restrictions as the document that spawned it.

The fix threads nsILoadInfo::IPAddressSpace from the channel’s LoadInfo into the PolicyContainer (Document::InitPolicyContainer), persists it across serialization and IPC (Write/Read, ToArgs/FromArgs, InitFromOther, Equals), and plumbs it through Service Worker registration and startup (ServiceWorkerManager::Register, ServiceWorkerPrivate::Initialize). With the address space now available inside worker contexts, LNA checks can enforce the same policy the document was subject to. The serialization version is bumped from 1 to 2 and the reader widened to accept both so older persisted containers still load.

Key insight
The single mistake was that the document’s IP address space was never propagated into the PolicyContainer that workers inherit, so worker fetches bypassed Local Network Access checks; the fix stores and serializes mIPAddressSpace on the container and plumbs it through the Service Worker registration path so worker contexts can enforce LNA.

Attack Path

  1. Host a public page An attacker serves a page from a public IP address space that registers or controls a Service Worker.
  2. Move requests into the worker Because the worker has no browsing context, its inherited PolicyContainer lacks the document’s IP address space and treats it as Unknown.
  3. Issue cross-address-space fetches The worker fetches resources on the victim’s loopback or local network, which LNA would normally block from a public-space initiator.
  4. Bypass the mitigation The missing address space causes the LNA check to be skipped, letting the worker reach otherwise-protected local network endpoints.

Impact Assessment

An attacker gains the ability to bypass the Local Network Access mitigation from within Service Worker (and inherited worker) contexts, allowing web content to reach loopback or local-network services that LNA is designed to protect. This operates in the content/worker context of a page the attacker controls, with the precondition that the victim visits attacker-controlled public-space content able to register or drive a worker. The impact is rated moderate: it is a mitigation bypass enabling local-network reachability, not direct memory corruption.

Changed Functions

FunctionChangeNotes
if
dom/base/Document.cpp
modified
Read
dom/security/PolicyContainer.cpp
modified
if
dom/security/PolicyContainer.cpp
modified
mType
dom/serviceworkers/ServiceWorkerRegisterJob.cpp
modified
mIPAddressSpace
dom/serviceworkers/ServiceWorkerRegisterJob.cpp
modified
if
dom/workers/WorkerPrivate.cpp
modified

Files Changed

  • dom/base/Document.cpp
  • dom/security/PolicyContainer.cpp
  • dom/security/PolicyContainer.h
  • dom/serviceworkers/ServiceWorkerManager.cpp
  • dom/serviceworkers/ServiceWorkerPrivate.cpp
  • dom/serviceworkers/ServiceWorkerRegisterJob.cpp
  • dom/serviceworkers/ServiceWorkerRegisterJob.h
  • dom/serviceworkers/ServiceWorkerRegistrationInfo.h
  • dom/workers/ScriptLoader.cpp
  • dom/workers/ScriptLoader.h
  • dom/workers/WorkerLoadInfo.h
  • dom/workers/WorkerPrivate.cpp
  • dom/workers/WorkerPrivate.h
  • dom/workers/remoteworkers/RemoteWorkerChild.cpp
  • ipc/glue/PBackgroundSharedTypes.ipdlh
  • netwerk/base/LNAPermissionRequest.cpp
  • netwerk/base/LoadInfo.cpp
  • netwerk/protocol/http/nsHttpChannel.cpp
  • netwerk/test/browser/browser.toml
  • netwerk/test/browser/browser_test_lna_worker.js
  • netwerk/test/browser/fetch_from_tracker.sjs
  • netwerk/test/browser/service_worker_lna.sjs
  • netwerk/test/browser/shared_worker_lna.js
  • netwerk/test/browser/worker_lna.js

Audit Directions

  • Inheritable security context in workerless contexts
    Audit every security property gated on a browsing context (CSP, referrer, sandbox flags, address space) to confirm it is carried on the PolicyContainer and reaches Service Worker, shared worker, and dedicated worker contexts.
  • Serialization version compatibility
    When a serialized security type gains a field, verify the version is bumped and both readers and writers agree on the range, so a version mismatch fails closed rather than dropping the new field.
  • IPC and clone completeness
    For any new security-relevant member, check that ToArgs/FromArgs, InitFromOther, and Equals all include it, since an omission silently drops the restriction across a process or context boundary.
diff --git a/dom/base/Document.cpp b/dom/base/Document.cpp
index 040e07c63f8..e0a568f6c08 100644
--- a/dom/base/Document.cpp
+++ b/dom/base/Document.cpp
@@ -3761,6 +3761,15 @@ nsresult Document::InitPolicyContainer(nsIChannel* aChannel) {
     mPolicyContainer = new PolicyContainer();
   }
 
+  // Propagate the document's IP address space to the policy container so that
+  // workers inheriting this container can perform Local Network Access checks
+  // (workers don't have a browsing context to read this from).
+  nsCOMPtr<nsILoadInfo> loadInfo = aChannel->LoadInfo();
+  nsILoadInfo::IPAddressSpace ipAddressSpace = loadInfo->GetIpAddressSpace();
+  if (ipAddressSpace != nsILoadInfo::Unknown) {
+    mPolicyContainer->SetIPAddressSpace(ipAddressSpace);
+  }
+
   return NS_OK;
 }
 
diff --git a/dom/security/PolicyContainer.cpp b/dom/security/PolicyContainer.cpp
index 42fb86b8d1d..59e62a4a144 100644
--- a/dom/security/PolicyContainer.cpp
+++ b/dom/security/PolicyContainer.cpp
@@ -18,15 +18,14 @@ using namespace mozilla::dom;
 PolicyContainer::PolicyContainer() = default;
 PolicyContainer::~PolicyContainer() = default;
 
-constexpr static uint32_t kPolicyContainerSerializationVersion = 1;
+constexpr static uint32_t kPolicyContainerSerializationVersion = 2;
 
 NS_IMETHODIMP
 PolicyContainer::Read(nsIObjectInputStream* aStream) {
-  // Currently, we don't care about the version, but we might in the future.
   uint32_t version = 0;
   MOZ_TRY(aStream->Read32(&version));
 
-  if (version != kPolicyContainerSerializationVersion) {
+  if (version < 1 || version > kPolicyContainerSerializationVersion) {
     return NS_ERROR_FAILURE;
   }
 
@@ -61,6 +60,13 @@ PolicyContainer::Read(nsIObjectInputStream* aStream) {
   MOZ_TRY(
       NS_ReadOptionalObject(aStream, true, getter_AddRefs(integrityPolicy)));
   mIntegrityPolicy = do_QueryInterface(integrityPolicy);
+
+  if (version >= 2) {
+    uint16_t ipAS = 0;
+    MOZ_TRY(aStream->Read16(&ipAS));
+    mIPAddressSpace = static_cast<nsILoadInfo::IPAddressSpace>(ipAS);
+  }
+
   return NS_OK;
 }
 
@@ -77,6 +83,8 @@ PolicyContainer::Write(nsIObjectOutputStream* aStream) {
   // TODO(Bug 2017654): (De)Serialize the WAICT state as part of the
   // Policy-Container
 
+  MOZ_TRY(aStream->Write16(static_cast<uint16_t>(mIPAddressSpace)));
+
   return NS_OK;
 }
 
@@ -98,6 +106,8 @@ void PolicyContainer::ToArgs(const PolicyContainer* aPolicy,
                             integrityPolicyArgs);
     aArgs.integrityPolicy() = Some(integrityPolicyArgs);
   }
+
+  aArgs.ipAddressSpace() = aPolicy->mIPAddressSpace;
 }
 
 void PolicyContainer::FromArgs(const mozilla::ipc::PolicyContainerArgs& aArgs,
@@ -118,6 +128,8 @@ void PolicyContainer::FromArgs(const mozilla::ipc::PolicyContainerArgs& aArgs,
     policy->SetIntegrityPolicy(integrityPolicy);
   }
 
+  policy->SetIPAddressSpace(aArgs.ipAddressSpace());
+
   policy.forget(aPolicy);
 }
 
@@ -139,6 +151,8 @@ void PolicyContainer::InitFromOther(PolicyContainer* aOther) {
         IntegrityPolicy::Cast(aOther->mIntegrityPolicy));
     mIntegrityPolicy = integrityPolicy;
   }
+
+  mIPAddressSpace = aOther->mIPAddressSpace;
 }
 
 NS_IMETHODIMP PolicyContainer::InitFromCSP(nsIContentSecurityPolicy* aCSP) {
@@ -169,6 +183,10 @@ bool PolicyContainer::Equals(const PolicyContainer* aContainer,
     return false;
   }
 
+  if (aContainer->mIPAddressSpace != aOtherContainer->mIPAddressSpace) {
+    return false;
+  }
+
   // TODO(Bug 2017654): Handle equality for WAICT.
 
   return true;
@@ -223,6 +241,16 @@ IntegrityPolicyWAICT* PolicyContainer::GetIntegrityPolicyWAICT(
   return PolicyContainer::Cast(aPolicyContainer)->GetIntegrityPolicyWAICT();
 }
 
+// == IP Address Space ==
+nsILoadInfo::IPAddressSpace PolicyContainer::GetIPAddressSpace() const {
+  return mIPAddressSpace;
+}
+
+void PolicyContainer::SetIPAddressSpace(
+    nsILoadInfo::IPAddressSpace aIPAddressSpace) {
+  mIPAddressSpace = aIPAddressSpace;
+}
+
 NS_IMETHODIMP PolicyContainer::GetCsp(nsIContentSecurityPolicy** aCsp) {
   nsCOMPtr<nsIContentSecurityPolicy> csp = mCSP;
   csp.forget(aCsp);
diff --git a/dom/security/PolicyContainer.h b/dom/security/PolicyContainer.h
index 6e40023ab9b..ff17edd44f7 100644
--- a/dom/security/PolicyContainer.h
+++ b/dom/security/PolicyContainer.h
@@ -8,6 +8,7 @@
 #include "nsCOMPtr.h"
 #include "nsIContentSecurityPolicy.h"
 #include "nsIIntegrityPolicy.h"
+#include "nsILoadInfo.h"
 #include "nsIPolicyContainer.h"
 
 namespace mozilla::ipc {
@@ -77,10 +78,18 @@ class PolicyContainer : public nsIPolicyContainer {
   static mozilla::dom::IntegrityPolicyWAICT* GetIntegrityPolicyWAICT(
       const nsIPolicyContainer* aPolicyContainer);
 
+  // == IP Address Space ==
+  // Stored per https://wicg.github.io/local-network-access/#integration-with-html
+  // to allow worker contexts (which have no browsing context) to perform
+  // Local Network Access checks against their parent document's address space.
+  nsILoadInfo::IPAddressSpace GetIPAddressSpace() const;
+  void SetIPAddressSpace(nsILoadInfo::IPAddressSpace aIPAddressSpace);
+
  private:
   nsCOMPtr<nsIContentSecurityPolicy> mCSP;
   nsCOMPtr<nsIIntegrityPolicy> mIntegrityPolicy;
   RefPtr<mozilla::dom::IntegrityPolicyWAICT> mIntegrityPolicyWAICT;
+  nsILoadInfo::IPAddressSpace mIPAddressSpace = nsILoadInfo::Unknown;
 
  protected:
   virtual ~PolicyContainer();
diff --git a/dom/serviceworkers/ServiceWorkerManager.cpp b/dom/serviceworkers/ServiceWorkerManager.cpp
index 069635c7d35..2ee2a2caae5 100644
--- a/dom/serviceworkers/ServiceWorkerManager.cpp
+++ b/dom/serviceworkers/ServiceWorkerManager.cpp
@@ -930,9 +930,17 @@ RefPtr<ServiceWorkerRegistrationPromise> ServiceWorkerManager::Register(
 
   auto lifetime = DetermineLifetimeForClient(aClientInfo);
 
+  uint16_t ipAddressSpace = 0;
+  auto policyContainerArgs = aClientInfo.GetPolicyContainerArgs();
+  if (policyContainerArgs.isSome()) {
+    ipAddressSpace =
+        static_cast<uint16_t>(policyContainerArgs->ipAddressSpace());
+  }
+
   RefPtr<ServiceWorkerRegisterJob> job = new ServiceWorkerRegisterJob(
       principal, aScopeURL, aType, aScriptURL,
-      static_cast<ServiceWorkerUpdateViaCache>(aUpdateViaCache), lifetime);
+      static_cast<ServiceWorkerUpdateViaCache>(aUpdateViaCache), lifetime,
+      ipAddressSpace);
 
   job->AppendResultCallback(cb);
   queue->ScheduleJob(job);
diff --git a/dom/serviceworkers/ServiceWorkerPrivate.cpp b/dom/serviceworkers/ServiceWorkerPrivate.cpp
index fc9fe3a0306..c2d4023ebff 100644
--- a/dom/serviceworkers/ServiceWorkerPrivate.cpp
+++ b/dom/serviceworkers/ServiceWorkerPrivate.cpp
@@ -28,6 +28,7 @@
 #include "mozilla/StaticPrefs_privacy.h"
 #include "mozilla/StoragePrincipalHelper.h"
 #include "mozilla/dom/Client.h"
+#include "mozilla/ipc/PBackgroundSharedTypes.h"
 #include "mozilla/dom/ClientIPCTypes.h"
 #include "mozilla/dom/ClientManager.h"
 #include "mozilla/dom/DOMTypes.h"
@@ -736,6 +737,11 @@ nsresult ServiceWorkerPrivate::Initialize() {
   mClientInfo->SetURL(mInfo->ScriptSpec());
   mClientInfo->SetFrameType(FrameType::None);
 
+  // Set the IP address space from the registration for LNA checks.
+  mozilla::ipc::PolicyContainerArgs policyContainerArgs;
+  policyContainerArgs.ipAddressSpace() =
+      static_cast<nsILoadInfo::IPAddressSpace>(regInfo->GetIPAddressSpace());
+  mClientInfo->SetPolicyContainerArgs(policyContainerArgs);
   WorkerOptions workerOptions;
   workerOptions.mCredentials = RequestCredentials::Omit;
   workerOptions.mType = mInfo->Type();
diff --git a/dom/serviceworkers/ServiceWorkerRegisterJob.cpp b/dom/serviceworkers/ServiceWorkerRegisterJob.cpp
index 9f448097851..2d394129650 100644
--- a/dom/serviceworkers/ServiceWorkerRegisterJob.cpp
+++ b/dom/serviceworkers/ServiceWorkerRegisterJob.cpp
@@ -13,11 +13,13 @@ namespace mozilla::dom {
 ServiceWorkerRegisterJob::ServiceWorkerRegisterJob(
     nsIPrincipal* aPrincipal, const nsACString& aScope, const WorkerType& aType,
     const nsACString& aScriptSpec, ServiceWorkerUpdateViaCache aUpdateViaCache,
-    const ServiceWorkerLifetimeExtension& aLifetimeExtension)
+    const ServiceWorkerLifetimeExtension& aLifetimeExtension,
+    uint16_t aIPAddressSpace)
     : ServiceWorkerUpdateJob(Type::Register, aPrincipal, aScope,
                              nsCString(aScriptSpec), aUpdateViaCache,
                              aLifetimeExtension),
-      mType(aType) {}
+      mType(aType),
+      mIPAddressSpace(aIPAddressSpace) {}
 
 void ServiceWorkerRegisterJob::AsyncExecute() {
   MOZ_ASSERT(NS_IsMainThread());
@@ -41,7 +43,7 @@ void ServiceWorkerRegisterJob::AsyncExecute() {
         mType == registration->Type();
 
     registration->SetOptions(GetUpdateViaCache(), mType);
-
+    registration->SetIPAddressSpace(mIPAddressSpace);
     RefPtr<ServiceWorkerInfo> newest = registration->Newest();
     if (newest && mScriptSpec.Equals(newest->ScriptSpec()) && sameOptions) {
       SetRegistration(registration);
@@ -55,6 +57,7 @@ void ServiceWorkerRegisterJob::AsyncExecute() {
       FailUpdateJob(NS_ERROR_DOM_ABORT_ERR);
       return;
     }
+    registration->SetIPAddressSpace(mIPAddressSpace);
   }
 
   SetRegistration(registration);
diff --git a/dom/serviceworkers/ServiceWorkerRegisterJob.h b/dom/serviceworkers/ServiceWorkerRegisterJob.h
index 4fa978bf3ba..f9565eaa551 100644
--- a/dom/serviceworkers/ServiceWorkerRegisterJob.h
+++ b/dom/serviceworkers/ServiceWorkerRegisterJob.h
@@ -18,10 +18,12 @@ class ServiceWorkerRegisterJob final : public ServiceWorkerUpdateJob {
       nsIPrincipal* aPrincipal, const nsACString& aScope,
       const WorkerType& aType, const nsACString& aScriptSpec,
       ServiceWorkerUpdateViaCache aUpdateViaCache,
-      const ServiceWorkerLifetimeExtension& aLifetimeExtension);
+      const ServiceWorkerLifetimeExtension& aLifetimeExtension,
+      uint16_t aIPAddressSpace = 0);
 
  private:
   WorkerType mType;
+  uint16_t mIPAddressSpace;
 
   // Implement the Register algorithm steps and then call the parent class
   // Update() to complete the job execution.
diff --git a/dom/serviceworkers/ServiceWorkerRegistrationInfo.h b/dom/serviceworkers/ServiceWorkerRegistrationInfo.h
index 68b1ebdbf3f..ea04b9d96f6 100644
--- a/dom/serviceworkers/ServiceWorkerRegistrationInfo.h
+++ b/dom/serviceworkers/ServiceWorkerRegistrationInfo.h
@@ -71,6 +71,7 @@ class ServiceWorkerRegistrationInfo final
   int64_t mNumberOfAttemptedActivations{0};
   bool mIsBroken{false};
   int64_t mCacheAPIId{-1};
+  uint16_t mIPAddressSpace = 0;
 
  public:
   NS_DECL_ISUPPORTS
@@ -246,6 +247,15 @@ class ServiceWorkerRegistrationInfo final
 
   const nsID& AgentClusterId() const;
 
+  uint16_t GetIPAddressSpace() const { return mIPAddressSpace; }
+  void SetIPAddressSpace(uint16_t aIPAddressSpace) {
+    // Same-origin registrations must have the same IP address space.
+    MOZ_ASSERT(mIPAddressSpace == 0 || mIPAddressSpace == aIPAddressSpace,
+               "Unexpected IP address space mismatch for same-origin "
+               "service worker registration");
+    mIPAddressSpace = aIPAddressSpace;
+  }
+
   void SetNavigationPreloadEnabled(const bool& aEnabled);
 
   void SetNavigationPreloadHeader(const nsCString& aHeader);
diff --git a/dom/workers/ScriptLoader.cpp b/dom/workers/ScriptLoader.cpp
index ad1a6cfa737..c6d587f0f8c 100644
--- a/dom/workers/ScriptLoader.cpp
+++ b/dom/workers/ScriptLoader.cpp
@@ -837,7 +837,7 @@ void WorkerScriptLoader::MaybeMoveToLoadedList(ScriptLoadRequest* aRequest) {
   }
 }
 
-bool WorkerScriptLoader::StoreCSP() {
+bool WorkerScriptLoader::StorePolicyContainerArgs() {
   // We must be on the same worker as we started on.
   mWorkerRef->Private()->AssertIsOnWorkerThread();
 
@@ -847,9 +847,9 @@ bool WorkerScriptLoader::StoreCSP() {
 
   MOZ_ASSERT(!mRv.Failed());
 
-  // Move the CSP from the workerLoadInfo in the corresponding Client
-  // where the CSP code expects it!
-  mWorkerRef->Private()->StoreCSPOnClient();
+  // Store the policy container args (CSP, IP address space, etc.) from
+  // WorkerLoadInfo into the worker's ClientSource.
+  mWorkerRef->Private()->StorePolicyContainerArgsOnClient();
   return true;
 }
 
@@ -1704,7 +1704,7 @@ bool ScriptExecutorRunnable::PreRun(WorkerPrivate* aWorkerPrivate) {
     }
   }
 
-  return mScriptLoader->StoreCSP();
+  return mScriptLoader->StorePolicyContainerArgs();
 }
 
 bool ScriptExecutorRunnable::ProcessModuleScript(
diff --git a/dom/workers/ScriptLoader.h b/dom/workers/ScriptLoader.h
index f01eba39bd0..3d6e44cf66e 100644
--- a/dom/workers/ScriptLoader.h
+++ b/dom/workers/ScriptLoader.h
@@ -224,7 +224,7 @@ class WorkerScriptLoader : public JS::loader::ScriptLoaderInterface,
 
   void MaybeMoveToLoadedList(ScriptLoadRequest* aRequest);
 
-  bool StoreCSP();
+  bool StorePolicyContainerArgs();
 
   bool ProcessPendingRequests(JSContext* aCx);
 
diff --git a/dom/workers/WorkerLoadInfo.h b/dom/workers/WorkerLoadInfo.h
index bf7c267d078..c005b3f3f55 100644
--- a/dom/workers/WorkerLoadInfo.h
+++ b/dom/workers/WorkerLoadInfo.h
@@ -13,6 +13,7 @@
 #include "mozilla/dom/OffThreadCSPContext.h"
 #include "mozilla/dom/ServiceWorkerRegistrationDescriptor.h"
 #include "mozilla/dom/WorkerCommon.h"
+#include "mozilla/ipc/PBackgroundSharedTypes.h"
 #include "mozilla/net/NeckoChannelParams.h"
 #include "nsIInterfaceRequestor.h"
 #include "nsILoadContext.h"
@@ -70,6 +71,11 @@ struct WorkerLoadInfoData {
   nsCOMPtr<nsIContentSecurityPolicy> mCSP;
   UniquePtr<OffThreadCSPContext> mCSPContext;
 
+  // IP address space inherited from the parent document's policy container.
+  // Stored as uint16_t to avoid including nsILoadInfo.h in this header.
+  // Maps to nsILoadInfo::IPAddressSpace enum values.
+  uint16_t mIPAddressSpace = 0;  // nsILoadInfo::Unknown
+
   nsCOMPtr<nsIChannel> mChannel;
   nsCOMPtr<nsILoadGroup> mLoadGroup;
 
diff --git a/dom/workers/WorkerPrivate.cpp b/dom/workers/WorkerPrivate.cpp
index 0460a81a45a..7803ce88b4e 100644
--- a/dom/workers/WorkerPrivate.cpp
+++ b/dom/workers/WorkerPrivate.cpp
@@ -1493,15 +1493,23 @@ bool WorkerPrivate::IsFrozen() const {
   return mParentFrozen;
 }
 
-void WorkerPrivate::StoreCSPOnClient() {
+void WorkerPrivate::StorePolicyContainerArgsOnClient() {
   auto data = mWorkerThreadAccessible.Access();
   MOZ_ASSERT(data->mScope);
+  auto& clientSource = data->mScope->MutableClientSourceRef();
+
+  mozilla::ipc::PolicyContainerArgs policyContainerArgs;
+
   if (mLoadInfo.mCSPContext) {
-    mozilla::ipc::PolicyContainerArgs policyContainerArgs;
     policyContainerArgs.csp() = Some(mLoadInfo.mCSPContext->CSPInfo());
-    data->mScope->MutableClientSourceRef().SetPolicyContainerArgs(
-        policyContainerArgs);
   }
+
+  policyContainerArgs.ipAddressSpace() =
+      static_cast<nsILoadInfo::IPAddressSpace>(mLoadInfo.mIPAddressSpace);
+  // TODO(Bug 2017654): Check if integrity policy args need to be propagated
+  // to workers as well. Currently not set explicitly here.
+
+  clientSource.SetPolicyContainerArgs(policyContainerArgs);
 }
 
 void WorkerPrivate::UpdateReferrerInfoFromHeader(
@@ -3337,6 +3345,7 @@ nsresult WorkerPrivate::GetLoadInfo(
     loadInfo.mParentController = aParent->GlobalScope()->GetController();
     loadInfo.mWatchedByDevTools = aParent->IsWatchedByDevTools();
     loadInfo.mIsOn3PCBExceptionList = aParent->IsOn3PCBExceptionList();
+    loadInfo.mIPAddressSpace = aParent->mLoadInfo.mIPAddressSpace;
   } else {
     AssertIsOnMainThread();
 
@@ -3476,6 +3485,14 @@ nsresult WorkerPrivate::GetLoadInfo(
       loadInfo.mWindowID = globalWindow->WindowID();
       loadInfo.mAssociatedBrowsingContextID =
           globalWindow->GetBrowsingContext()->Id();
+
+      // Inherit the document's IP address space for Local Network Access
+      // checks in workers.
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/netwerk/test/browser/browser.toml b/netwerk/test/browser/browser.toml
index 64dd976b236..82ba3cdddd9 100644
--- a/netwerk/test/browser/browser.toml
+++ b/netwerk/test/browser/browser.toml
@@ -251,6 +251,17 @@ support-files = [
   "fetch_from_tracker.sjs",
 ]
 
+["browser_test_lna_worker.js"]
+support-files = [
+  "head_local_network_access.js",
+  "page_with_non_trackers.html",
+  "page_with_non_trackers.html^headers^",
+  "fetch_from_tracker.sjs",
+  "worker_lna.js",
+  "shared_worker_lna.js",
+  "service_worker_lna.sjs",
+]
+
 ["browser_test_local_network_access_feature_policy.js"]
 support-files = [
   "head_local_network_access.js",
diff --git a/netwerk/test/browser/browser_test_lna_worker.js b/netwerk/test/browser/browser_test_lna_worker.js
new file mode 100644
index 00000000000..c206cf1bec0
--- /dev/null
+++ b/netwerk/test/browser/browser_test_lna_worker.js
@@ -0,0 +1,141 @@
+"use strict";
+
+// Tests that Local Network Access (LNA) checks are enforced for requests
+// made from dedicated and shared workers.
+
+Services.scriptloader.loadSubScript(
+  new URL("head_local_network_access.js", gTestPath).href,
+  this
+);
+
+add_setup(async function () {
+  await setupLnaPrefs();
+  await SpecialPowers.pushPrefEnv({
+    set: [["dom.serviceWorkers.testing.enabled", true]],
+  });
+  await setupLnaServer();
+});
+
+requestLongerTimeout(5);
+
+const sharedWorkerTestCases = [
+  {
+    type: "shared-worker-fetch",
+    allowStatus: Cr.NS_OK,
+    denyStatus: Cr.NS_ERROR_LOCAL_NETWORK_ACCESS_DENIED,
+  },
+  {
+    type: "shared-worker-xhr",
+    allowStatus: Cr.NS_OK,
+    denyStatus: Cr.NS_ERROR_LOCAL_NETWORK_ACCESS_DENIED,
+  },
+];
+
+// Shared worker: denied without persistent permission (no prompt shown)
+add_task(async function test_lna_shared_worker_denied_without_permission() {
+  Services.prefs.setCharPref(
+    "network.lna.address_space.public.override",
+    "127.0.0.1:4443"
+  );
+  for (const test of sharedWorkerTestCases) {
+    const rand = Math.random();
+    await runSingleTestCase(
+      test,
+      rand,
+      test.denyStatus,
+      `Shared worker ${test.type} denied without persistent permission`
+    );
+  }
+  Services.prefs.clearUserPref("network.lna.address_space.public.override");
+});
+
+// Shared worker: allowed with persistent permission (no prompt shown)
+add_task(async function test_lna_shared_worker_allowed_with_permission() {
+  Services.prefs.setCharPref(
+    "network.lna.address_space.public.override",
+    "127.0.0.1:4443"
+  );
+
+  // Add persistent loopback-network permission for example.com
+  let principal =
+    Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+      "https://example.com"
+    );
+  Services.perms.addFromPrincipal(
+    principal,
+    "loopback-network",
+    Services.perms.ALLOW_ACTION,
+    Services.perms.EXPIRE_NEVER
+  );
+
+  for (const test of sharedWorkerTestCases) {
+    const rand = Math.random();
+    await runSingleTestCase(
+      test,
+      rand,
+      test.allowStatus,
+      `Shared worker ${test.type} allowed with persistent permission`
+    );
+  }
+
+  Services.perms.removeFromPrincipal(principal, "loopback-network");
+  Services.prefs.clearUserPref("network.lna.address_space.public.override");
+});
+
+const serviceWorkerTestCases = [
+  {
+    type: "service-worker-fetch",
+    allowStatus: Cr.NS_OK,
+    denyStatus: Cr.NS_ERROR_LOCAL_NETWORK_ACCESS_DENIED,
+  },
+];
+
+// Service worker: denied without persistent permission (no prompt shown)
+add_task(async function test_lna_service_worker_denied_without_permission() {
+  Services.prefs.setCharPref(
+    "network.lna.address_space.public.override",
+    "127.0.0.1:4443"
+  );
+  for (const test of serviceWorkerTestCases) {
+    const rand = Math.random();
+    await runSingleTestCase(
+      test,
+      rand,
+      test.denyStatus,
+      `Service worker ${test.type} denied without persistent permission`
+    );
+  }
+  Services.prefs.clearUserPref("network.lna.address_space.public.override");
+});
+
+// Service worker: allowed with persistent permission (no prompt shown)
+add_task(async function test_lna_service_worker_allowed_with_permission() {
+  Services.prefs.setCharPref(
+    "network.lna.address_space.public.override",
+    "127.0.0.1:4443"
+  );
+
+  let principal =
+    Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+      "https://example.com"
+    );
+  Services.perms.addFromPrincipal(
+    principal,
+    "loopback-network",
+    Services.perms.ALLOW_ACTION,
+    Services.perms.EXPIRE_NEVER
+  );
+
+  for (const test of serviceWorkerTestCases) {
+    const rand = Math.random();
+    await runSingleTestCase(
+      test,
+      rand,
+      test.allowStatus,
+      `Service worker ${test.type} allowed with persistent permission`
+    );
+  }
+
+  Services.perms.removeFromPrincipal(principal, "loopback-network");
+  Services.prefs.clearUserPref("network.lna.address_space.public.override");
+});
diff --git a/netwerk/test/browser/fetch_from_tracker.sjs b/netwerk/test/browser/fetch_from_tracker.sjs
index d7c052fe68e..017e48a4410 100644
--- a/netwerk/test/browser/fetch_from_tracker.sjs
+++ b/netwerk/test/browser/fetch_from_tracker.sjs
@@ -106,6 +106,57 @@ function handleRequest(request, response) {
     }
   `;
 
+  let workerFetchScript = `
+    results.workerFetch = "PENDING";
+    const w = new Worker("worker_lna.js");
+    w.postMessage({ type: "worker-fetch", rand: "${rand}" });
+    w.onmessage = (e) => results.workerFetch = e.data.status;
+    w.onerror = () => results.workerFetch = "FAIL";
+  `;
+
+  let workerXhrScript = `
+    results.workerXhr = "PENDING";
+    const w = new Worker("worker_lna.js");
+    w.postMessage({ type: "worker-xhr", rand: "${rand}" });
+    w.onmessage = (e) => results.workerXhr = e.data.status;
+    w.onerror = () => results.workerXhr = "FAIL";
+  `;
+
+  let sharedWorkerFetchScript = `
+    results.sharedWorkerFetch = "PENDING";
+    const sw = new SharedWorker("shared_worker_lna.js");
+    sw.port.start();
+    sw.port.postMessage({ type: "shared-worker-fetch", rand: "${rand}" });
+    sw.port.onmessage = (e) => results.sharedWorkerFetch = e.data.status;
+    sw.onerror = () => results.sharedWorkerFetch = "FAIL";
+  `;
+
+  let sharedWorkerXhrScript = `
+    results.sharedWorkerXhr = "PENDING";
+    const sw = new SharedWorker("shared_worker_lna.js");
+    sw.port.start();
+    sw.port.postMessage({ type: "shared-worker-xhr", rand: "${rand}" });
+    sw.port.onmessage = (e) => results.sharedWorkerXhr = e.data.status;
+    sw.onerror = () => results.sharedWorkerXhr = "FAIL";
+  `;
+
+  let serviceWorkerFetchScript = `
+    results.serviceWorkerFetch = "PENDING";
+    navigator.serviceWorker.register("service_worker_lna.sjs", { scope: "./" })
+      .then(reg => {
+        return navigator.serviceWorker.ready;
+      })
+      .then(reg => {
+        navigator.serviceWorker.onmessage = (e) => {
+          results.serviceWorkerFetch = e.data.status;
+        };
+        reg.active.postMessage({ type: "service-worker-fetch", rand: "${rand}" });
+      })
+      .catch((e) => {
+        results.serviceWorkerFetch = "FAIL";
+      });
+  `;
+
   switch (params.get("test")) {
     case "fetch":
       response.write(fetchScript);
@@ -137,6 +188,21 @@ function handleRequest(request, response) {
     case "websocket":
       response.write(websocketScript);
       return;
+    case "worker-fetch":
+      response.write(workerFetchScript);
+      return;
+    case "worker-xhr":
+      response.write(workerXhrScript);
+      return;
+    case "shared-worker-fetch":
+      response.write(sharedWorkerFetchScript);
+      return;
+    case "shared-worker-xhr":
+      response.write(sharedWorkerXhrScript);
+      return;
+    case "service-worker-fetch":
+      response.write(serviceWorkerFetchScript);
+      return;
   }
   response.write(`console.log("unknown test type")`);
 }
diff --git a/netwerk/test/browser/service_worker_lna.sjs b/netwerk/test/browser/service_worker_lna.sjs
new file mode 100644
index 00000000000..e4713f57b48
--- /dev/null
+++ b/netwerk/test/browser/service_worker_lna.sjs
@@ -0,0 +1,30 @@
+"use strict";
+
+function handleRequest(request, response) {
+  response.setHeader("Content-Type", "application/javascript", false);
+  response.write(`\
+self.addEventListener("install", function () {
+  self.skipWaiting();
+});
+self.addEventListener("activate", function (e) {
+  e.waitUntil(self.clients.claim());
+});
+self.addEventListener("message", function (e) {
+  var data = e.data;
+  var url = "http://localhost:21555/?type=" + data.type + "&rand=" + data.rand;
+  fetch(url).then(function () {
+    return self.clients.matchAll({ includeUncontrolled: true, type: "window" });
+  }).then(function (clients) {
+    for (var i = 0; i < clients.length; i++) {
+      clients[i].postMessage({ status: "OK" });
+    }
+  }).catch(function (ex) {
+    self.clients.matchAll({ includeUncontrolled: true, type: "window" }).then(function (clients) {
+      for (var i = 0; i < clients.length; i++) {
+        clients[i].postMessage({ status: "FAIL", error: ex.message });
+      }
+    });
+  });
+});
+`);
+}
diff --git a/netwerk/test/browser/shared_worker_lna.js b/netwerk/test/browser/shared_worker_lna.js
new file mode 100644
index 00000000000..cc61fc8c8bd
--- /dev/null
+++ b/netwerk/test/browser/shared_worker_lna.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
... (truncated)
Loading diff…