
Preuve de concept et analyse d'un XSS stocké dans le filtre d'URL isSafeUrl() d'Instatic, où les caractères de contrôle C0 en tête contournent le blocage du schéma javascript:.
isSafeUrl() via des caractères de contrôle C0 en têteAffecté : Instatic v0.0.13 / commit 63ad5d6 (et toutes les révisions antérieures contenant src/core/html-sanitize/index.ts)
Composant : src/core/html-sanitize/index.ts → isSafeUrl() / safeUrl()
Classe : CWE-79 (XSS stocké) via CWE-184 (Liste incomplète d'entrées interdites)
isSafeUrl() est le point de passage unique qui bloque les URL javascript:, vbscript:
et data: dans l'ensemble du publisher. Il normalise l'entrée avec
.replace(/[\t\n\r]/g, '').trim() avant de tester le préfixe du schéma.
Le parseur d'URL WHATWG supprime tous les caractères de contrôle C0
(U+0000–U+001F) et l'espace en tête avant de lire un schéma. Le
String.prototype.trim() de JavaScript ne supprime que U+0009, U+000A, U+000B, U+000C, U+000D,
U+0020 et les espaces Unicode — il laisse U+0000–U+0008 et U+000E–U+001F
en place.
Ainsi, une URL préfixée par exemple par U+0001 est signalée comme sûre par le garde-fou, alors que tous
les navigateurs la parsent et l'exécutent comme le schéma javascript:.
Contre le fichier src/core/html-sanitize/index.ts non modifié :
payload : "\x01javascript:alert(document.domain)"
isSafeUrl() : true <-- le garde-fou signale "sûr"
WHATWG URL scheme : javascript: <-- ce que le navigateur exécute réellement
safeUrl() output : "\x01javascript:alert(document.domain)" (PAS réduit à "#")
27 des 32 caractères de contrôle C0 contournent la vérification. U+0000 est neutralisé par
le parsing des attributs HTML (NUL → U+FFFD), laissant 26 préfixes exploitables de manière fiable
(U+0001–U+0008, U+000E–U+001F). Le même contournement met en échec les
filtres vbscript: et data:.
La suite existante (src/__tests__/publisher/utils.test.ts) couvre le case
folding et les tabulations intégrées (java\tscript:) mais jamais un caractère de contrôle
en tête, ce qui explique pourquoi cela n'a pas été détecté.
base.link déclare href: { type: 'url' } et LinkPropsSchema le type comme
un Type.String({ default: '#' }) non contraint — il n'y a pas de validation d'URL
à l'écriture. Par conséquent :
escapeProps() route les props type: 'url' | 'image' | 'media' vers
isSafeUrl(value) ? value : '#' — le payload passe brut et
délibérément non échappé en HTML.render() de base.link émet `<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%24%7BsafeUrl%28props.href%29%7D" …>`.
safeUrl() revérifie avec le même isSafeUrl() défectueux, puis
escapeHtml() — qui n'échappe que & < > " ' et ne touche pas aux caractères
de contrôle.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>Tous ceux-ci passent par le même isSafeUrl() :
| Sink | Fichier |
|---|---|
Chaque prop de module url / image / media (lien href, bouton href, image src, vidéo src/poster, formulaire action, formulaire redirectUrl) | src/core/publisher/escapeProps.ts:108 |
| Attributs HTML personnalisés arbitraires définis par l'utilisateur sur n'importe quel nœud | src/core/htmlAttributes/attributes.ts:66 |
href et src des liens/images Markdown | src/core/markdown/renderMarkdown.ts:75 |
faviconUrl du site | src/core/publisher/render.ts:322 |
L'appel safeUrl() de chaque module de base | src/modules/base/utils/escape.ts |
Escalade de privilèges depuis un éditeur à faibles privilèges vers une compromission complète de l'admin.
Le rôle Client intégré détient site.content.edit, ce qui suffit pour définir
un href de lien ou un attribut HTML personnalisé. L'URL injectée est ensuite rendue dans
le canvas de l'éditeur admin, qui est une iframe srcdoc — de même origine que
/admin.
server/securityHeaders.ts:69-72 ne définit que
frame-ancestors 'none'; base-uri 'self'; object-src 'none' sur /admin, avec
un commentaire de code explicite indiquant qu'une politique script-src n'est délibérément pas définie
pour l'instant. Sans script-src, rien n'empêche une URL javascript: de s'exécuter
sur l'origine admin.
Lorsqu'un Owner ou un Admin ouvre la page affectée dans l'éditeur et active
l'élément, le payload s'exécute de même origine que la SPA admin. Le cookie de session est
HttpOnly, il ne peut donc pas être lu directement — mais le payload peut piloter l'API admin
en tant que victime (créer un compte owner, lire des secrets, ou installer un plugin,
dont le point d'entrée serveur est un chemin vers l'exécution de code).
Sur le site publié, l'impact est plus restreint : cspPlan.ts définit
script-src 'none' (ou 'self'), ce qui bloque les URL javascript:. Cependant
server/publish/frontendInjections.ts:377 l'assouplit en
'self' 'unsafe-inline' pour toute page portant un script inline, et
'unsafe-inline' réautorise les URL javascript: — le XSS sur le site publié est donc
atteignable sur ces pages.
Notez que la docstring de attributes.ts identifie déjà ce modèle de menace exact
("sur le site publié ET, plus grave, dans le canvas de l'éditeur admin
(de même origine que /admin)") — le garde-fou ne l'implémente simplement pas
complètement.
Confirmé dans un navigateur (Chromium, iframes srcdoc reproduisant la CSP de chaque origine,
rendant la sortie octet pour octet du safeUrl() du dépôt lui-même) :
| Origine reproduite | CSP appliquée | Résultat |
|---|---|---|
Canvas de l'éditeur admin (/admin) | aucune — telle qu'émise par securityHeaders.ts | javascript: exécuté |
| Page publiée (référence) | script-src 'none' — telle qu'émise par cspPlan.ts | bloqué (script-src-elem) |
| Page publiée avec un script inline | script-src 'self' 'unsafe-inline' — telle qu'émise par frontendInjections.ts:377 | voir note |
Les deux premières lignes sont des résultats observés. L'exécution sur l'origine admin a rapporté
document.domain comme l'origine servante, confirmant une exécution de même origine
plutôt qu'un contexte opaque.
Réserve sur la fidélité : le harnais applique chaque politique via <meta http-equiv>,
alors qu'Instatic l'envoie comme en-tête de réponse HTTP. Ces deux approches sont équivalentes pour
l'application de script-src, mais une reproduction sur une instance bun run dev en direct
porterait les en-têtes authentiques.
Supprimer la plage complète C0 + espace au lieu de se fier à trim(). C'est
précisément ce que fait la regex isJavaScriptProtocol de React elle-même avec son
préfixe ^[\u0000-\u001F ]* — une vérification croisée utile montrant qu'il s'agit d'une
classe de contournement connue et réelle plutôt que théorique.
--- 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:') &&
Vérifié contre le fichier corrigé : le payload est bloqué, safeUrl()
le réduit à #, 0 des 99 URL dangereuses préfixées par un caractère de contrôle/espace restent
acceptées, et les 14 cas de test isSafeUrl existants se comportent de manière identique.