
Proof-of-Concept und Analyse eines gespeicherten XSS im isSafeUrl()-URL-Filter von Instatic, bei dem führende C0-Steuerzeichen die Blockierung des javascript:-Schemas umgehen.
isSafeUrl() über führende C0-SteuerzeichenBetroffen: Instatic v0.0.13 / Commit 63ad5d6 (und alle früheren Revisionen, die src/core/html-sanitize/index.ts enthalten)
Komponente: src/core/html-sanitize/index.ts → isSafeUrl() / safeUrl()
Klasse: CWE-79 (Stored XSS) über CWE-184 (Incomplete List of Disallowed Input)
isSafeUrl() ist der einzige Engpass, der javascript:, vbscript:
und data: URLs im gesamten Publisher blockiert. Es normalisiert die Eingabe
mit .replace(/[\t\n\r]/g, '').trim(), bevor es das Schema-Präfix prüft.
Der WHATWG-URL-Parser entfernt alle führenden C0-Steuerzeichen
(U+0000–U+001F) und Leerzeichen, bevor er ein Schema liest. JavaScripts
String.prototype.trim() entfernt nur U+0009, U+000A, U+000B, U+000C, U+000D,
U+0020 und Unicode-Leerzeichen — es lässt U+0000–U+0008 und U+000E–U+001F
unangetastet.
Eine URL mit z. B. U+0001 als Präfix wird vom Guard also als sicher gemeldet,
während jeder Browser sie als javascript:-Schema parst und ausführt.
Gegen die unveränderte src/core/html-sanitize/index.ts:
payload : "\x01javascript:alert(document.domain)"
isSafeUrl() : true <-- Guard meldet "sicher"
WHATWG URL scheme : javascript: <-- was der Browser tatsächlich ausführt
safeUrl() output : "\x01javascript:alert(document.domain)" (NICHT zu "#" kollabiert)
27 der 32 C0-Steuerzeichen umgehen die Prüfung. U+0000 wird durch
HTML-Attribut-Parsing neutralisiert (NUL → U+FFFD), sodass 26 zuverlässig
ausnutzbare Präfixe verbleiben (U+0001–U+0008, U+000E–U+001F). Derselbe
Bypass umgeht auch die vbscript:- und data:-Filter.
Die bestehende Testsuite (src/__tests__/publisher/utils.test.ts) deckt
Case-Folding und eingebettete Tabs (java\tscript:) ab, aber niemals ein
führendes Steuerzeichen — deshalb wurde dies nicht erkannt.
base.link deklariert href: { type: 'url' } und LinkPropsSchema typisiert
es als uneingeschränktes Type.String({ default: '#' }) — es gibt keine
URL-Validierung zur Schreibzeit. Daher:
escapeProps() leitet Props vom Typ type: 'url' | 'image' | 'media' an
isSafeUrl(value) ? value : '#' weiter — die Payload passiert roh und
bewusst un-HTML-escaped.render() von base.link gibt `<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%24%7BsafeUrl%28props.href%29%7D" …>` aus.
safeUrl() prüft erneut mit demselben defekten isSafeUrl(), dann
escapeHtml() — was nur & < > " ' escaped und Steuerzeichen nicht anfasst.href-Attribut:
<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%5Cx01javascript%3Aalert%28document.domain%29" target="_self">Click me</a>All diese laufen durch dasselbe isSafeUrl():
| Sink | Datei |
|---|---|
Jede url / image / media-Modul-Prop (Link href, Button href, Image src, Video src/poster, Form action, Form redirectUrl) | src/core/publisher/escapeProps.ts:108 |
| Beliebige benutzerdefinierte HTML-Attribute auf jedem Node | src/core/htmlAttributes/attributes.ts:66 |
Markdown-Link/Image href und src | src/core/markdown/renderMarkdown.ts:75 |
Site faviconUrl | src/core/publisher/render.ts:322 |
Der safeUrl()-Aufruf jedes Basismoduls | src/modules/base/utils/escape.ts |
Rechteausweitung von einem niedrig privilegierten Editor zur vollständigen Admin-Kompromittierung.
Die eingebaute Client-Rolle besitzt site.content.edit, was ausreicht, um
ein Link-href oder ein benutzerdefiniertes HTML-Attribut zu setzen. Die
injizierte URL wird dann im Admin-Editor-Canvas gerendert, einem
srcdoc-iframe — same-origin mit /admin.
server/securityHeaders.ts:69-72 setzt auf /admin nur
frame-ancestors 'none'; base-uri 'self'; object-src 'none', mit einem
expliziten Code-Kommentar, dass eine script-src-Policy bewusst noch nicht
gesetzt ist. Ohne script-src hindert nichts eine javascript:-URL daran,
auf der Admin-Origin ausgeführt zu werden.
Wenn ein Owner oder Admin die betroffene Seite im Editor öffnet und das
Element aktiviert, läuft die Payload same-origin mit der Admin-SPA. Das
Session-Cookie ist HttpOnly, kann also nicht direkt gelesen werden — aber
die Payload kann die Admin-API als Opfer steuern (ein Owner-Konto anlegen,
Secrets lesen oder ein Plugin installieren, dessen Server-Einstiegspunkt ein
Pfad zur Codeausführung ist).
Auf der veröffentlichten Seite ist die Auswirkung geringer: cspPlan.ts
setzt script-src 'none' (oder 'self'), was javascript:-URLs blockiert.
Allerdings lockert server/publish/frontendInjections.ts:377 dies zu
'self' 'unsafe-inline' für jede Seite mit einem Inline-Script, und
'unsafe-inline' erlaubt javascript:-URLs wieder — auf diesen Seiten ist
also XSS auf der veröffentlichten Seite erreichbar.
Beachten Sie, dass der Docstring von attributes.ts bereits genau dieses
Bedrohungsmodell benennt ("on the published site AND, more seriously, inside
the admin editor canvas (same-origin as /admin)") — der Guard implementiert
es schlicht nicht vollständig.
Im Browser bestätigt (Chromium, srcdoc-iframes, die die CSP jeder Origin
reproduzieren, und die byte-genaue Ausgabe des repo-eigenen safeUrl()
rendern):
| Reproduzierte Origin | Angewandte CSP | Ergebnis |
|---|---|---|
Admin-Editor-Canvas (/admin) | keine — wie securityHeaders.ts sie ausgibt | javascript: ausgeführt |
| Veröffentlichte Seite (Baseline) | script-src 'none' — wie cspPlan.ts sie ausgibt | blockiert (script-src-elem) |
| Veröffentlichte Seite mit Inline-Script | script-src 'self' 'unsafe-inline' — wie frontendInjections.ts:377 sie ausgibt | siehe Hinweis |
Die ersten beiden Zeilen sind beobachtete Ergebnisse. Die Ausführung auf der
Admin-Origin meldete document.domain als die ausliefernde Origin, was
Same-Origin-Ausführung statt eines opaken Kontexts bestätigt.
Einschränkung zur Genauigkeit: Das Harness wendet jede Policy über
<meta http-equiv> an, während Instatic sie als HTTP-Response-Header sendet.
Diese sind für die script-src-Durchsetzung äquivalent, aber eine
Reproduktion auf einer laufenden bun run dev-Instanz würde die echten
Header tragen.
Den vollständigen C0- + Leerzeichen-Bereich entfernen, statt sich auf
trim() zu verlassen. Genau das tut Reacts eigene isJavaScriptProtocol-Regex
mit ihrem ^[\u0000-\u001F ]*-Präfix — eine nützliche Gegenprobe, dass dies
eine bekannte, reale Bypass-Klasse ist und keine theoretische.
--- 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:') &&
Gegen die gepatchte Datei verifiziert: Die Payload wird blockiert, safeUrl()
kollabiert sie zu #, 0 von 99 gefährlichen URLs mit Steuer-/Leerzeichen-Präfix
werden weiterhin akzeptiert, und alle 14 bestehenden isSafeUrl-Testfälle
verhalten sich identisch.
it('blocks javascript: behind a leading C0 control character', () => {
for (let i = 0x00; i <= 0x1f; i++) {
expect(isSafeUrl(String.fromCharCode(i) + 'javascript:alert(1)')).toBe(false)
}
})