Low firefox Info Disclosure 🔧 Commit mapped

Overview

Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionInformation disclosure in the Networking: WebSockets component
ComponentNetworking
Bug ClassInfo Disclosure
Tracker2036591
Fix commitfc5ca5d86492 (firefox) +25/-15
CISA KEVNot listed
CreditedYaqoub Aldurayhim
Disclosed2026-07-21

Background

`FailDelay`
a record in WebSocketChannel.cpp that tracks a failed WebSocket connection attempt to a given host so reconnects to it are throttled with a backoff delay.
`FailDelayManager`
the container that stores, looks up, adds, and removes FailDelay entries keyed by connection identity.
`mOriginSuffix`
the origin-attributes suffix (partition key encoding container, private-browsing, and isolation state) carried on each WebSocketChannel.
Reconnect backoff
the timing behavior in which a channel that shares an existing FailDelay key waits mNextDelay before connecting instead of connecting immediately.

Root Cause Analysis

The FailDelayManager keyed each FailDelay solely on mAddress, mPath, and mPort, omitting the channel’s mOriginSuffix, so failure records were shared across all origin-attribute partitions targeting the same host/path/port. This violated the partitioning invariant that per-origin network state (containers, private browsing, first-party isolation) must not be observable across partitions. Because Lookup, DelayOrBegin, Remove, and Add all matched without the suffix, a WebSocket in one partition could observe — via whether a reconnect was delayed or began immediately — that a connection to the same host had recently failed in a different partition.

The fix threads mOriginSuffix through the FailDelay constructor and through Add, Lookup, and Remove, and adds fail->mOriginSuffix.Equals(originSuffix) to the match predicate. As a result each partition maintains an isolated set of failure/backoff records, so the reconnect-timing side channel no longer crosses origin-attribute boundaries.

Key insight
The single core mistake was that WebSocket failure-backoff records were keyed only by network destination and not by origin-attribute partition, letting reconnect timing leak connection-failure state between partitions; adding mOriginSuffix to the key restores per-partition isolation.

Attack Path

  1. Establish victim-partition state Content in one partition (e.g., a container or private-browsing context) attempts a WebSocket to a target host/path/port that fails, creating a FailDelay entry.
  2. Probe from another partition An attacker page in a different origin-attribute partition opens a WebSocket to the same host/path/port.
  3. Observe timing divergence Because the shared FailDelay key matched, the attacker’s channel is throttled by mNextDelay instead of connecting immediately.
  4. Infer cross-partition activity The attacker distinguishes “delayed” from “immediate” to learn that the victim partition recently failed a connection to that host.

Impact Assessment

An attacker gains a low-severity cross-partition information-disclosure side channel: the ability to infer that another origin-attribute partition recently attempted and failed a WebSocket connection to a specific host/path/port. This operates in web content within the networking layer of the browser and requires the attacker to run script that can open WebSockets and the victim to have produced a recent failure record for the same destination.

Changed Functions

FunctionChangeNotes
FailDelay
netwerk/protocol/websocket/WebSocketChannel.cpp
modified
if
netwerk/protocol/websocket/WebSocketChannel.cpp
modified

Files Changed

  • netwerk/protocol/websocket/WebSocketChannel.cpp

Audit Directions

  • Missing origin-attribute in cache keys
    Audit other network-state stores (connection pools, backoff/throttle tables, DNS or auth caches) that key on host/path/port for absence of mOriginSuffix/origin attributes.
  • Timing-observable shared state
    Review any code where the presence of a shared record changes observable timing (delay vs. immediate), since such state must be partitioned to avoid cross-origin side channels.
  • Operator precedence in match predicates
    Verify predicates mixing && and || (as in the updated Remove condition) are parenthesized as intended, so an added key component is not accidentally scoped incorrectly.
diff --git a/netwerk/protocol/websocket/WebSocketChannel.cpp b/netwerk/protocol/websocket/WebSocketChannel.cpp
index 5dd6161947f..618430282b1 100644
--- a/netwerk/protocol/websocket/WebSocketChannel.cpp
+++ b/netwerk/protocol/websocket/WebSocketChannel.cpp
@@ -121,8 +121,12 @@ const uint32_t kWSReconnectMaxDelay = 60 * 1000;
 // to same host/path/port.
 class FailDelay {
  public:
-  FailDelay(nsCString address, nsCString path, int32_t port)
-      : mAddress(std::move(address)), mPath(std::move(path)), mPort(port) {
+  FailDelay(nsCString address, nsCString path, int32_t port,
+            nsCString originSuffix)
+      : mAddress(std::move(address)),
+        mPath(std::move(path)),
+        mPort(port),
+        mOriginSuffix(std::move(originSuffix)) {
     mLastFailure = TimeStamp::Now();
     mNextDelay = kWSReconnectInitialBaseDelay +
                  (rand() % kWSReconnectInitialRandomDelay);
@@ -160,6 +164,7 @@ class FailDelay {
   nsCString mAddress;  // IP address (or hostname if using proxy)
   nsCString mPath;
   int32_t mPort;
+  nsCString mOriginSuffix;
 
  private:
   TimeStamp mLastFailure;  // Time of last failed attempt
@@ -190,18 +195,20 @@ class FailDelayManager {
 
   ~FailDelayManager() { MOZ_COUNT_DTOR(FailDelayManager); }
 
-  void Add(nsCString& address, nsCString& path, int32_t port) {
+  void Add(nsCString& address, nsCString& path, int32_t port,
+           nsCString& originSuffix) {
     if (mDelaysDisabled) {
       return;
     }
 
-    mEntries.AppendElement(MakeUnique<FailDelay>(address, path, port));
+    mEntries.AppendElement(
+        MakeUnique<FailDelay>(address, path, port, originSuffix));
   }
 
   // Element returned may not be valid after next main thread event: don't keep
   // pointer to it around
   FailDelay* Lookup(nsCString& address, nsCString& path, int32_t port,
-                    uint32_t* outIndex = nullptr) {
+                    nsCString& originSuffix, uint32_t* outIndex = nullptr) {
     if (mDelaysDisabled) {
       return nullptr;
     }
@@ -214,7 +221,7 @@ class FailDelayManager {
     for (int32_t i = mEntries.Length() - 1; i >= 0; --i) {
       FailDelay* fail = mEntries[i].get();
       if (fail->mAddress.Equals(address) && fail->mPath.Equals(path) &&
-          fail->mPort == port) {
+          fail->mPort == port && fail->mOriginSuffix.Equals(originSuffix)) {
         if (outIndex) *outIndex = i;
         result = fail;
         // break here: removing more entries would mess up *outIndex.
@@ -233,7 +240,8 @@ class FailDelayManager {
   void DelayOrBegin(WebSocketChannel* ws) {
     if (!mDelaysDisabled) {
       uint32_t failIndex = 0;
-      FailDelay* fail = Lookup(ws->mAddress, ws->mPath, ws->mPort, &failIndex);
+      FailDelay* fail = Lookup(ws->mAddress, ws->mPath, ws->mPort,
+                               ws->mOriginSuffix, &failIndex);
 
       if (fail) {
         TimeStamp rightNow = TimeStamp::Now();
@@ -269,14 +277,15 @@ class FailDelayManager {
 
   // Remove() also deletes all expired entries as it iterates: better for
   // battery life than using a periodic timer.
-  void Remove(nsCString& address, nsCString& path, int32_t port) {
+  void Remove(nsCString& address, nsCString& path, int32_t port,
+              nsCString& originSuffix) {
     TimeStamp rightNow = TimeStamp::Now();
 
     // iterate from end, to make deletion indexing easier
     for (int32_t i = mEntries.Length() - 1; i >= 0; --i) {
       FailDelay* entry = mEntries[i].get();
       if ((entry->mAddress.Equals(address) && entry->mPath.Equals(path) &&
-           entry->mPort == port) ||
+           entry->mPort == port && entry->mOriginSuffix.Equals(originSuffix)) ||
           entry->IsExpired(rightNow)) {
         mEntries.RemoveElementAt(i);
       }
@@ -328,8 +337,8 @@ class nsWSAdmissionManager {
     bool hostFound = (sManager->IndexOf(ws->mAddress, ws->mOriginSuffix) >= 0);
 
     uint32_t failIndex = 0;
-    FailDelay* fail = sManager->mFailures.Lookup(ws->mAddress, ws->mPath,
-                                                 ws->mPort, &failIndex);
+    FailDelay* fail = sManager->mFailures.Lookup(
+        ws->mAddress, ws->mPath, ws->mPort, ws->mOriginSuffix, &failIndex);
     bool existingFail = fail != nullptr;
 
     // Always add ourselves to queue, even if we'll connect immediately
@@ -377,7 +386,7 @@ class nsWSAdmissionManager {
 
     // Connection succeeded, so stop keeping track of any previous failures
     sManager->mFailures.Remove(aChannel->mAddress, aChannel->mPath,
-                               aChannel->mPort);
+                               aChannel->mPort, aChannel->mOriginSuffix);
 
     // Check for queued connections to same host.
     // Note: still need to check for failures, since next websocket with same
@@ -398,8 +407,9 @@ class nsWSAdmissionManager {
 
     if (NS_FAILED(aReason)) {
       // Have we seen this failure before?
-      FailDelay* knownFailure = sManager->mFailures.Lookup(
-          aChannel->mAddress, aChannel->mPath, aChannel->mPort);
+      FailDelay* knownFailure =
+          sManager->mFailures.Lookup(aChannel->mAddress, aChannel->mPath,
+                                     aChannel->mPort, aChannel->mOriginSuffix);
       if (knownFailure) {
         if (aReason == NS_ERROR_NOT_CONNECTED) {
           // Don't count close() before connection as a network error
@@ -418,7 +428,7 @@ class nsWSAdmissionManager {
              aChannel->mAddress.get(), aChannel->mPath.get(),
              (int)aChannel->mPort, aChannel));
         sManager->mFailures.Add(aChannel->mAddress, aChannel->mPath,
-                                aChannel->mPort);
+                                aChannel->mPort, aChannel->mOriginSuffix);
       }
     }
 
Loading diff…