CVE/CVE ResearchCVE-2026-102333

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.

Sep 28, 2026·12 min read·High difficulty·Lab derived
Share
  • #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:

  1. 01**It ingests untrusted data.** The traffic it captures comes from arbitrary remote servers. Those servers control the URLs, the headers, everything.
  2. 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:

python
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):

python
@url.setter
def url(self, value: str) -> None:
    self._url = value

I 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):

html
<b>{{ method }}</b> <a href="{{ url }}">{{ url }}</a>

**Hop 4 — the DOM.** `webapp/static/render.js` (line 86):

javascript
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**:

text
javascript:alert('XSS: ' + document.cookie)

Run that through Mustache and it comes out the other side *byte-identical*. Not escaped, not mangled, not caught:

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

python
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] = rec

Open `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.

MJ

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.

Next dispatch

One long-form entry, roughly twice a month.

Root cause, reproduction, and the detection path — the same standard every time.