CVE-2026-102333 — or: How a Pure-Python HTTP Debug Tool Taught Me Not to Trust Lovingly Escaped HTML
Stored XSS in httpdbg via a javascript: URL scheme that survives Mustache HTML-encoding.
- #httpdbg
- #Stored XSS
- #Python
- #CWE-79
§Why I picked httpdbg at all
Honestly? Half laziness, half instinct. httpdbg is a Python HTTP traffic inspector — it hooks the socket layer of a running Python process, records every HTTP(S) request that process makes, and shows you the traffic in a local web UI on `http://localhost:4909`.
Two things about that description made me sit up:
- 01**It ingests untrusted data.** The traffic it captures comes from arbitrary remote servers. Those servers control the URLs, the headers, everything.
- 02**It renders that data in a browser with zero authentication.** Because "localhost is trusted," presumably. Localhost is not trusted. Localhost is where I live.
And what does httpdbg capture? Exactly the traffic a developer was trying to debug — which means the thing sitting in that browser tab is often **production credentials, API keys, session tokens, whole POST bodies**. Stabbable, I figured, and I stabbed, and I report here with some giddiness that I was right.
§The audit
I ran a full security audit of httpdbg 2.2.0 (the latest at the time) and came out with seven findings. The one that ended up with a CVE is the one that gave me the most trouble *conceptually* and the least trouble *practically*, which is an unusual combination I want to write down while I still remember it.
(Yes, I know. Also note: the finder credit CNA says "M.J Dhurgesh" — the GitAhead-era records spell it slightly differently. Not my typo for once, that's literally the API.)
§The taint trail
The chain is genuinely short. Here it is, followed in the order I found it.
**Hop 1 — the source.** `hooks/recordhttp2.py` (lines 88–97), where httpdbg builds the recorded URL for HTTP/2 traffic by concatenating pseudo-headers straight from the wire:
self._url = (
self.request.get_header(":scheme") # ← came over the network
+ "://" + self.request.get_header(":authority") # ← came over the network
+ self.request.uri # ← came over the network
)In `--server` monitoring mode, `:scheme` is whatever the *client* said it was. There is no allow-list. There is no check. Nothing stops me from sending `:scheme: javascript` over the wire.
**Hop 2 — the passthrough.** `hooks/record.py` (lines 173–175):
@url.setter
def url(self, value: str) -> None:
self._url = valueI checked this one interactively at some point and typed `rec.url = "javascript:alert(document.cookie)"`. It got stored on the first try. Typed again assuming I'd made a mistake somewhere. Nope — it actually just accepts any string on Earth.
**Hop 3 — the template.** `webapp/static/index.htm` (lines 289, 302):
<b>{{ method }}</b> <a href="{{ url }}">{{ url }}</a>**Hop 4 — the DOM.** `webapp/static/render.js` (line 86):
element.innerHTML = Mustache.render(template, data);So: `<a href="{{ url }}">`, then `element.innerHTML = `.
At this point I remember doing the exact thing auditors are supposed to do and mostly never do: I stopped and read even the part that I *assumed would work correctly.*
§Why this bug is genuinely elegant, not just dangerous
Here's the bit I still think about.
`{{ }}` HTML-encodes `<`, `>`, `&`, `"`, and `'`. That means something like `<script>alert(1)</script>` stuffed into the URL dies right there. Whoever wrote this template was, I suspect, *correct in their head about Mustache being safe* — I bet they even tested XSS and couldn't get it to fire. I genuinely almost stopped reading here. Almost.
But look at the sink again. It's not text. It's **`<a href=`**.
`href` is not an HTML-encoding sink. `href` is a *URL-scheme* sink. And the payload that matters for a URL-scheme sink contains **none of the characters Mustache escapes**:
javascript:alert('XSS: ' + document.cookie)Run that through Mustache and it comes out the other side *byte-identical*. Not escaped, not mangled, not caught:
<a href="javascript:alert('XSS: ' + document.cookie)">javascript:alert('XSS: ' + document.cookie)</a>A developer opens httpdbg, root-causes an HTTP/2 request, clicks the URL because clicking URLs is what people do — and the JS runs in the context of `http://localhost:4909`, i.e. the page that currently displays every header, cookie, and body from their debugging session. One click and the session data can leave the machine.
§Reproducing it two ways
**Method A — direct record injection.** Fastest self-contained proof:
from httpdbg.server import httpdbg_srv
from httpdbg.hooks.recordhttp1 import HTTP1Record
XSS_PAYLOAD = "javascript:alert('XSS: ' + document.cookie)"
with httpdbg_srv("localhost", 4909) as records:
rec = HTTP1Record(initiator.id, group.id)
rec.url = XSS_PAYLOAD # the setter accepts it, as established
records.requests[rec.id] = recOpen `http://localhost:4909`, click the request row, click the URL in the Headers tab. `alert()` fires — confirmed live on Windows 11 + Python 3.14 + httpdbg 2.2.0. I will confess the alert popping made me laugh out loud at 11-something-pm.
**Method B — through the wire, no local code execution at all.** Against a monitored HTTP/2 server, an attacker just makes a request with headers `:scheme: javascript` and `:authority: alert(document.cookie)//`. httpdbg concatenates them and records the URL `javascript:alert(document.cookie)///`, which the UI then renders as a clickable payload. The attacker needs nothing but network access — no SE, no printer, no role. Textbook stored XSS.
§Submission and CVE
I disclosed as a full writeup: taint path, both PoCs, exact code locations, and three concrete fixes (scheme allow-listing in the URL setter, validated `:scheme` in the HTTP/2 hook, and a `safe_href` renderer in the template). Submitted to VulnCheck on 2026-09-20, and the process was fast and smooth:
- **CVE-2026-102333** assigned, CNA VulnCheck, published 2026-09-28
- Affected: **httpdbg < 2.2.1** — fixed in the next release after my disclosure
- Issue 220 in cle-b/httpdbg
- CISA SSVC: Exploitation=poc · Automatable=poc · Technical Impact=partial
§The actual lesson, and the joke is on me
I'd been auditing mostly C++ up to that point, where the exploited bug tends to be low-level because the escaping *actually is hard by hand*. In Python, escaping is easy — libraries do it, so most XSS is eliminate-by-accident — but I got lazy about *checking the sink throes*. A small "not just what gets escaped, what's being rendered *into*" question is quick to ask, and in this case it paid for my whole afternoon.
The general form of the lesson, for anyone: **if you render anything as a link** — filename from user input, URL from checked-in traffic, anything — it should be either only `http(s)`, or not a link at all.
I'll end with the embarrassing feeling that stuck with me: this vuln is *trivial* by exploit sophistication. Not layout. Not heap. Just "trust the dev tool." Funny how the boring ones keep being the good ones.
Written by
MJ — offensive security researcher
I break things in a lab so defenders can build better controls. Everything published here is lab-derived, responsibly disclosed, and documented with the detection path in mind.