DEV Community

幻灵末士
幻灵末士

Posted on AI-assisted

How I Found an SSR Cache Isolation Failure in a React Data Fetching Library

There's a certain kind of bug that only shows up when you stop thinking about what code does and start thinking about when it does it.

Most security researchers spend their time asking "can an attacker make this code do something bad?" That's a fine question. But it misses an entire category of vulnerabilities that have nothing to do with malicious input. Sometimes the code does something bad all on its own, just because of how it's structured.

This is the story of one of those bugs.

The Setup

I was auditing a popular React data fetching library. You've probably used it or at least seen it in a Next.js project. It's the kind of library that handles caching, revalidation, and all the other unglamorous parts of making API calls from React components.

The library has a feature that's pretty common in the React ecosystem: it works differently depending on whether you're running on the client or the server. On the client, you want caching to persist across page navigations. On the server—during server-side rendering—each request should be completely isolated from every other request. That's Security 101 for SSR: if user A and user B both trigger a server render at the same time, neither should be able to see the other's data.

So I started asking a simple question: how does this library manage its cache during server-side rendering?

The Deep Dive

I traced the cache initialization back to its source. What I found was about as simple as it gets:

const [cache, mutate, , , unload] = initCache(new Map())
Enter fullscreen mode Exit fullscreen mode

That's a module-level variable. A single Map object, created once, sitting at the top of a file. Every part of the library that needs a cache references this same object.

On the client, this makes total sense. You want one cache for the entire application. When the user navigates between pages, the cache persists. That's the whole point.

But on the server, module-level variables are dangerous. In a Node.js environment, modules are loaded once and shared across all incoming requests. That means this single cache Map is shared across every user who hits the server. Concurrently.

I looked for a mechanism that would provide request-scoped isolation by default. Some kind of AsyncLocalStorage wrapper, or a factory function that creates a fresh cache per request, or even a check that said "hey, you're in SSR mode, here's an isolated cache instead."

I didn't find one.

The "Oh No" Moment

Here's what the vulnerability looks like in practice. Imagine a Next.js application that uses this library to fetch user-specific data during server-side rendering. The component looks something like this:

function UserProfile() {
  const { data } = useSWR('/api/user-data', fetcher);
  return <div>Hello, {data.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

The cache key is /api/user-data. It's the same for every user. The library uses this key to look up cached data before making a fetch request.

Now here's what happens when two users hit the server around the same time:

  1. User A's request starts rendering. The library checks the global cache for /api/user-data. It's empty, so it fetches the data from the API and stores it in the cache. The cache now contains User A's data.

  2. User B's request starts rendering a few milliseconds later. The library checks the global cache for /api/user-data. It finds User A's data sitting there. It uses it. No fetch needed.

  3. User B's HTML response contains User A's name, email, or whatever else was in that cached payload.

No attacker needed. No malicious input. Just two users using the application at the same time, and the library's default behavior hands one user's data to another.

Why This Happens

The library does provide a way to isolate caches per request. There's a provider component that you can wrap your app in, and it creates a fresh cache scope. But it's opt-in. The default configuration doesn't do this.

And here's the thing about defaults: they're what most people use. If you're a developer integrating this library into your Next.js app, you'll probably follow the docs, use the default setup, and never realize that you're sharing cache state across all your users. The docs might mention the provider in passing, but unless you're specifically looking for SSR isolation concerns, you're not going to connect the dots.

This is one of those situations where the secure option exists but the insecure option is the default. And in security, defaults matter more than anything.

The Broader Pattern

This isn't unique to this particular library. Module-level mutable state in server-side JavaScript is a pattern that shows up everywhere. Any time a Node.js application shares a mutable variable across request boundaries, there's potential for data leakage.

The difference here is that this library is explicitly designed to work in SSR environments. It knows that server-side rendering means concurrent requests sharing the same module instances. And yet the default cache is still a module-level singleton.

I've seen this same pattern in other libraries too. A utility module that stores configuration in a top-level object. A logger that keeps a buffer in module scope. A session manager that initializes once and keeps state between requests. The details vary, but the underlying mistake is the same: assuming that "loaded once" means "safe to share."

What I Reported

I put together a proof of concept that demonstrated the issue clearly. The setup simulated three concurrent requests from three different users, all using the same cache key. The first user's data ended up being served to the second and third users.

The output was pretty damning:

[VULNERABILITY CONFIRMED]
  User B received User A private data from shared global cache!

[CRITICAL] User C received User A private data from polluted cache!
Enter fullscreen mode Exit fullscreen mode

No elaborate exploit chain. No tricky timing attack. Just three requests happening in the normal course of application usage, and the library's default behavior leaking data between them.

The Fix

The fix has two parts. First, the library should use something like AsyncLocalStorage to provide request-scoped cache isolation on the server by default. Second, the documentation needs to be much more explicit about the risks of using the default configuration in SSR environments.

The first part is a code change. The second part is arguably harder—you can't fix a documentation gap with a patch, and you can't force developers to read the docs you've already written. The real fix is making the secure option the default, so developers don't have to opt in to safety.

What I Learned

Server-side module state is a liability. Every time you create a module-level mutable variable in a Node.js application, ask yourself: what happens when two requests access this at the same time? If the answer involves one request seeing another request's data, you have a problem.

Defaults define security posture. An opt-in security feature is not a security feature. If the safe configuration requires the developer to know about the danger and take action, most developers won't do it. Not because they don't care, but because they don't know.

Concurrency bugs are often invisible in development. If you're testing your app locally, you're probably making one request at a time. The cache never collides because there's nothing to collide with. The bug only shows up in production, under load, when multiple users happen to hit the same cache key at the same time. That's what makes this class of vulnerability so insidious—it's invisible until it's catastrophic.

Final Thoughts

I can't share the library name or the vendor's response yet—standard responsible disclosure practice. But the pattern is worth understanding regardless of which library it happened to be.

If you're building React applications with server-side rendering, go check your data fetching libraries. Look at how they manage cache state. If the cache is a module-level singleton and there's no obvious per-request isolation, you might be one concurrent request away from leaking user data.

And if you're on the security research side, don't just look for code that can be exploited. Look for code that behaves differently under concurrency than it does in isolation. Those bugs are harder to find, harder to trigger in development, and often far more damaging in production.

The best bugs aren't the ones that require a sophisticated attacker. They're the ones that happen all by themselves, in the normal course of things, while everyone assumes the code is working correctly.

Top comments (0)