CVE-2026-16405
Overview
Background
- `FailDelay`
- a record in
WebSocketChannel.cppthat 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
FailDelayentries 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
FailDelaykey waitsmNextDelaybefore 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.
mOriginSuffix to the key restores per-partition isolation.Attack Path
- 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
FailDelayentry. - Probe from another partition An attacker page in a different origin-attribute partition opens a WebSocket to the same host/path/port.
- Observe timing divergence
Because the shared
FailDelaykey matched, the attacker’s channel is throttled bymNextDelayinstead of connecting immediately. - 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
Changed Functions
| Function | Change | Notes |
|---|---|---|
FailDelaynetwerk/protocol/websocket/WebSocketChannel.cpp |
modified | |
ifnetwerk/protocol/websocket/WebSocketChannel.cpp |
modified |
Files Changed
netwerk/protocol/websocket/WebSocketChannel.cpp
Audit Directions
- Missing origin-attribute in cache keysAudit 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 stateReview 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 predicatesVerify predicates mixing
&&and||(as in the updatedRemovecondition) are parenthesized as intended, so an added key component is not accidentally scoped incorrectly.
Patch
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);
}
}