
Proof-of-concept e analisi di un XSS memorizzato nel filtro URL isSafeUrl() di Instatic, dove i caratteri di controllo C0 iniziali aggirano il blocco dello schema javascript:.
isSafeUrl() tramite caratteri di controllo C0 inizialiInteressato: Instatic v0.0.13 / commit 63ad5d6 (e tutte le revisioni precedenti che contengono src/core/html-sanitize/index.ts)
Componente: src/core/html-sanitize/index.ts → isSafeUrl() / safeUrl()
Classe: CWE-79 (XSS memorizzato) tramite CWE-184 (Lista incompleta di input non consentiti)
isSafeUrl() è l'unico punto di strozzatura che blocca gli URL javascript:, vbscript:
e data: in tutto il publisher. Normalizza l'input con
.replace(/[\t\n\r]/g, '').trim() prima di testare il prefisso dello schema.
Il parser URL WHATWG rimuove tutti i caratteri di controllo C0 iniziali
(U+0000–U+001F) e lo spazio prima di leggere uno schema. Il metodo
String.prototype.trim() di JavaScript rimuove solo U+0009, U+000A, U+000B, U+000C, U+000D,
U+0020 e gli spazi Unicode — lascia U+0000–U+0008 e U+000E–U+001F
al loro posto.
Quindi un URL con prefisso ad esempio U+0001 viene segnalato come sicuro dalla guardia, mentre ogni
browser lo analizza ed esegue come schema javascript:.
Contro il file src/core/html-sanitize/index.ts non modificato:
payload : "\x01javascript:alert(document.domain)"
isSafeUrl() : true <-- guard reports "safe"
WHATWG URL scheme : javascript: <-- what the browser actually runs
safeUrl() output : "\x01javascript:alert(document.domain)" (NOT collapsed to "#")
27 dei 32 caratteri di controllo C0 aggirano il controllo. U+0000 è neutralizzato dal
parsing degli attributi HTML (NUL → U+FFFD), lasciando 26 prefissi sfruttabili in modo affidabile
(U+0001–U+0008, U+000E–U+001F). Lo stesso bypass vanifica i filtri
vbscript: e data:.
La suite esistente (src/__tests__/publisher/utils.test.ts) copre il case
folding e le tabulazioni incorporate (java\tscript:) ma mai un carattere di controllo
iniziale, motivo per cui questo non è stato rilevato.
base.link dichiara href: { type: 'url' } e LinkPropsSchema lo tipizza come
un Type.String({ default: '#' }) non vincolato — non esiste validazione dell'URL
al momento della scrittura. Pertanto:
escapeProps() instrada le prop type: 'url' | 'image' | 'media' verso
isSafeUrl(value) ? value : '#' — il payload passa grezzo e
deliberatamente non sottoposto a escape HTML.render() di base.link emette `<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%24%7BsafeUrl%28props.href%29%7D" …>`.
safeUrl() ricontrolla con lo stesso isSafeUrl() difettoso, poi
escapeHtml() — che esegue l'escape solo di & < > " ' e non tocca i caratteri
di controllo.href:
<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%5Cx01javascript%3Aalert%28document.domain%29" target="_self">Click me</a>Tutti questi confluiscono nello stesso isSafeUrl():
| Sink | File |
|---|---|
Ogni prop dei moduli url / image / media (link href, button href, image src, video src/poster, form action, form redirectUrl) | src/core/publisher/escapeProps.ts:108 |
| Attributi HTML personalizzati arbitrari impostati dall'utente su qualsiasi nodo | src/core/htmlAttributes/attributes.ts:66 |
href e src di link/immagini Markdown | src/core/markdown/renderMarkdown.ts:75 |
faviconUrl del sito | src/core/publisher/render.ts:322 |
Chiamata safeUrl() di ogni modulo base | src/modules/base/utils/escape.ts |
Escalation di privilegi da un editor con privilegi bassi a compromissione completa dell'admin.
Il ruolo integrato Client possiede site.content.edit, che è sufficiente per impostare
un href di un link o un attributo HTML personalizzato. L'URL iniettato viene poi renderizzato nella
canvas dell'editor admin, che è un iframe srcdoc — stessa origine di
/admin.
server/securityHeaders.ts:69-72 imposta solo
frame-ancestors 'none'; base-uri 'self'; object-src 'none' su /admin, con
un commento esplicito nel codice che una policy script-src non è deliberatamente impostata
per ora. Senza script-src, nulla impedisce a un URL javascript: di essere eseguito
sull'origine admin.
Quando un Owner o un Admin apre la pagina interessata nell'editor e attiva
l'elemento, il payload viene eseguito nella stessa origine della SPA admin. Il cookie di sessione è
HttpOnly, quindi non può essere letto direttamente — ma il payload può pilotare l'API
admin come la vittima (creare un account owner, leggere segreti o installare un plugin,
il cui entrypoint server è un percorso verso l'esecuzione di codice).
Sul sito pubblicato l'impatto è più limitato: cspPlan.ts imposta
script-src 'none' (o 'self'), che blocca gli URL javascript:. Tuttavia
server/publish/frontendInjections.ts:377 lo allenta a
'self' 'unsafe-inline' per qualsiasi pagina che contenga uno script inline, e
'unsafe-inline' ripermette gli URL javascript: — quindi l'XSS sul sito pubblicato è
raggiungibile su quelle pagine.
Si noti che la docstring di attributes.ts identifica già esattamente questo modello di minaccia
("sul sito pubblicato E, più gravemente, all'interno della canvas dell'editor admin
(stessa origine di /admin)") — la guardia semplicemente non lo implementa
completamente.
Confermato in un browser (Chromium, iframe srcdoc che riproducono la CSP di ciascuna origine,
renderizzando l'output byte per byte del safeUrl() del repository stesso):
| Origine riprodotta | CSP applicata | Risultato |
|---|---|---|
Canvas dell'editor admin (/admin) | nessuna — come emessa da securityHeaders.ts | javascript: eseguito |
| Pagina pubblicata (baseline) | script-src 'none' — come emessa da cspPlan.ts | bloccato (script-src-elem) |
| Pagina pubblicata con uno script inline | script-src 'self' 'unsafe-inline' — come emessa da frontendInjections.ts:377 | vedi nota |
Le prime due righe sono risultati osservati. L'esecuzione sull'origine admin ha riportato
document.domain come origine servente, confermando l'esecuzione nella stessa origine
anziché un contesto opaco.
Avvertenza sulla fedeltà: l'harness applica ciascuna policy tramite <meta http-equiv>,
mentre Instatic la invia come header di risposta HTTP. Sono equivalenti per
l'applicazione di script-src, ma una riproduzione su un'istanza live bun run dev
comporterebbe gli header reali.
Rimuovere l'intero intervallo C0 + spazio invece di affidarsi a trim(). Questo è
precisamente ciò che fa la regex isJavaScriptProtocol di React con il suo
prefisso ^[\u0000-\u001F ]* — un utile controllo incrociato che questa è una
classe di bypass nota e reale, non teorica.
--- a/src/core/html-sanitize/index.ts
+++ b/src/core/html-sanitize/index.ts
@@ -30,7 +30,11 @@ export function escapeHtml(value: unknown): string {
* normalisation browsers apply during URL parsing.
*/
export function isSafeUrl(url: string): boolean {
- const normalized = url.replace(/[\t\n\r]/g, '').trim().toLowerCase()
+ const normalized = String(url ?? '')
+ .replace(/[\t\n\r]/g, '')
+ .replace(/^[\u0000-\u0020]+/, '')
+ .replace(/[\u0000-\u0020]+$/, '')
+ .toLowerCase()
return (
!normalized.startsWith('javascript:') &&
!normalized.startsWith('vbscript:') &&
Verificato contro il file corretto: il payload è bloccato, safeUrl()
lo riduce a #, 0 di 99 URL pericolosi con prefisso di controllo/spazio rimangono
accettati, e tutti i 14 casi di test isSafeUrl esistenti si comportano in modo identico.