---
id: 4ab82678-f954-4bef-bfbc-eed37ab38bde
title: 'Stored XSS: raw/<path> serves committed HTML/SVG as text/html'
state: closed
labels:
- security
assignees: []
milestone: null
created: 2026-10-27T14:53:32.418656561Z
updated: 2026-10-27T15:44:20.851821135Z
---
`dist/raw/<path>` is byte-identical repo content served with a content type chosen from the *extension*, so any committed `.html` (or `.svg`, `.xhtml`, `.xml`) executes script on the site's origin.

Verified:

```
$ printf '<!doctype html><script>fetch("/inbox/issue",{method:"POST"})</script>hi\n' > evil.html
$ hu13 build . -o out && hu13 serve --www out --inbox-dir /tmp/ib
$ curl -D- http://127.0.0.1:39501/raw/evil.html
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
...
<!doctype html><script>fetch("/inbox/issue",{method:"POST"})</script>hi
```

`files/evil.html.html` is correctly escaped (`&lt;script&gt;`), so the escaped view is fine — the raw view is the hole. It needs no click: visiting the URL runs it.

Why it matters here specifically: the page is same-origin with `/inbox/*`, so injected script satisfies the `Origin` gate, can POST spam from the visitor's own IP, has full access to the DOM of the mirror (phishing/defacement), and can chain the inbox forms. It also breaks the project invariant "bodies from the repo are treated as untrusted".

Same hole exists on Cloudflare Pages (it also types by extension), so this cannot be fixed in `serve` alone.

Fix direction:
- serve `/raw/**` as `application/octet-stream` + `Content-Disposition: attachment; filename="..."` for anything that browsers render (html/htm/svg/xhtml/xml/xsl/mht), `text/plain; charset=utf-8` for text files
- never `text/html`, `image/svg+xml` or `application/xhtml+xml` under `/raw/`
- Pages: emit `_headers` with the same rule (or drop the `raw/` tree from Pages builds)
- optionally add `Content-Security-Policy: default-src 'none'; style-src 'self'` on raw responses as belt-and-braces

Acceptance:
- [ ] `GET /raw/x.html` is never `text/html` and does not execute script
- [ ] text files still render readably as `text/plain`
- [ ] a test builds a repo containing `<script>` in `.html`/`.svg` and asserts the served content type
