Medium firefox Logic Error 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionMitigation bypass in the Storage: Cache API component
ComponentDOM
Bug ClassLogic Error
Tracker2047853
Fix commit657c280dd7bc (firefox) +35/-4
CISA KEVNot listed
CreditedDavid Bors at Snyk Security Labs
Disclosed2026-08-18

Background

Cache API
the dom/cache storage mechanism that lets a ServiceWorker (or window) persist Request/Response pairs in a per-origin SQLite database.
COEP credentialless
a Cross-Origin-Embedder-Policy mode that permits embedding cross-origin resources only if they are fetched without credentials, mitigating cross-origin data leaks such as Spectre-style side channels.
`response_credentials`
the new entries table column recording the credentials mode of the fetch that produced a cached Response, stored as an integer.
`MigrateFrom30To31`
the schema migration that adds response_credentials to existing databases and backfills a safe default.

Root Cause Analysis

The Cache API persists a Response and later rehydrates it in db::ReadResponse, but the stored entries table had no column recording the credentials mode of the response, and ReadResponse incorrectly selected entries.request_credentials in the slot meant to convey the response’s credentials state. Because the response’s true credentials mode was never recorded, a credentialless cross-origin embedder could be handed a cached Response that had originally been fetched with credentials, violating the COEP-credentialless invariant that credentialed data must not be exposed to a credentialless context.

The fix adds a response_credentials column (kLatestSchemaVersion bumped to 31), populates it in InsertEntry from aResponse.credentials(), and reads it back correctly in ReadResponse. TypeUtils::ToCacheResponseWithoutBody now captures aIn.GetCredentialsMode() so the response carries its real credentials mode into storage. MigrateFrom30To31 backfills existing rows with DEFAULT 2 so pre-existing entries are treated as credentialed and thus refused to credentialless embedders, forcing a fresh network fetch. Together these ensure the credentials mode used at cache-read time reflects the response, not the request, restoring the mitigation.

Key insight
The single mistake was that the Cache API never persisted the response’s own credentials mode and ReadResponse reused request_credentials in its place, so credentialless-embedding checks operated on the wrong value; the fix stores and reads a dedicated response_credentials column that records what actually produced the response.

Attack Path

  1. Seed credentialed cache entry An attacker-controlled or victim page fetches a cross-origin resource with credentials and stores the resulting Response via the Cache API.
  2. Load in credentialless context A page under COEP: credentialless embeds the same cross-origin resource, which is meant to be served without credentials.
  3. Serve wrong entry ReadResponse returns the cached credentialed Response because no response_credentials value gated it, exposing credentialed content where only a credentialless fetch should be allowed.
  4. Leak cross-origin data The credentialless embedder observes the credentialed response, defeating the isolation the mitigation was meant to enforce.

Impact Assessment

An attacker gains a bypass of the COEP-credentialless mitigation, letting a credentialless cross-origin embedder read a Response that was cached with credentials it should never receive, within the content process running the embedding document. The precondition is that a credentialed response for the target resource already exists in the origin’s Cache API storage; exploitation is a logic/mitigation-bypass rather than memory corruption, consistent with the moderate severity rating.

Files Changed

  • dom/cache/DBSchema.cpp
  • dom/cache/TypeUtils.cpp

Audit Directions

  • Persisted security metadata
    Verify that every security-relevant attribute (credentials mode, principal, CORS state) checked at load time is actually stored per-entry, not re-derived from a sibling field like request_credentials.
  • Schema migration defaults
    When adding NOT NULL columns that gate access, confirm the backfill DEFAULT is the safe (most-restrictive) value so legacy rows fail closed.
  • Cache read gating
    Audit other cache/storage read paths (ReadResponse and callers) to ensure credentialless and cross-origin isolation checks consult the stored response credentials before returning data.
diff --git a/dom/cache/DBSchema.cpp b/dom/cache/DBSchema.cpp
index 98cef52a468..c38a0d81d7e 100644
--- a/dom/cache/DBSchema.cpp
+++ b/dom/cache/DBSchema.cpp
@@ -77,7 +77,7 @@ const int32_t kHackyDowngradeSchemaVersion = 25;
 const int32_t kHackyPaddingSizePresentVersion = 27;
 //
 // Update this whenever the DB schema is changed.
-const int32_t kLatestSchemaVersion = 30;
+const int32_t kLatestSchemaVersion = 31;
 // ---------
 // The following constants define the SQL schema.  These are defined in the
 // same order the SQL should be executed in CreateOrMigrateSchema().  They are
@@ -146,7 +146,8 @@ const char kTableEntries[] =
     "request_url_fragment TEXT NOT NULL, "
     "response_padding_size INTEGER NULL, "
     "request_body_disk_size INTEGER NULL, "
-    "response_body_disk_size INTEGER NULL "
+    "response_body_disk_size INTEGER NULL, "
+    "response_credentials INTEGER NOT NULL "
     // New columns must be added at the end of table to migrate and
     // validate properly.
     ")";
@@ -1745,6 +1746,7 @@ nsresult InsertEntry(mozIStorageConnection& aConn, CacheId aCacheId,
                        "response_security_info_id, "
                        "response_principal_info, "
                        "response_padding_size, "
+                       "response_credentials, "
                        "cache_id "
                        ") VALUES ("
                        ":request_method, "
@@ -1773,6 +1775,7 @@ nsresult InsertEntry(mozIStorageConnection& aConn, CacheId aCacheId,
                        ":response_security_info_id, "
                        ":response_principal_info, "
                        ":response_padding_size, "
+                       ":response_credentials, "
                        ":cache_id "
                        ");"_ns));
 
@@ -1895,6 +1898,10 @@ nsresult InsertEntry(mozIStorageConnection& aConn, CacheId aCacheId,
                                                   aResponse.paddingSize())));
     }
 
+    QM_TRY(MOZ_TO_RESULT(
+        state->BindInt32ByName("response_credentials"_ns,
+                               static_cast<int32_t>(aResponse.credentials()))));
+
     QM_TRY(MOZ_TO_RESULT(state->BindInt64ByName("cache_id"_ns, aCacheId)));
 
     QM_TRY(MOZ_TO_RESULT(state->Execute()));
@@ -2007,7 +2014,7 @@ Result<SavedResponse, nsresult> ReadResponse(mozIStorageConnection& aConn,
           "entries.response_principal_info, "
           "entries.response_padding_size, "
           "security_info.data, "
-          "entries.request_credentials "
+          "entries.response_credentials "
           "FROM entries "
           "LEFT OUTER JOIN security_info "
           "ON entries.response_security_info_id=security_info.id "
@@ -2609,6 +2616,8 @@ nsresult MigrateFrom28To29(nsIFile& aDBDir, mozIStorageConnection& aConn,
                            bool& aRewriteSchema);
 nsresult MigrateFrom29To30(nsIFile& aDBDir, mozIStorageConnection& aConn,
                            bool& aRewriteSchema);
+nsresult MigrateFrom30To31(nsIFile& aDBDir, mozIStorageConnection& aConn,
+                           bool& aRewriteSchema);
 // Configure migration functions to run for the given starting version.
 constexpr Migration sMigrationList[] = {
     Migration{15, MigrateFrom15To16}, Migration{16, MigrateFrom16To17},
@@ -2618,7 +2627,7 @@ constexpr Migration sMigrationList[] = {
     Migration{23, MigrateFrom23To24}, Migration{24, MigrateFrom24To25},
     Migration{25, MigrateFrom25To26}, Migration{26, MigrateFrom26To27},
     Migration{27, MigrateFrom27To28}, Migration{28, MigrateFrom28To29},
-    Migration{29, MigrateFrom29To30},
+    Migration{29, MigrateFrom29To30}, Migration{30, MigrateFrom30To31},
 };
 
 nsresult RewriteEntriesSchema(mozIStorageConnection& aConn) {
@@ -3279,5 +3288,24 @@ nsresult MigrateFrom29To30(nsIFile& aDBDir, mozIStorageConnection& aConn,
   return NS_OK;
 }
 
+nsresult MigrateFrom30To31(nsIFile& aDBDir, mozIStorageConnection& aConn,
+                           bool& aRewriteSchema) {
+  MOZ_ASSERT(!NS_IsMainThread());
+
+  // Existing response objects would be getting credential type set to 2,
+  // which means that these response objects can no longer be loaded by
+  // credentialless cross-origin embedders which is fine; a network fetch
+  // would need to be performed for such objects.
+  QM_TRY(MOZ_TO_RESULT(aConn.ExecuteSimpleSQL(
+      "ALTER TABLE entries "
+      "ADD COLUMN response_credentials INTEGER NOT NULL DEFAULT 2;"_ns)));
+
+  QM_TRY(MOZ_TO_RESULT(aConn.SetSchemaVersion(31)));
+
+  aRewriteSchema = true;
+
+  return NS_OK;
+}
+
 }  // anonymous namespace
 }  // namespace mozilla::dom::cache::db
diff --git a/dom/cache/TypeUtils.cpp b/dom/cache/TypeUtils.cpp
index 66720d0a711..5d06479c0b6 100644
--- a/dom/cache/TypeUtils.cpp
+++ b/dom/cache/TypeUtils.cpp
@@ -208,6 +208,9 @@ void TypeUtils::ToCacheResponseWithoutBody(CacheResponse& aOut,
 
   aOut.paddingInfo() = aIn.GetPaddingInfo();
   aOut.paddingSize() = aIn.GetPaddingSize();
+
+  // Record the credentials mode of the fetch that produced this response.
+  aOut.credentials() = aIn.GetCredentialsMode();
 }
 
 void TypeUtils::ToCacheResponse(JSContext* aCx, CacheResponse& aOut,
Loading diff…