loke.dev
Header image for The Speculative Leak

The Speculative Leak

Why the browser's fastest new prefetching feature is opening a novel backdoor for cross-site timing attacks—and how to defend your state.

· 5 min read

The Speculative Leak

I was browsing a site the other day that felt so fast it was almost unsettling. I’d hover over a link, and before I’d even finished the click, the page was just... there. No spinner, no layout shift, just instant gratification. We’ve spent decades optimizing the critical path, but we’ve finally reached the point where the browser is effectively reading our minds.

This "mind-reading" is powered by the Speculation Rules API. It’s the successor to the old <link rel="prefetch"> tag, and it is significantly more aggressive. But as with most things in web security, when we trade deterministic behavior for "guesses" to gain speed, we usually leave the door unlocked for someone else.

In this case, that door is a novel flavor of a cross-site timing attack.

The Magic of Speculation Rules

Before we look at the leak, we need to look at the tech. The Speculation Rules API allows developers to send a JSON structure to the browser, telling it exactly which URLs it should fetch—or even fully render in a hidden tab—before the user ever clicks.

It looks like this:

<script type="speculationrules">
{
  "prerender": [
    {
      "source": "list",
      "urls": ["/dashboard", "/settings/billing"],
      "score": 0.5
    }
  ]
}
</script>

When the browser sees this, it goes into overdrive. It doesn't just download the HTML; it starts a background renderer, executes JavaScript, and constructs the DOM. By the time you click "Billing," the work is already done.

The Side Channel in the Speed

The problem isn't the speed; it's the *conditionality* of that speed.

Most web applications serve different content based on who you are. If I'm logged in, /dashboard returns my private data. If I'm not, it redirects me to /login.

Imagine an attacker who wants to know if you have an active account on a sensitive site—let’s say an anonymous whistleblowing platform or a health portal. If that site uses speculation rules, the attacker can try to force your browser to speculate on a specific URL and then measure how long it took.

The Timing Attack

If the browser successfully prerenders a page, it caches the result. If the attacker then triggers a "real" navigation to that same page, the navigation will be nearly instantaneous (0-10ms). If the prerender failed (perhaps because the user wasn't logged in, or the server blocked the prefetch), the navigation will take a standard round-trip time (200ms+).

Here’s a simplified version of how an attacker might measure this using the PerformanceNavigationTiming API:

// Attacker's site triggers a navigation to the target
const start = performance.now();
window.location.href = "https://vulnerable-site.com/private-data";

// In a real attack, they might use an iframe or a popup 
// and observe the 'load' event timing.
window.addEventListener('load', () => {
  const duration = performance.now() - start;
  
  if (duration < 50) {
    console.log("Likely cached/prerendered: User is logged in.");
  } else {
    console.log("Standard load time: User likely logged out.");
  }
});

This is a classic XS-Leak (Cross-Site Leak). By observing side effects (timing) rather than the data itself, the attacker bypasses the Same-Origin Policy.

Why this is harder to fix than it looks

You might think, "Just don't prefetch sensitive pages."

The issue is that modern speculation rules can be document-triggered. A site might have a rule that says "prefetch any link the user hovers over." If the site has a "Delete Account" or "Admin Panel" link in the footer, the browser might try to prefetch it the moment the user’s mouse wanders near it.

Furthermore, prerendering executes JavaScript. If that JavaScript triggers a side effect—like an API call that logs "User viewed the settings page"—it can skew analytics or, worse, trigger state changes just because a user hovered over a link.

How to Defend Your State

We shouldn't disable prefetching entirely; the UX wins are too big to ignore. Instead, we need to be surgical.

1. Check the Sec-Purpose Header

The browser is actually polite enough to tell you why it’s asking for a page. When a request comes from a speculation rule, it includes a specific header. You should check this on your backend.

// Node.js/Express example
app.get('/dashboard', (req, res) => {
  const purpose = req.headers['sec-purpose'];

  if (purpose === 'prefetch' || purpose === 'prerender') {
    // If the data is sensitive, we can refuse to prefetch
    // or return a generic version of the page.
    return res.status(204).end(); 
  }

  res.render('dashboard', { user: req.user });
});

2. Vary Your Cache

If you are using a CDN or a local cache, ensure you are varying by the Sec-Purpose header so that a prefetched (and potentially generic) version of a page doesn't get served to a real user later.

Vary: Sec-Purpose, Cookie

3. Use Cross-Origin-Resource-Policy (CORP)

To prevent other sites from forcing your resources into their cache or measuring them, set a strict CORP header. This tells the browser: "Only let my own site load this."

Cross-Origin-Resource-Policy: same-origin

The "Eagerness" Setting

If you’re implementing the Speculation Rules API yourself, be careful with the eagerness setting. Setting it to immediate or conservative (on hover) is where the timing leaks become easiest to exploit.

I prefer a more balanced approach. Only speculate on URLs that aren't behind an auth wall, or use the noprefetch hint on sensitive links:

<a href="/admin" rel="noprefetch">Admin Panel</a>

Final Thoughts

The web is getting faster because it’s getting smarter, but "smart" usually means "making assumptions." Speculation Rules are a massive leap forward for perceived performance, but they turn every link into a potential metadata leak.

As developers, we have to stop treating every GET request as a simple fetch and start asking *why* the browser is asking. If it’s just "guessing," maybe don't tell it your secrets just yet.