Low firefox Logic Error 🔧 Commit mapped

Overview

Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionInformation disclosure in the Networking: Cache component
ComponentNetworking
Bug ClassLogic Error
Tracker2050313
Fix commit27557128682e (firefox) +435/-134
CISA KEVNot listed
CreditedMohamed Mbarek
Disclosed2026-10-13

Changed Functions

FunctionChangeNotes
if
netwerk/cache2/CacheIndex.cpp
modified
CorruptDictionaryHash
netwerk/cache2/CacheStorageService.cpp
modified
if
netwerk/cache2/CacheStorageService.cpp
modified
ClearDictionaryDataForTesting
netwerk/cache2/CacheStorageService.cpp
modified
if
netwerk/cache2/Dictionary.cpp
modified
DictionaryCacheEntry
netwerk/cache2/Dictionary.cpp
modified

Files Changed

  • netwerk/cache2/CacheEntry.cpp
  • netwerk/cache2/CacheIndex.cpp
  • netwerk/cache2/CacheStorageService.cpp
  • netwerk/cache2/Dictionary.cpp
  • netwerk/cache2/Dictionary.h
  • netwerk/cache2/nsICacheTesting.idl
  • netwerk/protocol/http/nsHttpChannel.cpp
  • netwerk/protocol/http/nsHttpHandler.cpp
  • netwerk/test/unit/test_content_decoding_error_telemetry.js
  • netwerk/test/unit/test_dictionary_oa_isolation.js
  • netwerk/test/unit/test_dictionary_replacement.js
  • netwerk/test/unit/xpcshell.toml
diff --git a/netwerk/cache2/CacheEntry.cpp b/netwerk/cache2/CacheEntry.cpp
index 16f248bddf7..c94b07ea23b 100644
--- a/netwerk/cache2/CacheEntry.cpp
+++ b/netwerk/cache2/CacheEntry.cpp
@@ -1864,13 +1864,13 @@ void CacheEntry::DoomAlreadyRemoved() {
   // Remove from DictionaryCache immediately, to ensure the removal is
   // synchronous
   LOG(("DoomAlreadyRemoved [entry=%p removed]", this));
-  // These are keyed on the same ascii spec the entry itself is keyed on.
   nsAutoCString uriSpec;
   mURI->GetAsciiSpec(uriSpec);
+  nsCOMPtr<nsILoadContextInfo> lci = CacheFileUtils::ParseKey(mStorageID);
   if (mEnhanceID.EqualsLiteral("dict:")) {
-    DictionaryCache::RemoveOriginFor(uriSpec);
+    DictionaryCache::RemoveOriginFor(uriSpec, lci);
   } else {
-    DictionaryCache::RemoveDictionaryOMT(uriSpec);
+    DictionaryCache::RemoveDictionaryOMT(uriSpec, lci);
   }
 
   // Pretend pinning is know.  This entry is now doomed for good, so don't
diff --git a/netwerk/cache2/CacheIndex.cpp b/netwerk/cache2/CacheIndex.cpp
index e0336db3643..a73f057ac2d 100644
--- a/netwerk/cache2/CacheIndex.cpp
+++ b/netwerk/cache2/CacheIndex.cpp
@@ -897,7 +897,12 @@ nsresult CacheIndex::RemoveEntry(const SHA1Sum::Hash* aHash,
   // CacheFileContextEvictor purges entries; they've already been cleared
   // via CacheIndex::EvictByContext synchronously
   if (aClearDictionary) {
-    DictionaryCache::RemoveDictionaryOMT(aKey);
+    nsAutoCString uriSpec;
+    nsCOMPtr<nsILoadContextInfo> lci =
+        CacheFileUtils::ParseKey(aKey, nullptr, &uriSpec);
+    if (lci) {
+      DictionaryCache::RemoveDictionaryOMT(uriSpec, lci);
+    }
   }
 
   StaticMutexAutoLock lock(sLock);
diff --git a/netwerk/cache2/CacheStorageService.cpp b/netwerk/cache2/CacheStorageService.cpp
index 2be380aafab..826584e7b03 100644
--- a/netwerk/cache2/CacheStorageService.cpp
+++ b/netwerk/cache2/CacheStorageService.cpp
@@ -12,10 +12,13 @@
 #include "CacheLog.h"
 #include "CacheObserver.h"
 #include "CacheStorage.h"
+#include "Dictionary.h"
 #include "ErrorList.h"
+#include "LoadContextInfo.h"
 #include "mozilla/AtomicBitfields.h"
 #include "mozilla/DebugOnly.h"
 #include "mozilla/IntegerPrintfMacros.h"
+#include "mozilla/OriginAttributes.h"
 #include "mozilla/Services.h"
 #include "mozilla/StaticPrefs_network.h"
 #include "mozilla/StoragePrincipalHelper.h"
@@ -2632,23 +2635,37 @@ CacheStorageService::ClearDictionaryCacheMemory() {
 }
 
 NS_IMETHODIMP
-CacheStorageService::CorruptDictionaryHash(const nsACString& aURI) {
+CacheStorageService::CorruptDictionaryHash(
+    const nsACString& aURI, JS::Handle<JS::Value> aOriginAttributes,
+    JSContext* aCx) {
   LOG(("CacheStorageService::CorruptDictionaryHash [uri=%s]",
        PromiseFlatCString(aURI).get()));
+  OriginAttributes attrs;
+  if (!attrs.Init(aCx, aOriginAttributes)) {
+    return NS_ERROR_INVALID_ARG;
+  }
   RefPtr<DictionaryCache> cache = DictionaryCache::GetInstance();
   if (cache) {
-    cache->CorruptHashForTesting(aURI);
+    RefPtr<LoadContextInfo> lci = GetLoadContextInfo(false, attrs);
+    cache->CorruptHashForTesting(aURI, lci);
   }
   return NS_OK;
 }
 
 NS_IMETHODIMP
-CacheStorageService::ClearDictionaryDataForTesting(const nsACString& aURI) {
+CacheStorageService::ClearDictionaryDataForTesting(
+    const nsACString& aURI, JS::Handle<JS::Value> aOriginAttributes,
+    JSContext* aCx) {
   LOG(("CacheStorageService::ClearDictionaryDataForTesting [uri=%s]",
        PromiseFlatCString(aURI).get()));
+  OriginAttributes attrs;
+  if (!attrs.Init(aCx, aOriginAttributes)) {
+    return NS_ERROR_INVALID_ARG;
+  }
   RefPtr<DictionaryCache> cache = DictionaryCache::GetInstance();
   if (cache) {
-    cache->ClearDictionaryDataForTesting(aURI);
+    RefPtr<LoadContextInfo> lci = GetLoadContextInfo(false, attrs);
+    cache->ClearDictionaryDataForTesting(aURI, lci);
   }
   return NS_OK;
 }
diff --git a/netwerk/cache2/Dictionary.cpp b/netwerk/cache2/Dictionary.cpp
index e9c541ff726..03496b824ad 100644
--- a/netwerk/cache2/Dictionary.cpp
+++ b/netwerk/cache2/Dictionary.cpp
@@ -78,7 +78,6 @@ LazyLogModule gDictionaryLog("CompressionDictionaries");
  * Reference to the DictionaryCache singleton. May be null.
  */
 StaticRefPtr<DictionaryCache> gDictionaryCache;
-StaticRefPtr<nsICacheStorage> DictionaryCache::sCacheStorage;
 Atomic<bool, Relaxed> DictionaryCache::sShutdown{false};
 
 // about:cache gets upset about entries that don't fit URL specs, so we need
@@ -91,6 +90,36 @@ static nsresult GetDictPath(nsIURI* aURI, nsACString& aPrePath) {
   return NS_OK;
 }
 
+// static
+void DictionaryCache::MakeCacheKey(const nsACString& aPrePath,
+                                   nsILoadContextInfo* aLoadContextInfo,
+                                   nsACString& aKey) {
+  aKey.Truncate();
+  if (aLoadContextInfo) {
+    if (aLoadContextInfo->IsAnonymous()) {
+      aKey.AppendLiteral("A");
+    }
+    aLoadContextInfo->OriginAttributesPtr()->CreateSuffix(aKey);
+  }
+  aKey.Append(aPrePath);
+}
+
+// static
+already_AddRefed<nsICacheStorage> DictionaryCache::GetCacheStorage(
+    nsILoadContextInfo* aLoadContextInfo) {
+  nsCOMPtr<nsICacheStorageService> cacheStorageService(
+      components::CacheStorage::Service());
+  if (!cacheStorageService) {
+    return nullptr;
+  }
+  nsCOMPtr<nsICacheStorage> storage;
+  if (NS_FAILED(cacheStorageService->DiskCacheStorage(
+          aLoadContextInfo, getter_AddRefs(storage)))) {
+    return nullptr;
+  }
+  return storage.forget();
+}
+
 DictionaryCacheEntry::DictionaryCacheEntry(const char* aKey) {
   mURI = aKey;
   DICTIONARY_LOG(("Created DictionaryCacheEntry %p, uri=%s", this, aKey));
@@ -287,6 +316,16 @@ nsresult DictionaryCacheEntry::Prefetch(
 
   DICTIONARY_LOG(("Prefetch for %s", mURI.get()));
 
+  // Refuse to serve dictionary bytes to a request from a different
+  // partition than the one this entry was added under.
+  if (mLoadContextInfo && aLoadContextInfo &&
+      !mLoadContextInfo->Equals(aLoadContextInfo)) {
+    DICTIONARY_LOG(
+        ("Prefetch for %s - refusing cross-partition access", mURI.get()));
+    aShouldSuspend = false;
+    return NS_ERROR_FAILURE;
+  }
+
   // Determine private browsing status for this request
   bool isPrivateBrowsing = aLoadContextInfo && aLoadContextInfo->IsPrivate();
 
@@ -314,18 +353,9 @@ nsresult DictionaryCacheEntry::Prefetch(
   // already. Add to waiting list.
   mWaitingPrefetch.push_back(PrefetchRequest{aFunc, isPrivateBrowsing});
 
-  // We can't use sCacheStorage because we need the correct LoadContextInfo
-  nsCOMPtr<nsICacheStorageService> cacheStorageService(
-      components::CacheStorage::Service());
-  if (!cacheStorageService) {
-    mWaitingPrefetch.clear();
-    aShouldSuspend = false;
-    return NS_ERROR_FAILURE;
-  }
-  nsCOMPtr<nsICacheStorage> cacheStorage;
-  nsresult rv = cacheStorageService->DiskCacheStorage(
-      aLoadContextInfo, getter_AddRefs(cacheStorage));
-  if (NS_FAILED(rv)) {
+  nsCOMPtr<nsICacheStorage> cacheStorage =
+      DictionaryCache::GetCacheStorage(aLoadContextInfo);
+  if (!cacheStorage) {
     mWaitingPrefetch.clear();
     aShouldSuspend = false;
     return NS_ERROR_FAILURE;
@@ -820,7 +850,8 @@ DictionaryCacheEntry::OnStopRequest(nsIRequest* request, nsresult result) {
         self->mStopReceived = true;
         if (shouldRemoveDictionary) {
           // Already on MainThread
-          DictionaryCache::RemoveDictionary(self->mURI);
+          DictionaryCache::RemoveDictionary(self->mURI,
+                                            self->GetLoadContextInfo());
         }
       });
   NS_DispatchToMainThread(runnable);
@@ -837,6 +868,11 @@ void DictionaryCacheEntry::UnblockAddEntry(DictionaryOrigin* aOrigin) {
   mBlocked = false;
 }
 
+void DictionaryCacheEntry::SetOrigin(DictionaryOrigin* aOrigin) {
+  mOrigin = aOrigin;
+  mLoadContextInfo = aOrigin ? aOrigin->GetLoadContextInfo() : nullptr;
+}
+
 void DictionaryCacheEntry::WriteOnHash() {
   MOZ_ASSERT(NS_IsMainThread());
   if (!mHash.IsEmpty() && mOrigin) {
@@ -900,12 +936,13 @@ DictionaryCacheEntry::OnCacheEntryAvailable(nsICacheEntry* entry, bool isNew,
     entry->OpenInputStream(0, getter_AddRefs(stream));
     if (!stream) {
       DICTIONARY_LOG(("OpenInputStream failed for %s", mURI.get()));
-      nsCOMPtr<nsIRunnable> runnable = NS_NewRunnableFunction(
-          "DictionaryCacheEntry::OnCacheEntryAvailable",
-          [self = RefPtr{this}]() {
-            self->CleanupOnCacheData(NS_ERROR_FAILURE);
-            DictionaryCache::RemoveDictionary(self->mURI);
-          });
+      nsCOMPtr<nsIRunnable> runnable =
+          NS_NewRunnableFunction("DictionaryCacheEntry::OnCacheEntryAvailable",
+                                 [self = RefPtr{this}]() {
+                                   self->CleanupOnCacheData(NS_ERROR_FAILURE);
+                                   DictionaryCache::RemoveDictionary(
+                                       self->mURI, self->GetLoadContextInfo());
+                                 });
       NS_DispatchToMainThread(runnable);
       return NS_OK;
     }
@@ -918,23 +955,25 @@ DictionaryCacheEntry::OnCacheEntryAvailable(nsICacheEntry* entry, bool isNew,
     nsresult rv = nsInputStreamPump::Create(getter_AddRefs(pump), stream);
     if (NS_FAILED(rv)) {
       DICTIONARY_LOG(("nsInputStreamPump::Create failed for %s", mURI.get()));
-      NS_DispatchToMainThread(NS_NewRunnableFunction(
-          "DictionaryCacheEntry::OnCacheEntryAvailable",
-          [self = RefPtr{this}]() {
-            self->CleanupOnCacheData(NS_ERROR_FAILURE);
-            DictionaryCache::RemoveDictionary(self->mURI);
-          }));
+      NS_DispatchToMainThread(
+          NS_NewRunnableFunction("DictionaryCacheEntry::OnCacheEntryAvailable",
+                                 [self = RefPtr{this}]() {
+                                   self->CleanupOnCacheData(NS_ERROR_FAILURE);
+                                   DictionaryCache::RemoveDictionary(
+                                       self->mURI, self->GetLoadContextInfo());
+                                 }));
       return NS_OK;
     }
     rv = pump->AsyncRead(this);
     if (NS_FAILED(rv)) {
       DICTIONARY_LOG(("AsyncRead failed for %s", mURI.get()));
-      NS_DispatchToMainThread(NS_NewRunnableFunction(
-          "DictionaryCacheEntry::OnCacheEntryAvailable",
-          [self = RefPtr{this}]() {
-            self->CleanupOnCacheData(NS_ERROR_FAILURE);
-            DictionaryCache::RemoveDictionary(self->mURI);
-          }));
+      NS_DispatchToMainThread(
+          NS_NewRunnableFunction("DictionaryCacheEntry::OnCacheEntryAvailable",
+                                 [self = RefPtr{this}]() {
+                                   self->CleanupOnCacheData(NS_ERROR_FAILURE);
+                                   DictionaryCache::RemoveDictionary(
+                                       self->mURI, self->GetLoadContextInfo());
+                                 }));
       return NS_OK;
     }
     DICTIONARY_LOG(("Waiting for data"));
@@ -950,7 +989,8 @@ DictionaryCacheEntry::OnCacheEntryAvailable(nsICacheEntry* entry, bool isNew,
         "DictionaryCacheEntry::OnCacheEntryAvailable",
         [self = RefPtr{this}, uriCopy]() {
           self->CleanupOnCacheData(NS_ERROR_CORRUPTED_CONTENT);
-          DictionaryCache::RemoveDictionary(self->mURI);
+          DictionaryCache::RemoveDictionary(self->mURI,
+                                            self->GetLoadContextInfo());
         });
     NS_DispatchToMainThread(runnable);
   }
@@ -964,7 +1004,8 @@ DictionaryCacheEntry::OnCacheEntryAvailable(nsICacheEntry* entry, bool isNew,
 // as needed. If aType is TYPE_OTHER, there is no Match() to do
 void DictionaryOriginReader::Start(
     bool aCreate, DictionaryOrigin* aOrigin, nsACString& aKey, nsIURI* aURI,
-    ExtContentPolicyType aType, DictionaryCache* aCache,
+    ExtContentPolicyType aType, nsILoadContextInfo* aLoadContextInfo,
+    DictionaryCache* aCache,
     const std::function<nsresult(bool, DictionaryCacheEntry*)>& aCallback) {
   mOrigin = aOrigin;
   mURI = aURI;
@@ -985,14 +1026,21 @@ void DictionaryOriginReader::Start(
   if (mOrigin->mWaitingCacheRead.Length() == 1) {  // was empty
     DICTIONARY_LOG(("DictionaryOriginReader::Start(%s): %p",
                     PromiseFlatCString(aKey).get(), this));
-    DictionaryCache::sCacheStorage->AsyncOpenURIString(
-        aKey, META_DICTIONARY_PREFIX,
-        aCreate
-            ? nsICacheStorage::OPEN_NORMALLY |
-                  nsICacheStorage::CHECK_MULTITHREADED
-            : nsICacheStorage::OPEN_READONLY | nsICacheStorage::OPEN_SECRETLY |
-                  nsICacheStorage::CHECK_MULTITHREADED,
-        this);
+    nsCOMPtr<nsICacheStorage> storage =
+        DictionaryCache::GetCacheStorage(aLoadContextInfo);
+    if (!storage) {
+      mOrigin->mWaitingCacheRead.Clear();
+      (aCallback)(true, nullptr);
+      return;
+    }
+    storage->AsyncOpenURIString(aKey, META_DICTIONARY_PREFIX,
+                                aCreate
+                                    ? nsICacheStorage::OPEN_NORMALLY |
+                                          nsICacheStorage::CHECK_MULTITHREADED
+                                    : nsICacheStorage::OPEN_READONLY |
+                                          nsICacheStorage::OPEN_SECRETLY |
+                                          nsICacheStorage::CHECK_MULTITHREADED,
+                                this);
     // This one will get the direct callback to do Match()
   }
   // Else we already have a read for this cache entry pending, just wait
@@ -1116,13 +1164,6 @@ nsresult DictionaryCache::Init() {
     if (!cacheStorageService) {
       return NS_ERROR_FAILURE;
     }
-    nsCOMPtr<nsICacheStorage> temp;
-    nsresult rv = cacheStorageService->DiskCacheStorage(
-        nullptr, getter_AddRefs(temp));  // Don't need a load context
-    if (NS_FAILED(rv)) {
-      return rv;
-    }
-    sCacheStorage = temp;
 
     nsCOMPtr<nsIObserverService> obsService =
         mozilla::services::GetObserverService();
@@ -1130,7 +1171,7 @@ nsresult DictionaryCache::Init() {
       obsService->AddObserver(this, "idle-daily", false);
     }
   }
-  DICTIONARY_LOG(("Inited DictionaryCache %p", sCacheStorage.get()));
+  DICTIONARY_LOG(("Inited DictionaryCache"));
   return NS_OK;
 }
 
@@ -1145,7 +1186,6 @@ void DictionaryCache::Shutdown() {
     }
   }
   gDictionaryCache = nullptr;
-  sCacheStorage = nullptr;
 }
 
 NS_IMETHODIMP
@@ -1165,13 +1205,11 @@ DictionaryCache::Observe(nsISupports* subject, const char* topic,
 
 NS_IMPL_ISUPPORTS(DictionaryCache, nsIObserver)
 
-nsresult DictionaryCache::AddEntry(nsIURI* aURI, const nsACString& aKey,
-                                   const nsACString& aPattern,
-                                   nsTArray<nsCString>& aMatchDest,
-                                   const nsACString& aId,
-                                   const Maybe<nsCString>& aHash,
-                                   bool aNewEntry, uint32_t aExpiration,
-                                   DictionaryCacheEntry** aDictEntry) {
+nsresult DictionaryCache::AddEntry(
+    nsIURI* aURI, const nsACString& aKey, const nsACString& aPattern,
+    nsTArray<nsCString>& aMatchDest, const nsACString& aId,
+    const Maybe<nsCString>& aHash, bool aNewEntry, uint32_t aExpiration,
+    nsILoadContextInfo* aLoadContextInfo, DictionaryCacheEntry** aDictEntry) {
   // Note that normally we're getting an entry in and until all the data
   // has been received, we can't use it.  The Hash being null is a flag
   // that it's not yet valid.
@@ -1182,7 +1220,7 @@ nsresult DictionaryCache::AddEntry(nsIURI* aURI, const nsACString& aKey,
   // Note that we don't know if there's an entry for this key in the origin
   RefPtr<DictionaryCacheEntry> dict = new DictionaryCacheEntry(
       aKey, aPattern, aMatchDest, aId, aExpiration, aHash);
-  dict = AddEntry(aURI, aNewEntry, dict);
+  dict = AddEntry(aURI, aNewEntry, aLoadContextInfo, dict);
   if (dict) {
     *aDictEntry = do_AddRef(dict).take();
     return NS_OK;
@@ -1194,7 +1232,8 @@ nsresult DictionaryCache::AddEntry(nsIURI* aURI, const nsACString& aKey,
 }
 
 already_AddRefed<DictionaryCacheEntry> DictionaryCache::AddEntry(
-    nsIURI* aURI, bool aNewEntry, DictionaryCacheEntry* aDictEntry) {
+    nsIURI* aURI, bool aNewEntry, nsILoadContextInfo* aLoadContextInfo,
+    DictionaryCacheEntry* aDictEntry) {
   // Note that normally we're getting an entry in and until all the data
   // has been received, we can't use it.  The Hash being null is a flag
   // that it's not yet valid.
@@ -1202,26 +1241,32 @@ already_AddRefed<DictionaryCacheEntry> DictionaryCache::AddEntry(
   if (NS_FAILED(GetDictPath(aURI, prepath))) {
     return nullptr;
   }
-  DICTIONARY_LOG(
-      ("AddEntry: %s, %d, %p", prepath.get(), aNewEntry, aDictEntry));
+  nsCString mapKey;
+  MakeCacheKey(prepath, aLoadContextInfo, mapKey);
+  DICTIONARY_LOG(("AddEntry: %s, %d, %p", mapKey.get(), aNewEntry, aDictEntry));
+  // SetOrigin() below only runs when the origin is newly created.
+  aDictEntry->SetLoadContextInfo(aLoadContextInfo);
   // create for the origin if it doesn't exist
   RefPtr<DictionaryCacheEntry> newEntry;
-  (void)mDictionaryCache.WithEntryHandle(prepath, [&](auto&& entry) {
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/netwerk/test/unit/test_content_decoding_error_telemetry.js b/netwerk/test/unit/test_content_decoding_error_telemetry.js
index af62153b3c3..1e6c9a29c3a 100644
--- a/netwerk/test/unit/test_content_decoding_error_telemetry.js
+++ b/netwerk/test/unit/test_content_decoding_error_telemetry.js
@@ -6,6 +6,7 @@ const { NodeHTTPSServer } = ChromeUtils.importESModule(
 
 let httpServer;
 let dictUrl;
+let dictOriginAttributes;
 
 function makeChan(url) {
   let chan = NetUtil.newChannel({
@@ -16,6 +17,13 @@ function makeChan(url) {
   return chan;
 }
 
+// Returns the OriginAttributes a completed channel's request was
+// partitioned under, for use with nsICacheTesting's dictionary methods.
+function originAttributesForChannel(chan) {
+  let partitionKey = chan.loadInfo.cookieJarSettings.partitionKey;
+  return partitionKey ? { partitionKey } : {};
+}
+
 function channelOpenPromise(chan) {
   return new Promise(resolve => {
     chan.asyncOpen(
@@ -66,6 +74,7 @@ add_setup(async function setup() {
 
   let chan = makeChan(dictUrl);
   await channelOpenPromise(chan);
+  dictOriginAttributes = originAttributesForChannel(chan);
 });
 
 async function registerBadDCBEndpoint(httpServer, path, badPayload) {
@@ -219,7 +228,7 @@ add_task(async function test_dict_hash_mismatch_telemetry() {
   Services.fog.testResetFOG();
 
   let cacheTesting = Services.cache2.QueryInterface(Ci.nsICacheTesting);
-  cacheTesting.corruptDictionaryHash(dictUrl);
+  cacheTesting.corruptDictionaryHash(dictUrl, dictOriginAttributes);
 
   Services.obs.notifyObservers(null, "clear-dictionary-data", dictUrl);
diff --git a/netwerk/test/unit/test_dictionary_oa_isolation.js b/netwerk/test/unit/test_dictionary_oa_isolation.js
new file mode 100644
index 00000000000..980687cc13a
--- /dev/null
+++ b/netwerk/test/unit/test_dictionary_oa_isolation.js
@@ -0,0 +1,136 @@
+/**
+ * Verifies that the DictionaryCache is partitioned by OriginAttributes /
+ * LoadContextInfo: a compression dictionary registered by one partition
+ * (e.g. a container) must not be offered to requests from a different
+ * partition, even for the exact same origin and matching path.
+ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript("resource://test/head_cache.js", this);
+
+const { NodeHTTPSServer } = ChromeUtils.importESModule(
+  "resource://testing-common/NodeServer.sys.mjs"
+);
+
+let server = null;
+
+add_setup(async function () {
+  Services.prefs.setBoolPref("network.http.dictionaries.enable", true);
+
+  server = new NodeHTTPSServer();
+  await server.start();
+
+  registerCleanupFunction(async () => {
+    try {
+      await server.stop();
+    } catch (e) {
+      // Ignore server stop errors during cleanup
+    }
+    Services.prefs.clearUserPref("network.http.dictionaries.enable");
+  });
+});
+
+function makeChan(url, userContextId) {
+  let chan = NetUtil.newChannel({
+    uri: url,
+    loadUsingSystemPrincipal: true,
+    contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT,
+  }).QueryInterface(Ci.nsIHttpChannel);
+  chan.loadInfo.originAttributes = { userContextId };
+  return chan;
+}
+
+function channelOpenPromise(chan) {
+  return new Promise(resolve => {
+    chan.asyncOpen(
+      new ChannelListener(
+        (req, buffer) => resolve([req, buffer]),
+        null,
+        CL_ALLOW_UNKNOWN_CL
+      )
+    );
+  });
+}
+
+function syncCache() {
+  return new Promise(resolve => {
+    syncWithCacheIOThread(resolve, true);
+  });
+}
+
+add_task(async function test_dictionary_cache_isolated_by_origin_attributes() {
+  await server.registerPathHandler("/dict1", function (request, response) {
+    response.writeHead(200, {
+      "Content-Type": "application/octet-stream",
+      "Use-As-Dictionary": `match="/content*", id="dict-container-1", type=raw`,
+      "Cache-Control": "max-age=3600",
+    });
+    response.end("DICTIONARY_CONTENT_FOR_CONTAINER_1", "binary");
+  });
+
+  await server.registerPathHandler("/dict2", function (request, response) {
+    response.writeHead(200, {
+      "Content-Type": "application/octet-stream",
+      "Use-As-Dictionary": `match="/content*", id="dict-container-2", type=raw`,
+      "Cache-Control": "max-age=3600",
+    });
+    response.end("DICTIONARY_CONTENT_FOR_CONTAINER_2", "binary");
+  });
+
+  await server.registerPathHandler("/content", function (request, response) {
+    global.lastAvailableDictionary =
+      request.headers["available-dictionary"] || null;
+    global.lastDictionaryId = request.headers["dictionary-id"] || null;
+    response.writeHead(200, {
+      "Content-Type": "text/plain",
+      "Cache-Control": "no-cache",
+    });
+    response.end("PLAIN_CONTENT", "binary");
+  });
+
+  let dict1Url = `https://localhost:${server.port()}/dict1`;
+  let dict2Url = `https://localhost:${server.port()}/dict2`;
+  let contentUrl = `https://localhost:${server.port()}/content`;
+
+  // Register a dictionary in container 1, and a different one -- matching
+  // the same path pattern -- in container 2.
+  await channelOpenPromise(makeChan(dict1Url, 1));
+  await channelOpenPromise(makeChan(dict2Url, 2));
+  await syncCache();
+
+  async function lastOfferedDictionaryId(userContextId) {
+    await server.execute(
+      "global.lastAvailableDictionary = null; global.lastDictionaryId = null;"
+    );
+    await channelOpenPromise(makeChan(contentUrl, userContextId));
+    return server.execute("global.lastDictionaryId");
+  }
+
+  Assert.equal(
+    await lastOfferedDictionaryId(1),
+    '"dict-container-1"',
+    "Container 1 should be offered its own dictionary"
+  );
+
+  Assert.equal(
+    await lastOfferedDictionaryId(2),
+    '"dict-container-2"',
+    "Container 2 should be offered its own dictionary, not container 1's"
+  );
+
+  Assert.equal(
+    await lastOfferedDictionaryId(3),
+    null,
+    "A container with no registered dictionary must not be offered one " +
+      "from a different partition"
+  );
+
+  // Re-check container 1 again, to make sure the intervening requests from
+  // other partitions didn't clobber or replace its dictionary.
+  Assert.equal(
+    await lastOfferedDictionaryId(1),
+    '"dict-container-1"',
+    "Container 1 should still be offered its own dictionary"
+  );
+});
diff --git a/netwerk/test/unit/test_dictionary_replacement.js b/netwerk/test/unit/test_dictionary_replacement.js
index e976bd68171..bec5507a18b 100644
--- a/netwerk/test/unit/test_dictionary_replacement.js
+++ b/netwerk/test/unit/test_dictionary_replacement.js
@@ -58,6 +58,13 @@ function makeChan(url, bypassCache = false) {
   return chan;
 }
 
+// Returns the OriginAttributes a completed channel's request was
+// partitioned under, for use with nsICacheTesting's dictionary methods.
+function originAttributesForChannel(chan) {
+  let partitionKey = chan.loadInfo.cookieJarSettings.partitionKey;
+  return partitionKey ? { partitionKey } : {};
+}
+
 function channelOpenPromise(chan, intermittentFail = false) {
   return new Promise(resolve => {
     function finish(req, buffer) {
@@ -582,6 +589,7 @@ add_task(async function test_dictionary_hash_mismatch() {
   let dictUrl = `https://localhost:${server.port()}/dict/hash-test`;
   let chan = makeChan(dictUrl);
   let [, data] = await channelOpenPromise(chan);
+  let dictOriginAttributes = originAttributesForChannel(chan);
 
   Assert.equal(
     data,
@@ -608,14 +616,14 @@ add_task(async function test_dictionary_hash_mismatch() {
 
   dump("**** Step 3: Corrupt the dictionary hash\n");
 
-  testingInterface.corruptDictionaryHash(dictUrl);
+  testingInterface.corruptDictionaryHash(dictUrl, dictOriginAttributes);
 
   dump("**** Step 4: Clear dictionary data to force reload from disk\n");
 
   // Clear dictionary data while keeping the corrupted hash.
   // When next prefetch happens, data will be reloaded and compared
   // against the corrupted hash, causing a mismatch.
-  testingInterface.clearDictionaryDataForTesting(dictUrl);
+  testingInterface.clearDictionaryDataForTesting(dictUrl, dictOriginAttributes);
 
   dump(
     "**** Step 5: Request matching resource - should fail due to hash mismatch\n"
diff --git a/netwerk/test/unit/xpcshell.toml b/netwerk/test/unit/xpcshell.toml
index bee57222dc8..1b029a6e29e 100644
--- a/netwerk/test/unit/xpcshell.toml
+++ b/netwerk/test/unit/xpcshell.toml
@@ -517,10 +517,10 @@ prefs = ["browser.cache.disk.encryption.enabled=true"]
 
 ["test_cache2-43-purge-residual-folder.js"]
 
-["test_cache2-44-no-rewrite-unchanged-metadata.js"]
-
 ["test_cache2-44-clear-origin-context-evictor.js"]
 
+["test_cache2-44-no-rewrite-unchanged-metadata.js"]
+
 ["test_cache2-45-clear-base-domain-context-evictor.js"]
 
 ["test_cache2_clear_with_principal.js"]
@@ -658,6 +658,9 @@ prefs = ["content.cors.use_triggering_principal=true"] # See bug 1982916.
 ["test_dictionary_compression_dcb.js"]
 run-sequentially = ["true"]
 
+["test_dictionary_oa_isolation.js"]
+run-sequentially = ["true"]
+
 ["test_dictionary_replacement.js"]
 run-sequentially = ["true"]
Loading diff…