advanced 3 min answer

On 20 March 2023 a change to OpenAI's servers spiked Redis request cancellations, a redis-py bug returned another user's cached reply on a pooled connection, and chat titles plus payment details of roughly 1.2% of active ChatGPT Plus subscribers were exposed during a nine-hour window. Which design decision turned a client-library bug into a personal-data breach?

openaicacheconnection-poolbreachdata-map
Show the full answer Hide the answer

The trigger

A connection pool hands one TCP connection to many logical requests in sequence. When a request is cancelled after its command has been written but before its reply is read, the unread reply stays in the socket buffer and the next borrower of that connection reads it as its own. The library's invariant — one reply per request — is broken, and every layer above assumes it holds. A server change raised the cancellation rate, so a rare interleaving became a frequent one.

Nothing in the application was wrong. The application asked for user A's cached object and was handed user B's, with a 200.

Why a cache bug becomes a breach

Because of what was in the cache. OpenAI's write-up lists conversation titles and, for the subscribers affected, first and last name, email address, payment address, the last four digits of the card and the expiry date. The cache was holding directly identifying data and was therefore a personal-data store with the protections of a performance component.

The decision that mattered was allowing the cache to hold identifying fields without an ownership assertion on read. One invariant would have contained it: store the owner's subject id inside the cached object, and compare it with the requesting session before anything is rendered. A mismatch then becomes a 500 and an alert rather than a disclosure. Key prefixes do not help here — the bug crossed replies, not keys.

Why detection lagged

No error was raised anywhere. Latency was normal, error rates were normal, the cache hit ratio was normal. The only signal available was a human seeing a title that was not theirs. This is the general property of confidentiality failures: availability and correctness failures announce themselves, and disclosure failures do not, so they must be detected by an assertion you wrote on purpose.

The structural fix versus the tempting local fix

The tempting fix is to pin or patch the client library and move on. That closes this instance.

The structural fixes are:

  1. Owner-bound cache values with an assert on read, measured as a counter you alert on.
  2. Field minimisation in caches. Card metadata and billing addresses do not belong in a cache fronting a chat UI; they were there because the object was convenient.
  3. Caches, queues and log stores in the data map. A data map that lists databases understates the estate, and the 72-hour notification clock starts whether or not the store was on your diagram.
  4. A cancellation policy: prefer a deadline that lets the reply drain, or close the connection on cancel rather than returning it to the pool.

The general lesson

Any shared mutable resource between tenants is a confidentiality boundary, whether or not it was designed as one: connection pools, thread-locals, request-scoped context reused across a keep-alive connection, a memoised object in a warm serverless container. Write one assertion per boundary and pay the nanoseconds.

Common weak answers

  • "Upgrade the library." It closes this instance and leaves the class of bug intact. The next pool, in the next language, has the same shape.
  • "Encrypt the cache." Encryption at rest protects a stolen disk. Both users here were served by the same process, which holds the key.
  • "Add per-user key prefixes." Reasonable-sounding and beside the point: the keys were correct and the replies were swapped underneath them.