# One Click, No File: Upload XSS Without the Upload

## TL;DR

Reflected XSS on a file upload endpoint is routinely written off as unexploitable. The reasoning is always the same: the victim would have to craft a malicious file and upload it themselves, so there is no realistic attack path. That reasoning is wrong, and it is wrong for a boring reason — it is an assumption about *delivery*, not about the vulnerability.

A single HTML page can build the file in the victim's browser, populate a file input with it, and submit a cross-origin multipart POST as a top-level navigation. No file picker. No filesystem. One click on a link.

This post is about that pattern, why the obvious approach fails, and where its real limits are.

* * *

## 1\. The assumption

Find a reflection in a `?q=` parameter and nobody questions the impact. Find the same reflection behind an endpoint that consumes `multipart/form-data` and the conclusion flips: *only the person who uploaded the file can trigger it.*

The unstated premise is that a file upload requires a human to open a file picker and select something from disk. That premise held in 2010. It has not held since `Blob`, `File` and `DataTransfer` became universally available.

The interaction required from the victim is exactly the same as for a reflected GET-based XSS: click a link. Everything after that click is automated. The rest of this post is the mechanics of that automation, and an honest accounting of what it does and does not buy you.

* * *

## 2\. Anatomy of what you need to reproduce

Before automating anything, look at the request you are actually trying to forge. Stripped down, an upload POST looks like this:

```http
POST /api/import/validate/public HTTP/1.1
Host: target.example
Content-Type: multipart/form-data; boundary=----geckoformboundary12a924e3
Content-Length: 1189

------geckoformboundary12a924e3
Content-Disposition: form-data; name="file"; filename="poc.xml"
Content-Type: text/xml

<?xml version="1.0" encoding="UTF-8"?>
...
------geckoformboundary12a924e3--
```

Three things matter:

*   `name="file"` — this is the parameter name. This is what the server binds to. Get it wrong and nothing works.
    
*   `filename="poc.xml"` — often cosmetic, sometimes fed into extension validation, occasionally reflected itself.
    
*   **The inner** `Content-Type` — the declared type of the part, independent of the request's own content type. Some validators check it, most do not.
    

The boundary is irrelevant to you, and that turns out to matter a lot in the next section.

* * *

## 3\. Why `fetch()` is the wrong tool

The obvious first instinct is `FormData` plus `fetch()`. It builds a valid multipart body in three lines and it works perfectly — for a CSRF-style side effect.

It does not work for XSS, and the reason is worth internalising:

*   `fetch()` gives you the response as *data*, inside **your** origin's JavaScript context. Cross-origin, you either get blocked by CORS or, with `mode: "no-cors"`, an opaque response you cannot read at all. Either way the HTML is never parsed as a document, and no script in it ever executes.
    
*   **A real** `<form>` **submission** performs a **top-level navigation**. The browser renders the response as a document, at the target's URL, in the target's origin. Any script in that response executes with full access to that origin.
    

So:

> `fetch()` — you can read the response, but you cannot execute it. `form.submit()` — you cannot read the response, but it executes in the target's origin.

For XSS, always the second. As a bonus, the browser generates the boundary and assembles the body itself, which makes this approach considerably more robust than hand-rolling a multipart body as a string.

### "But doesn't the target have to allow my origin?"

No, and this is the objection that comes up every time, so it is worth answering properly.

**CORS does not govern navigations.** CORS decides whether *your JavaScript* is allowed to read a cross-origin response. Here you read nothing: the browser leaves your page and navigates to the target. Forms predate CORS by a decade and have always been submittable cross-origin. That is precisely why CSRF exists as a vulnerability class, if a cross-origin `<form>` needed the destination's permission, it wouldn't.

The consequence that matters: after the navigation, the document lives at `target.example`. Your script executes in **the target's origin**, with its `document.domain` and access to any non-`HttpOnly` cookie via `document.cookie`. Your attacker origin is no longer in the picture; the tab left.

What *can* stop you, in descending order of likelihood:

*   **The response** `Content-Type` **plus** `nosniff`**.** The real gate. No `text/html`, no document, no execution.
    
*   **CSP** `script-src`**.** Kills inline execution even with a flawless reflection.
    
*   **Server-side** `Origin` **/** `Referer` **validation.** The only one genuinely related to the question. The browser *does* send `Origin: https://attacker.example` on a cross-origin form POST, along with `Sec-Fetch-Site: cross-site`. This is not CORS — the header is informational — but plenty of frameworks validate it out of the box, and if the backend does, you are rejected before the reflection ever happens. Check this first; it is the single most common reason a working manual payload fails to fire from a hosted PoC.
    
*   `X-Frame-Options` **/** `frame-ancestors`**.** Irrelevant here, and that is exactly why the PoC uses a top-level navigation rather than an iframe. Those directives only govern framing.
    

The price of being cross-site is the `SameSite` cookie loss covered below. If you need a same-site position, any HTML injection or open redirect on the target domain gives you one — and from there the cookies travel.

* * *

## 4\. The obstacle: `input.files` is read-only

You cannot do this:

```js
input.value = "/etc/passwd";   // blocked
input.files = [myFile];        // TypeError
```

File inputs are deliberately locked down. A page that could set `input.value` could exfiltrate arbitrary files from the victim's disk, so browsers made `value` unwritable and `files` a read-only `FileList`.

The rationale is stated plainly in the spec history. When Nico Weber proposed lifting the restriction on the WHATWG list in 2012, he named the exact attack it was there to prevent assigning a filesystem path to `input.files` and submitting the form, and argued it no longer applied, since the attribute's type had changed from a string to a `FileList`. You cannot name a file on the victim's disk any more. You *can* hand the input a file you constructed yourself, and that is a different threat model entirely.

This is the wall that most upload-XSS PoCs hit, and it is the technical reason behind the self-XSS verdict. It also has a well-documented door in it.

* * *

## 5\. The bridge: `DataTransfer`

`DataTransfer` was built for drag & drop. Unlike `FileList`, it is constructible and writable and its `.files` property is a genuine `FileList` that the file input accepts.

The whole chain is five steps:

```plaintext
string → Blob → File → DataTransfer.items.add() → input.files → form.submit()
```

In code:

```js
const blob = new Blob([xmlString], { type: "text/xml" });
const file = new File([blob], "poc.xml", { type: "text/xml" });

const dt = new DataTransfer();
dt.items.add(file);

input.files = dt.files;   // now legal
```

That is the entire trick. The file exists only in memory, never touches disk, and the victim never sees a file picker.

* * *

## 6\. The full one-click PoC

```html
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; text-align: center; margin-top: 50px;">

<p>Loading…</p>
<button onclick="go()">Continue</button>

<script>
function go() {
  const payload = `<?xml version="1.0" encoding="UTF-8"?>
<record>
  <field>canary1a&lt;script>alert(document.domain)&lt;/script>canary1b</field>
</record>`;

  const blob = new Blob([payload], { type: "text/xml" });
  const file = new File([blob], "poc.xml", { type: "text/xml" });

  const dt = new DataTransfer();
  dt.items.add(file);

  const form = document.createElement("form");
  form.method  = "POST";
  form.action  = "https://target.example/api/import/validate/public";
  form.enctype = "multipart/form-data";

  const input = document.createElement("input");
  input.type  = "file";
  input.name  = "file";
  input.files = dt.files;

  form.appendChild(input);
  document.body.appendChild(form);
  form.submit();
}

window.onload = go;
</script>

</body>
</html>
```

Host it anywhere, send the link, done. The `Continue` button is a fallback in case the auto-submit is suppressed, and it makes for a much better demo video than a page that flashes and redirects before the viewer sees anything.

* * *

## 7\. Real-world gotchas

`SameSite` **cookies.** This is a cross-site POST, so `Lax` and `Strict` cookies do not travel. Your payload executes in the target's origin but in an *unauthenticated* context. Be honest about this in the report — it caps the impact at phishing under a trusted domain, response defacement, and abuse of origin trust, rather than session theft. Overselling it is the fastest way to lose a triager's goodwill.

**The** `</script>` **trap.** A template literal inside an inline `<script>` does not protect you: while in the script data state, the HTML tokenizer scans for the literal sequence `</script` and terminates the block there — string context, backticks and comments are all irrelevant. Write `<\/script>` and move on. This one costs people twenty minutes every single time.

**Encoding for the inner parser, which happens to solve the trap for free.** This is worth walking through carefully, because three parsers are involved and each one sees something different.

In XML, `<` is illegal in character data and must be written `&lt;`. So the payload inside the template literal reads:

```plaintext
canary1a&lt;script>alert(document.domain)&lt;/script>canary1b
```

Now trace it:

1.  **The HTML tokenizer**, reading your inline `<script>` block, sees `&lt;/script>`. Character references are *not* decoded in the script data state, and there is no literal `<` in front of `/script`, so the terminator never matches. Your script block survives — the `</script>` trap is neutralised as a side effect of the XML encoding.
    
2.  **The XML parser** on the server decodes `&lt;` back to `<`, so the element value becomes the literal string `<script>alert(1)</script>`.
    
3.  **The victim's HTML parser** receives that value reflected into the error page without encoding, and executes it.
    

`>` needs no escaping in XML character data, which is why only the opening angle brackets are encoded. The general principle: encode for the parser immediately in front of you, not for the final sink. Every hop that decodes on your behalf is a hop that can carry your payload past a filter looking for `<script`.

**CSP on the response.** A restrictive `script-src` on the target kills inline execution even with a perfect reflection. Check it before spending an afternoon.

`X-Content-Type-Options: nosniff`**.** Half of this class of bug is the *response* content type. If it comes back as `application/json` with `nosniff`, the browser will not render it as a document and nothing runs.

**Extra parts and CSRF tokens.** Add hidden inputs to the form for any additional fields. If the endpoint requires an unpredictable token, the chain breaks — and that is a legitimate reason for a lower severity.

**Extension and magic byte validation.** You control the filename via the `File` constructor and the declared type via the `Blob`. For magic byte checks, prepend the required bytes to the blob contents.

**Post/Redirect/Get.** If the server 302s after the POST, the reflected content may never be rendered. Worth checking early.

* * *

## 8\. A worked example: the XML validation endpoint

The pattern is easiest to see in a concrete shape, so here is one that turns up regularly: a public validation tool that accepts an XML document via `multipart/form-data` and validates it against an XSD.

When an element value fails validation, the server helpfully embeds that value **verbatim** into the error message — and serves the whole response as `Content-Type: text/html`.

Two independent defects, harmless apart, critical together:

1.  No contextual output encoding of the reflected value.
    
2.  A content type that instructs the browser to parse the response as a document.
    

That second point deserves emphasis, because it is the half everyone forgets. If the response came back as `application/json`, the exact same missing-encoding bug would execute nothing at all. The content type is not a detail of presentation, it is what decides whether a reflection is a cosmetic bug or an XSS.

The reflection points are easiest to find with random canary markers wrapping the payload: submit a document full of unique strings, then grep the error response to see exactly which values come back raw and which get escaped. Validation errors are verbose and nested, and eyeballing them does not scale.

**And no, this is not XXE.** In a properly configured parser, external entity resolution is off: no `DOCTYPE` processing, no `SYSTEM` fetches, no out-of-band callbacks. Worth stating explicitly, because "XML upload endpoint" makes everyone reach for XXE first and then stop thinking. The interesting bug here is downstream of the parser, in what the application does with a value it has already safely parsed.

* * *

## Try it yourself

Everything above is reproducible locally in under two minutes:

[Reproducible lab](https://github.com/cain-infosec/upload-xss-lab): a minimal vulnerable server that reproduces the reflect-plus-`text/html` *pattern, and a parameterised PoC template pointing at* `localhost`.

* * *

## Prior art and credits

None of the APIs used here are new, and this is not the first write-up to chain them. If you want the primary sources rather than another blog repeating them:

**Where the capability comes from** — not security research, but the standards process:

*   [W3C Bug 10505](https://lists.w3.org/Archives/Public/public-html-bugzilla/2010Aug/1048.html) (2010) — the original request to allow a `DataTransfer` to be passed to a file input, so that forms could be processed without AJAX.
    
*   [WHATWG mailing list, May 2012](https://lists.whatwg.org/pipermail/whatwg-whatwg.org/2012-May/036140.html) — Nico Weber's proposal to make `files` writable, including the security rationale for the original restriction. The single most useful link in this list.
    
*   [W3C Bug 22682](https://www.w3.org/Bugs/Public/show_bug.cgi?id=22682) (2013) — Ian Hickson confirming WebKit's implementation of `input.files = dataTransfer.files`, and the API design discussion around it.
    
*   [Rik Schennink, "How To Set The Value Of A File Input"](https://pqina.nl/blog/set-value-to-file-input/) — the clearest web-dev-side explanation, plus the browser support history (Safari was last, in 14.1) and the macOS quirk where the filename does not render.
    

**Security-side prior art:**

*   [mchklt, "Self-XSS via filename + CSRF on contact us 'multipart/data' form"](https://mchklt.medium.com/self-xss-via-filename-csrf-on-contact-us-multipart-data-form-f852dd539547) (2024) — the same chain applied to a reflection in the `filename` part. Notably, Burp's built-in multipart CSRF PoC handled everything except the file part, which is exactly why custom JS is needed. Also contains a neat filter bypass: `document['domain']` when dot notation is blocked. He credits an earlier write-up by Sabermohamed.
    
*   [Yann C., "Leveraging Self-XSS via WYSINWYC"](https://www.asafety.fr/en/vuln-exploit-poc/poc-xss-elever-et-exploiter-une-self-xss-via-wysinwyc/) — a different approach to the same problem, and worth reading for the framing: it lists the impossibility of automating a POST payload via form auto-submit as an inherent limitation of self-XSS. That assumption is precisely what this post argues is no longer true.
    

What I hope this post adds is not the technique but the systematic treatment: why `fetch()` is the wrong tool, why CORS is not in play, where `SameSite` caps the impact, and how to demonstrate the whole thing so the delivery question never comes up.

* * *

## Closing

The vulnerability in the worked example is ordinary a missing `htmlspecialchars()` and a wrong `Content-Type`. What makes this class worth writing about is that the gap between "unexploitable" and "reflected XSS" usually has nothing to do with the vulnerability. It comes down to whether anyone bothered to solve the delivery problem.

Files are parameters. Build them in the victim's browser.
