Moderate firefox Race 🔧 Commit mapped

Overview

Moderate
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactmoderate
DescriptionSandbox escape due to incorrect boundary conditions in the Storage: Quota Manager component
ComponentDOM
Bug ClassRace
Tracker2072411
Fix commit2d33105047eb (firefox) +114/-18
CISA KEVNot listed
CreditedMozilla
Disclosed2026-10-27

Background

Quota Manager encrypted streams
dom/quota/DecryptingInputStream wraps a base nsIInputStream and decrypts fixed-size blocks on the fly. It implements nsIInputStream, nsISeekableStream, nsITellableStream, nsICloneableInputStream, and nsIIPCSerializableInputStream.
Mutable decryption cursor
State such as mPlainBuffer (decrypted bytes), mPlainBytes (count), mNextByte (read cursor), mEncryptedBlock, mCipherStrategy, and mBaseStream together track where in the stream reading is. ReadSegments consumes from this cursor; Seek rewinds/moves it.
Thread-safe refcount, no data lock
The stream is reference-counted thread-safely, so it can be shared across threads. As the patch comment states, an HTTP upload stream can be read on the socket thread while a redirect rewinds it (Seek) on the main thread.
`MOZ_GUARDED_BY` / `MOZ_REQUIRES` / `Mutex`
Mozilla’s static thread-safety annotations and a Mutex type; a field annotated MOZ_GUARDED_BY(mMutex) must only be touched with mMutex held, and a method annotated MOZ_REQUIRES(mMutex) must be called with it held.

Root Cause Analysis

DecryptingInputStream’s mutable decryption state had no lock, yet the object is thread-safely refcounted and, in the HTTP upload case, is read on the socket thread while being seeked on the main thread on redirect. ReadSegments/ReadAll/ParseNextChunk advance mNextByte/mPlainBytes and refill mPlainBuffer/mEncryptedBlock using mCipherStrategy, while a concurrent Seek resets those same fields. With no serialization this is a data race: a read can copy from mPlainBuffer using a mNextByte/mPlainBytes pair that a concurrent Seek has partially updated, producing a torn read — reading from a stale cursor with a stale count — and operating on cipher/buffer state that is mid-mutation. The regression test DummyCipherStrategy_ConcurrentReadAndSeek demonstrates exactly this: a seek thread hammers Seek while the main thread Reads a byte-ramp payload, and asserts each returned chunk is consecutive; pre-patch the race yields non-consecutive (“torn”) bytes.

The patch adds a Mutex mMutex to DecryptingInputStreamBase, annotates all mutable cursor/cipher fields with MOZ_GUARDED_BY(mMutex), and takes MutexAutoLock lock(mMutex) at the start of every public entry point that touches that state: the constructors (via Init, now MOZ_REQUIRES(mMutex)), Close, Available, StreamStatus, BaseStreamStatus, ReadSegments, Tell, Seek, Clone, Serialize, and Deserialize. TellInternal is demoted from NS_IMETHODIMP to a private nsresult ... MOZ_REQUIRES(mMutex) helper and Available/Tell now call TellInternal while already holding the lock (avoiding a self-deadlock a re-entrant Tell would cause). The nsICloneableInputStream/nsIIPCSerializableInputStream conditional interface-map entries change their guard from !mBaseStream to !mBlockSize, because mBaseStream is now lock-guarded and must not be read in QueryInterface without the lock, whereas mBlockSize is effectively immutable after construction. Net effect: all reads and seeks of the decryption cursor are now mutually exclusive.

Note on advisory wording: the advisory calls this a “Sandbox escape due to incorrect boundary conditions in the Storage: Quota Manager component.” The diff does not substantiate boundary/off-by-one logic or a sandbox-escape mechanism; it is unambiguously a missing-lock data race on shared stream state. Trusting the diff, the true class is a concurrency/race bug; any memory-corruption or escape consequence is inferred, not shown.

Key insight
A thread-safely-refcounted decrypting stream shared across the socket and main threads had no lock on its read cursor and buffers, so a concurrent Read and Seek raced and produced torn reads from mid-mutation state.

Attack Path

  1. Shared stream Content triggers an operation that serializes an encrypted quota-backed stream as an HTTP upload body, so the DecryptingInputStream is read on the socket thread (inferred reachability from the patch’s own description of the HTTP-upload/redirect scenario).
  2. Concurrent rewind A redirect causes the upload stream to be rewound via Seek on the main thread while the socket thread is mid-ReadSegments.
  3. Race window mNextByte/mPlainBytes/mPlainBuffer/mCipherStrategy are mutated by Seek while ReadSegments reads them, with no lock.
  4. Torn/stale read ReadSegments copies using a stale cursor/count, yielding corrupted or wrongly-bounded plaintext (the test catches this as non-consecutive bytes).
  5. Primitive Data corruption of stream contents and operation on inconsistent buffer/cipher state; memory-safety corruption or any sandbox consequence beyond this is inferred and not demonstrated by the diff.

Impact Assessment

This is a data race on a quota-manager stream that can be read and seeked from two threads. The directly substantiated effect is corrupted/torn stream data and mutation of buffer/cipher state without synchronization; the regression test proves the torn-read behavior. The advisory’s “sandbox escape”/“boundary conditions” framing is not evidenced by the code — escalation to a controllable memory-corruption primitive or sandbox escape is inferred, not shown. Reachability requires the stream to be shared across threads (the HTTP-upload-with-redirect pattern the patch cites); severity is rated “moderate.”

Changed Functions

FunctionChangeNotes
mKey
dom/quota/DecryptingInputStream_impl.h
modified
if
dom/quota/DecryptingInputStream_impl.h
modified
TEST_F
dom/quota/test/gtest/TestEncryptedStream.cpp
modified
for
dom/quota/test/gtest/TestEncryptedStream.cpp
modified
while
dom/quota/test/gtest/TestEncryptedStream.cpp
modified

Files Changed

  • dom/quota/DecryptingInputStream.cpp
  • dom/quota/DecryptingInputStream.h
  • dom/quota/DecryptingInputStream_impl.h
  • dom/quota/test/gtest/TestEncryptedStream.cpp

Audit Directions

  • Other refcounted streams shared across threads
    Audit other nsIInputStream/nsISeekableStream implementations in dom/quota and netwerk that keep a mutable read cursor but no lock yet can be read on the socket thread while seeked on the main thread.
  • Thread-safety annotation coverage
    Use MOZ_GUARDED_BY/MOZ_REQUIRES to find mutable members of thread-safely-refcounted classes that lack guards, the exact pattern this patch retrofits.
  • QueryInterface reading guarded state
    Review conditional interface maps that test lock-guarded members (like the old !mBaseStream); prefer testing immutable fields (!mBlockSize) to avoid unlocked reads.
diff --git a/dom/quota/DecryptingInputStream.cpp b/dom/quota/DecryptingInputStream.cpp
index 9bb867d1db2..870b34b93a3 100644
--- a/dom/quota/DecryptingInputStream.cpp
+++ b/dom/quota/DecryptingInputStream.cpp
@@ -16,15 +16,16 @@ NS_INTERFACE_MAP_BEGIN(DecryptingInputStreamBase)
   NS_INTERFACE_MAP_ENTRY(nsIInputStream)
   NS_INTERFACE_MAP_ENTRY(nsISeekableStream)
   NS_INTERFACE_MAP_ENTRY_CONDITIONAL(nsICloneableInputStream,
-                                     mBaseCloneableInputStream || !mBaseStream)
+                                     mBaseCloneableInputStream || !mBlockSize)
   NS_INTERFACE_MAP_ENTRY_CONDITIONAL(
       nsIIPCSerializableInputStream,
-      mBaseIPCSerializableInputStream || !mBaseStream)
+      mBaseIPCSerializableInputStream || !mBlockSize)
   NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIInputStream)
 NS_INTERFACE_MAP_END
 
 DecryptingInputStreamBase::DecryptingInputStreamBase(
     MovingNotNull<nsCOMPtr<nsIInputStream>> aBaseStream, size_t aBlockSize) {
+  MutexAutoLock lock(mMutex);
   Init(std::move(aBaseStream), aBlockSize);
 }
 
diff --git a/dom/quota/DecryptingInputStream.h b/dom/quota/DecryptingInputStream.h
index 62bb46ae29a..1c64e6875d6 100644
--- a/dom/quota/DecryptingInputStream.h
+++ b/dom/quota/DecryptingInputStream.h
@@ -15,6 +15,7 @@
 #include "ErrorList.h"
 #include "mozilla/InitializedOnce.h"
 #include "mozilla/Maybe.h"
+#include "mozilla/Mutex.h"
 #include "mozilla/NotNull.h"
 #include "mozilla/ipc/InputStreamParams.h"
 #include "nsCOMPtr.h"
@@ -61,16 +62,22 @@ class DecryptingInputStreamBase : public nsIInputStream,
   virtual ~DecryptingInputStreamBase() = default;
 
   void Init(MovingNotNull<nsCOMPtr<nsIInputStream>> aBaseStream,
-            size_t aBlockSize);
+            size_t aBlockSize) MOZ_REQUIRES(mMutex);
 
   // Convenience routine to determine how many bytes of plain data
   // we currently have in our plain buffer.
-  size_t PlainLength() const;
+  size_t PlainLength() const MOZ_REQUIRES(mMutex);
 
   size_t EncryptedBufferLength() const;
 
+  // The stream is refcounted thread-safely and may be read on one thread while
+  // being seeked from another (e.g. an HTTP upload stream that is read on the
+  // socket thread and rewound on the main thread on redirect). All mutable
+  // state below is therefore only accessed with this lock held.
+  Mutex mMutex{"DecryptingInputStreamBase::mMutex"};
+
   LazyInitializedOnceEarlyDestructible<const NotNull<nsCOMPtr<nsIInputStream>>>
-      mBaseStream;
+      mBaseStream MOZ_GUARDED_BY(mMutex);
   LazyInitializedOnce<const NotNull<nsISeekableStream*>> mBaseSeekableStream;
   LazyInitializedOnce<const NotNull<nsICloneableInputStream*>>
       mBaseCloneableInputStream;
@@ -78,10 +85,10 @@ class DecryptingInputStreamBase : public nsIInputStream,
       mBaseIPCSerializableInputStream;
 
   // Number of bytes of plain data in mPlainBuffer.
-  size_t mPlainBytes = 0;
+  size_t mPlainBytes MOZ_GUARDED_BY(mMutex) = 0;
 
   // Next byte of mPlainBuffer to return in ReadSegments().
-  size_t mNextByte = 0;
+  size_t mNextByte MOZ_GUARDED_BY(mMutex) = 0;
 
   LazyInitializedOnceNotNull<const size_t> mBlockSize;
 };
@@ -111,7 +118,6 @@ class DecryptingInputStream final : public DecryptingInputStreamBase {
                           uint32_t aCount, uint32_t* _retval) override;
 
   NS_DECL_NSITELLABLESTREAM
-  NS_IMETHOD TellInternal(int64_t* const aRetval, uint64_t const aBlockOffset);
 
   NS_IMETHOD Seek(int32_t aWhence, int64_t aOffset) override;
 
@@ -125,9 +131,13 @@ class DecryptingInputStream final : public DecryptingInputStreamBase {
  private:
   ~DecryptingInputStream();
 
+  nsresult TellInternal(int64_t* aRetval, uint64_t aBlockOffset)
+      MOZ_REQUIRES(mMutex);
+
   // Parse the next chunk of data.  This populates mPlainBuffer (until the
   // stream position is at EOF).
-  nsresult ParseNextChunk(bool aCheckAvailableBytes, uint32_t* aBytesReadOut);
+  nsresult ParseNextChunk(bool aCheckAvailableBytes, uint32_t* aBytesReadOut)
+      MOZ_REQUIRES(mMutex);
 
   // Convenience routine to Read() from the base stream until we get
   // the given number of bytes or reach EOF.
@@ -146,25 +156,27 @@ class DecryptingInputStream final : public DecryptingInputStreamBase {
   // aBytesReadOut          - An out parameter indicating how many bytes were
   // 			      read.
   nsresult ReadAll(char* aBuf, uint32_t aCount, uint32_t aMinValidCount,
-                   bool aCheckAvailableBytes, uint32_t* aBytesReadOut);
+                   bool aCheckAvailableBytes, uint32_t* aBytesReadOut)
+      MOZ_REQUIRES(mMutex);
 
-  bool EnsureBuffers();
+  bool EnsureBuffers() MOZ_REQUIRES(mMutex);
 
-  nsresult EnsureDecryptedStreamSize();
+  nsresult EnsureDecryptedStreamSize() MOZ_REQUIRES(mMutex);
 
-  CipherStrategy mCipherStrategy;
+  CipherStrategy mCipherStrategy MOZ_GUARDED_BY(mMutex);
   LazyInitializedOnce<const typename CipherStrategy::KeyType> mKey;
 
   // Buffer to hold encrypted data.  Must copy here since we need a
   // flat buffer to run the decryption process on.
   using EncryptedBlockType = EncryptedBlock<CipherStrategy::BlockPrefixLength,
                                             CipherStrategy::BasicBlockSize>;
-  Maybe<EncryptedBlockType> mEncryptedBlock;
+  Maybe<EncryptedBlockType> mEncryptedBlock MOZ_GUARDED_BY(mMutex);
 
   // Buffer storing the resulting plain data.
-  nsTArray<uint8_t> mPlainBuffer;
+  nsTArray<uint8_t> mPlainBuffer MOZ_GUARDED_BY(mMutex);
 
-  LazyInitializedOnce<const int64_t> mDecryptedStreamSize;
+  LazyInitializedOnce<const int64_t> mDecryptedStreamSize
+      MOZ_GUARDED_BY(mMutex);
 };
 
 }  // namespace mozilla::dom::quota
diff --git a/dom/quota/DecryptingInputStream_impl.h b/dom/quota/DecryptingInputStream_impl.h
index 384368eea6e..e7a30e3d352 100644
--- a/dom/quota/DecryptingInputStream_impl.h
+++ b/dom/quota/DecryptingInputStream_impl.h
@@ -31,6 +31,7 @@ DecryptingInputStream<CipherStrategy>::DecryptingInputStream(
     typename CipherStrategy::KeyType aKey)
     : DecryptingInputStreamBase(std::move(aBaseStream), aBlockSize),
       mKey(aKey) {
+  MutexAutoLock lock(mMutex);
   // XXX Move this to a fallible init function.
   MOZ_ALWAYS_SUCCEEDS(mCipherStrategy.Init(CipherMode::Decrypt,
                                            CipherStrategy::SerializeKey(aKey)));
@@ -53,6 +54,7 @@ DecryptingInputStream<CipherStrategy>::DecryptingInputStream()
 
 template <typename CipherStrategy>
 NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::Close() {
+  MutexAutoLock lock(mMutex);
   if (!mBaseStream) {
     return NS_OK;
   }
@@ -69,12 +71,13 @@ NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::Close() {
 template <typename CipherStrategy>
 NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::Available(
     uint64_t* aLengthOut) {
+  MutexAutoLock lock(mMutex);
   if (!mBaseStream) {
     return NS_BASE_STREAM_CLOSED;
   }
 
   int64_t current;
-  nsresult rv = Tell(&current);
+  nsresult rv = TellInternal(&current, mNextByte);
   if (NS_WARN_IF(NS_FAILED(rv))) {
     return rv;
   }
@@ -94,11 +97,13 @@ NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::Available(
 
 template <typename CipherStrategy>
 NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::StreamStatus() {
+  MutexAutoLock lock(mMutex);
   return mBaseStream ? NS_OK : NS_BASE_STREAM_CLOSED;
 }
 
 template <typename CipherStrategy>
 nsresult DecryptingInputStream<CipherStrategy>::BaseStreamStatus() {
+  MutexAutoLock lock(mMutex);
   return mBaseStream ? (*mBaseStream)->StreamStatus() : NS_BASE_STREAM_CLOSED;
 }
 
@@ -108,6 +113,7 @@ NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::ReadSegments(
     uint32_t* aBytesReadOut) {
   *aBytesReadOut = 0;
 
+  MutexAutoLock lock(mMutex);
   if (!mBaseStream) {
     return NS_BASE_STREAM_CLOSED;
   }
@@ -370,11 +376,12 @@ nsresult DecryptingInputStream<CipherStrategy>::EnsureDecryptedStreamSize() {
 template <typename CipherStrategy>
 NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::Tell(
     int64_t* const aRetval) {
+  MutexAutoLock lock(mMutex);
   return TellInternal(aRetval, mNextByte);
 }
 
 template <typename CipherStrategy>
-NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::TellInternal(
+nsresult DecryptingInputStream<CipherStrategy>::TellInternal(
     int64_t* const aRetval, uint64_t const aBlockOffset) {
   MOZ_ASSERT(aRetval);
 
@@ -410,6 +417,7 @@ NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::TellInternal(
 template <typename CipherStrategy>
 NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::Seek(const int32_t aWhence,
                                                           int64_t aOffset) {
+  MutexAutoLock lock(mMutex);
   if (!mBaseStream) {
     return NS_BASE_STREAM_CLOSED;
   }
@@ -549,6 +557,7 @@ NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::Seek(const int32_t aWhence,
 template <typename CipherStrategy>
 NS_IMETHODIMP DecryptingInputStream<CipherStrategy>::Clone(
     nsIInputStream** _retval) {
+  MutexAutoLock lock(mMutex);
   if (!mBaseStream) {
     return NS_BASE_STREAM_CLOSED;
   }
@@ -575,6 +584,7 @@ template <typename CipherStrategy>
 void DecryptingInputStream<CipherStrategy>::Serialize(
     mozilla::ipc::InputStreamParams& aParams, uint32_t aMaxSize,
     uint32_t* aSizeUsed) {
+  MutexAutoLock lock(mMutex);
   MOZ_ASSERT(mBaseStream);
   MOZ_ASSERT(mBaseIPCSerializableInputStream);
 
@@ -602,6 +612,7 @@ bool DecryptingInputStream<CipherStrategy>::Deserialize(
     return false;
   }
 
+  MutexAutoLock lock(mMutex);
   Init(WrapNotNull<nsCOMPtr<nsIInputStream>>(std::move(stream)),
        params.blockSize());
 
diff --git a/dom/quota/test/gtest/TestEncryptedStream.cpp b/dom/quota/test/gtest/TestEncryptedStream.cpp
index a34225aefb3..1a105dbbb80 100644
--- a/dom/quota/test/gtest/TestEncryptedStream.cpp
+++ b/dom/quota/test/gtest/TestEncryptedStream.cpp
@@ -4,6 +4,7 @@
 
 #include <algorithm>
 #include <cstdint>
+#include <iterator>
 #include <new>
 #include <numeric>
 #include <string>
@@ -14,6 +15,7 @@
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
 #include "mozilla/Assertions.h"
+#include "mozilla/Atomics.h"
 #include "mozilla/Attributes.h"
 #include "mozilla/FixedBufferOutputStream.h"
 #include "mozilla/NotNull.h"
@@ -34,10 +36,12 @@
 #include "nsISeekableStream.h"
 #include "nsISupports.h"
 #include "nsITellableStream.h"
+#include "nsIThread.h"
 #include "nsStreamUtils.h"
 #include "nsString.h"
 #include "nsStringFwd.h"
 #include "nsTArray.h"
+#include "nsThreadUtils.h"
 #include "nscore.h"
 #include "nss.h"
 
@@ -665,6 +669,74 @@ TEST_P(ParametrizedCryptTest, zeroInitializedEncryptedBlock) {
   EXPECT_THAT(unusedBytesInFirstBlock, testing::Each(0ul));
 }
 
+// Regression test for bug 2072411: an HTTP upload stream is read on the socket
+// thread while a redirect rewinds it on the main thread. Read and Seek must
+// serialize, otherwise ReadSegments copies from a stale cursor with a stale
+// count. The test data is a byte ramp, so a torn read shows up as a
+// non-consecutive chunk even when it does not crash.
+TEST_F(DOM_Quota_EncryptedStream, DummyCipherStrategy_ConcurrentReadAndSeek) {
+  using CipherStrategy = DummyCipherStrategy;
+  constexpr size_t kBlockSize = 1024;
+  constexpr size_t kDataSize = 4000;
+  constexpr size_t kReadChunkSize = 64;
+  constexpr size_t kSeekIterations = 5000;
+
+  const auto baseOutputStream = WrapNotNull(
+      RefPtr<FixedBufferOutputStream>{FixedBufferOutputStream::Create(8192)});
+
+  const auto data = MakeTestData(kDataSize);
+
+  WriteTestData<CipherStrategy>(
+      nsCOMPtr<nsIOutputStream>{baseOutputStream.get()}, Span{data}, kDataSize,
+      kBlockSize, CipherStrategy::KeyType{}, FlushMode::Never);
+
+  const auto baseInputStream =
+      MakeRefPtr<ArrayBufferInputStream>(baseOutputStream->WrittenData());
+
+  const auto inStream = MakeRefPtr<DecryptingInputStream<CipherStrategy>>(
+      WrapNotNull(nsCOMPtr<nsIInputStream>{baseInputStream}), kBlockSize,
+      CipherStrategy::KeyType{});
+
+  Atomic<bool> done{false};
+  Atomic<uint32_t> seekFailures{0};
+
+  nsCOMPtr<nsIThread> seekThread;
+  ASSERT_EQ(NS_OK, NS_NewNamedThread(
+                       "SeekThread", getter_AddRefs(seekThread),
+                       NS_NewRunnableFunction(
+                           "DOM_Quota_EncryptedStream::ConcurrentSeek", [&] {
+                             constexpr int64_t kOffsets[] = {0, kDataSize - 48,
+                                                             kDataSize / 2,
+                                                             kDataSize - 1};
+                             for (size_t i = 0; i < kSeekIterations; ++i) {
+                               if (NS_FAILED(inStream->Seek(
+                                       nsISeekableStream::NS_SEEK_SET,
+                                       kOffsets[i % std::size(kOffsets)]))) {
+                                 ++seekFailures;
+                               }
+                             }
+                             done = true;
+                           })));
+
+  uint8_t buffer[kReadChunkSize];
+  size_t reads = 0;
+  while (!done) {
+    uint32_t read = 0;
+    ASSERT_EQ(NS_OK, inStream->Read(reinterpret_cast<char*>(buffer),
+                                    kReadChunkSize, &read));
+    ASSERT_LE(read, kReadChunkSize);
+    for (uint32_t j = 1; j < read; ++j) {
+      ASSERT_EQ(static_cast<uint8_t>(buffer[0] + j), buffer[j])
+          << "torn read at read " << reads << ", byte " << j;
+    }
+    ++reads;
+  }
+
+  EXPECT_EQ(NS_OK, seekThread->Shutdown());
+  EXPECT_EQ(0u, static_cast<uint32_t>(seekFailures));
+  EXPECT_GT(reads, 0u);
+}
+
 enum struct SeekOffset {
   Zero,
   MinusHalfDataSize,
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/dom/quota/test/gtest/TestEncryptedStream.cpp b/dom/quota/test/gtest/TestEncryptedStream.cpp
index a34225aefb3..1a105dbbb80 100644
--- a/dom/quota/test/gtest/TestEncryptedStream.cpp
+++ b/dom/quota/test/gtest/TestEncryptedStream.cpp
@@ -4,6 +4,7 @@
 
 #include <algorithm>
 #include <cstdint>
+#include <iterator>
 #include <new>
 #include <numeric>
 #include <string>
@@ -14,6 +15,7 @@
 #include "gmock/gmock.h"
 #include "gtest/gtest.h"
 #include "mozilla/Assertions.h"
+#include "mozilla/Atomics.h"
 #include "mozilla/Attributes.h"
 #include "mozilla/FixedBufferOutputStream.h"
 #include "mozilla/NotNull.h"
@@ -34,10 +36,12 @@
 #include "nsISeekableStream.h"
 #include "nsISupports.h"
 #include "nsITellableStream.h"
+#include "nsIThread.h"
 #include "nsStreamUtils.h"
 #include "nsString.h"
 #include "nsStringFwd.h"
 #include "nsTArray.h"
+#include "nsThreadUtils.h"
 #include "nscore.h"
 #include "nss.h"
 
@@ -665,6 +669,74 @@ TEST_P(ParametrizedCryptTest, zeroInitializedEncryptedBlock) {
   EXPECT_THAT(unusedBytesInFirstBlock, testing::Each(0ul));
 }
 
+// Regression test for bug 2072411: an HTTP upload stream is read on the socket
+// thread while a redirect rewinds it on the main thread. Read and Seek must
+// serialize, otherwise ReadSegments copies from a stale cursor with a stale
+// count. The test data is a byte ramp, so a torn read shows up as a
+// non-consecutive chunk even when it does not crash.
+TEST_F(DOM_Quota_EncryptedStream, DummyCipherStrategy_ConcurrentReadAndSeek) {
+  using CipherStrategy = DummyCipherStrategy;
+  constexpr size_t kBlockSize = 1024;
+  constexpr size_t kDataSize = 4000;
+  constexpr size_t kReadChunkSize = 64;
+  constexpr size_t kSeekIterations = 5000;
+
+  const auto baseOutputStream = WrapNotNull(
+      RefPtr<FixedBufferOutputStream>{FixedBufferOutputStream::Create(8192)});
+
+  const auto data = MakeTestData(kDataSize);
+
+  WriteTestData<CipherStrategy>(
+      nsCOMPtr<nsIOutputStream>{baseOutputStream.get()}, Span{data}, kDataSize,
+      kBlockSize, CipherStrategy::KeyType{}, FlushMode::Never);
+
+  const auto baseInputStream =
+      MakeRefPtr<ArrayBufferInputStream>(baseOutputStream->WrittenData());
+
+  const auto inStream = MakeRefPtr<DecryptingInputStream<CipherStrategy>>(
+      WrapNotNull(nsCOMPtr<nsIInputStream>{baseInputStream}), kBlockSize,
+      CipherStrategy::KeyType{});
+
+  Atomic<bool> done{false};
+  Atomic<uint32_t> seekFailures{0};
+
+  nsCOMPtr<nsIThread> seekThread;
+  ASSERT_EQ(NS_OK, NS_NewNamedThread(
+                       "SeekThread", getter_AddRefs(seekThread),
+                       NS_NewRunnableFunction(
+                           "DOM_Quota_EncryptedStream::ConcurrentSeek", [&] {
+                             constexpr int64_t kOffsets[] = {0, kDataSize - 48,
+                                                             kDataSize / 2,
+                                                             kDataSize - 1};
+                             for (size_t i = 0; i < kSeekIterations; ++i) {
+                               if (NS_FAILED(inStream->Seek(
+                                       nsISeekableStream::NS_SEEK_SET,
+                                       kOffsets[i % std::size(kOffsets)]))) {
+                                 ++seekFailures;
+                               }
+                             }
+                             done = true;
+                           })));
+
+  uint8_t buffer[kReadChunkSize];
+  size_t reads = 0;
+  while (!done) {
+    uint32_t read = 0;
+    ASSERT_EQ(NS_OK, inStream->Read(reinterpret_cast<char*>(buffer),
+                                    kReadChunkSize, &read));
+    ASSERT_LE(read, kReadChunkSize);
+    for (uint32_t j = 1; j < read; ++j) {
+      ASSERT_EQ(static_cast<uint8_t>(buffer[0] + j), buffer[j])
+          << "torn read at read " << reads << ", byte " << j;
+    }
+    ++reads;
+  }
+
+  EXPECT_EQ(NS_OK, seekThread->Shutdown());
+  EXPECT_EQ(0u, static_cast<uint32_t>(seekFailures));
+  EXPECT_GT(reads, 0u);
+}
+
 enum struct SeekOffset {
   Zero,
   MinusHalfDataSize,
Loading diff…