Date : 15/02/2026
Status : IMPLEMENTEE
Auditeur : Claude Opus 4.6 (passe 2, regard neuf)
Perimetre : 22 fichiers .tsx + 3 fichiers .ts dans src/
Classification : BUG CERTAIN / BUG PROBABLE / RISQUE THEORIQUE / FAUX POSITIF
Le projet est une application Qwik City a 8 routes + 7 composants, avec communication temps reel via WebSocket et API REST. L'architecture repose sur un bus evenementiel document.dispatchEvent(CustomEvent) pour decouple le layout (WS manager) des composants enfants.
Findings critiques : 4 BUG CERTAIN, 6 BUG PROBABLE, 10 RISQUE THEORIQUE, 1 FAUX POSITIF
useStore mute dans le render (EventCard.tsx)Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/EventCard.tsx
Lignes : 319, 322-323
const hasOriginalMessage = (event.type === 'objective_completed' || event.type === 'objective_failed') && event.data?.originalMessage;
const routingConfidence = event.type === 'routing' && event.data?.confidence
? Math.round(event.data.confidence * 100) : null;
Ces acces a event.data dans le corps du render (hors $() closure) sont directement dans le scope du composant. Si event est un proxy reactif Qwik (passe par useStore ou signal), chaque acces declenche un tracking. Ce n'est pas un bug de mutation, mais...
Verdict : FAUX POSITIF — event est passe en prop, pas un store. Les acces sont en lecture seule.
useStore pour streamInfo dans ChatMessages.tsxFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/ChatMessages.tsx
Ligne : 22
const streamInfo = useStore<StreamingInfo>({ agent: '', model: '', text: '', iteration: '', maxIterations: '', tool: '', toolDuration: '' });
Le useStore est mute directement dans un event listener vanilla (lignes 41-86) qui est enregistre dans useVisibleTask$. Les mutations comme streamInfo.agent = ''; (ligne 41) sont faites dans un callback non-Qwik (document.addEventListener). Qwik serialise les stores — les mutations depuis un handler vanilla JS fonctionnent en pratique car le proxy est toujours le meme objet, mais ce pattern n'est pas garanti par la spec Qwik.
Classification : RISQUE THEORIQUE Impact : Peut fonctionner en dev mais casser en production avec SSR/resumability.
searchTimerRef via useSignal<number>(0) pour stocker un timer IDFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/sidebar/ConversationSidebar.tsx
Ligne : 77
const searchTimerRef = useSignal(0);
Puis ligne 327 :
if (searchTimerRef.value) clearTimeout(searchTimerRef.value);
Et ligne 334 :
searchTimerRef.value = setTimeout(async () => { ... }, 300) as any;
Probleme : setTimeout retourne un ID de type number | NodeJS.Timeout. Le signal est type number (initial 0). Le as any masque le cast force. Plus grave : le timer n'est jamais nettoye au cleanup. Si le composant est demonte pendant que le timer tourne, le callback setTimeout executera quand meme et tentera de modifier searchResults.value.
Classification : BUG CERTAIN
Impact : Fuite de memoire + potentiel crash apres demontage. Pas de cleanup dans la closure cleanup() de useVisibleTask$.
archiveInterval qui ne detecte pas les changementsFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/sidebar/ConversationSidebar.tsx
Lignes : 151-156
const origShowArchived = showArchived.value;
const archiveInterval = setInterval(() => {
if (showArchived.value !== origShowArchived) {
loadConversations();
}
}, 500);
Probleme : origShowArchived capture la valeur au moment de l'initialisation. Apres le premier changement et le premier loadConversations(), l'interval continuera d'appeler loadConversations() toutes les 500ms indefiniment, car showArchived.value !== origShowArchived restera toujours vrai apres le premier toggle. Pas de mise a jour de origShowArchived apres detection.
Classification : BUG CERTAIN Impact : Polling API toutes les 500ms indefiniment apres le premier toggle archive. Charge reseau et serveur.
loadLastConversation avec setTimeout(500ms)Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/layout.tsx
Lignes : 167-169
setTimeout(() => {
document.dispatchEvent(new CustomEvent('ulias:load-history', { detail: { messages: items } }));
}, 500);
Probleme : Le setTimeout(500) suppose que ChatMessages sera monte en 500ms. Si le composant est lent a se monter (reseau lent, appareil lent), l'evenement est perdu. Si le composant est deja monte, le delay de 500ms est inutile. Pas de mecanisme de retry ni de verification.
Classification : BUG PROBABLE Impact : Au premier chargement, la conversation precedente peut ne pas s'afficher.
setTimeout(500) pour template messageFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/sidebar/ConversationSidebar.tsx
Lignes : 201-203
setTimeout(() => {
document.dispatchEvent(new CustomEvent('ulias:send-template-message', { detail: { content } }));
}, 500);
Meme probleme que 1.5. Le WS peut ne pas avoir fini le switch de conversation en 500ms.
Classification : RISQUE THEORIQUE
dangerouslySetInnerHTML avec du contenu utilisateurFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/history/index.tsx
Ligne : 376
<div ... dangerouslySetInnerHTML={renderMarkdown(obj.result.output)} />
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/EventCard.tsx
Lignes : 407, 423
<div ... dangerouslySetInnerHTML={renderMarkdown(summary)} />
Le renderMarkdown() dans markdown.ts appelle escapeHtml() en premier (ligne 18), puis applique les regexes de conversion. Cependant, la regex pour les liens (ligne 46) :
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" ...>$1</a>');
...permet d'injecter du HTML dans l'attribut href. Un contenu comme [click](javascript:alert(1)) passera escapeHtml (pas de <, >, &, " dans javascript:alert(1)) et generera :
<a href="javascript:alert(1)" ...>click</a>
Classification : BUG CERTAIN (XSS via javascript: URI)
Impact : Un agent ou le serveur peut envoyer un lien malveillant qui execute du JS arbitraire au clic.
localStorage sans expirationFichiers : Multiples (layout.tsx:32, login/index.tsx:69, et 12+ autres acces a localStorage.getItem('ulias_api_key'))
Impact : Si un script tiers ou une extension navigateur lit le localStorage, la cle API est compromise. Pas de token rotation cote client.
Classification : RISQUE THEORIQUE Note : C'est un pattern standard pour les SPA. Acceptable si le backend invalide les cles regulierement.
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/lib/ws-manager.ts
Lignes : 93-96, 98-101
send(content: string) {
if (this._status !== 'connected') return;
this.sendRaw({ type: 'message', apiKey: this.apiKey, content });
}
La cle API est envoyee dans chaque message apres authentification, pas seulement dans le message auth. Si le WebSocket est intercepte (downgrade attack, mauvaise config TLS), tous les messages exposent la cle.
Classification : RISQUE THEORIQUE
Recommendation : Apres auth_success, utiliser un session token ou ne plus envoyer la cle dans chaque message.
Les appels fetch() vers l'API utilisent X-API-Key en header. Pas de cookie, donc pas de CSRF au sens classique. Mais si l'API accepte aussi des cookies de session, le risque existe.
Classification : RISQUE THEORIQUE (probablement OK si l'API n'utilise que les API keys)
event.data untyped access avec as any partoutFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/EventCard.tsx
Lignes : 72-76 (event.data access), 134 (event.data?.title || event.data?.summary)
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/ChatMessages.tsx
Lignes : 52-86 (d?.model, d?.confidence, etc.)
Les types dans types.ts ne definissent pas model, confidence, reasoning, pipeline, duration, tools, success, originalMessage, toolCalls, iterations sur les interfaces existantes (ThinkingEvent.data n'a que iteration et maxIterations, par exemple). Le code accede a des champs non-types avec event.data?.model etc.
Classification : BUG PROBABLE Impact : Les types TypeScript ne protegent pas contre un changement cote serveur. Un champ renomme cote backend passera silencieusement.
useNavigate() utilise dans un handler onClick$Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/layout.tsx
Lignes : 261-265
onClick$={() => {
localStorage.removeItem('ulias_api_key');
document.dispatchEvent(new CustomEvent('ulias:disconnect'));
nav('/login');
}}
nav est le retour de useNavigate(). Il est capture dans une closure $(). En Qwik, les closures $() sont serialisees et le contenu capture doit etre serialisable. useNavigate() retourne une QRL qui est serialisable par Qwik, donc cela devrait fonctionner. Cependant, le document.dispatchEvent dans la meme closure accede a document qui est un objet browser — Qwik devrait pouvoir le gerer car c'est un global.
Classification : FAUX POSITIF (fonctionne correctement en Qwik)
Note : Reclassifie en FAUX POSITIF car le pattern est correct.
$() capturant des variables locales complexesFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/EventCard.tsx
Ligne : 305-308
const retryIdentical = $(() => {
const msg = event.data?.originalMessage;
if (!msg) return;
document.dispatchEvent(new CustomEvent('ulias:send', { detail: { content: msg } }));
});
La closure $() capture event (une prop). En Qwik, les props passees a un composant sont serialisees. Si event contient des objets profondement imbriques avec des fonctions ou des references circulaires, la serialisation echouera.
Classification : RISQUE THEORIQUE
Note : En pratique, ServerEvent est un objet JSON pur, donc serialisable.
useSignal dans EventCard pour chaque instanceFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/EventCard.tsx
Lignes : 174-188
const feedbackGiven = useSignal<-1 | 0 | 1>(0);
const showComment = useSignal(false);
const comment = useSignal('');
const isEditing = useSignal(false);
const editContent = useSignal('');
const correctedLocal = useSignal(isCorrected || false);
const showRetryEdit = useSignal(false);
const retryMessage = useSignal('');
const toolResultExpanded = useSignal(false);
9 signaux par instance de EventCard. Avec potentiellement des centaines d'evenements dans le chat, cela cree des centaines de signaux Qwik. Chaque signal est un objet avec un abonnement reactif.
Classification : RISQUE THEORIQUE Impact : Performance degradee avec beaucoup de messages. Pas un bug mais un anti-pattern pour les listes longues.
onScroll attache a containerRef.value jamais mis a jourFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/ChatMessages.tsx
Lignes : 214-216, 225
requestAnimationFrame(() => {
containerRef.value?.addEventListener('scroll', onScroll);
});
// ...
cleanup(() => {
containerRef.value?.removeEventListener('scroll', onScroll);
});
Si containerRef.value change entre le moment de l'attach (requestAnimationFrame) et le cleanup, le removeEventListener sera appele sur un element different de celui ou addEventListener a ete appele. L'ancien listener restera attache a l'ancien element.
Classification : RISQUE THEORIQUE
Note : En pratique, containerRef est un ref sur un div statique, donc l'element ne change probablement pas.
searchTimerRef jamais nettoye au cleanup (deja mentionne en 1.3)Combine avec le point 1.3 : aucun clearTimeout(searchTimerRef.value) dans la closure cleanup(). Si l'utilisateur tape dans la recherche puis quitte la page, le timer executera son callback sur un composant demonte.
Classification : BUG CERTAIN (deja compte en 1.3)
URL.createObjectURL correctement libereFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/sidebar/ConversationSidebar.tsx
Lignes : 289-297
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = ...;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
Le revokeObjectURL est appele immediatement apres click(). Le click() declenche un telechargement asynchrone — si le navigateur n'a pas encore lu le blob quand revokeObjectURL est appele, le telechargement peut echouer dans certains navigateurs.
Classification : RISQUE THEORIQUE
Recommendation : Ajouter un setTimeout(() => URL.revokeObjectURL(url), 1000).
catch sont silencieuxPresque tous les blocs try/catch dans le projet utilisent catch { /* silent */ } ou catch(() => {}) :
layout.tsx : lignes 105, 131, 173, 187, 311routes/index.tsx : ligne 29routes/decisions/index.tsx : ligne 76routes/history/index.tsx : lignes 76, 94ConversationSidebar.tsx : lignes 97, 109, 208, 224, 247, 265, 277, 299, 321, 345, 547ChatInput.tsx : catch affiche en console (ligne 77) mais utilise alert() - OKEventCard.tsx : lignes 222, 247, 277, 301Classification : BUG PROBABLE Impact : L'utilisateur n'a AUCUN feedback quand une operation reseau echoue (suppression, archivage, fork, pin, export, correction, etc.). L'UI reste dans un etat incoherent — le changement local est fait mais le serveur n'a pas recu l'action.
Aucun mecanisme de retry n'est implementé pour les appels API. Le WsManager a un reconnect avec backoff exponentiel (bon), mais les appels REST n'ont aucun retry.
Classification : RISQUE THEORIQUE Note : Le reconnect WS est bien fait (exponential backoff, max 5 tentatives, cleanup propre).
res.ok === false sans throwFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/layout.tsx
Lignes : 97-105
const res = await fetch(`${API_URL}/api/conversations/${convId}/messages`, {
headers: { 'X-API-Key': apiKey },
});
if (res.ok) {
// load messages
}
// else: silently ignored
Si le serveur retourne un 401 (cle expiree), 500, ou 404, l'utilisateur ne voit rien. Pire : apres un 401, le WS continue de fonctionner avec l'ancienne session.
Classification : BUG PROBABLE
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/prompts/index.tsx
Lignes : 186-266
Le modal d'edition de prompt est un <div class="fixed inset-0 bg-black/50 z-50 ...">. Il n'a :
role="dialog" ou aria-modal="true"aria-labelledbyClassification : BUG PROBABLE (violation WCAG 2.1 AA)
Quasiment tous les boutons d'action (pin, archive, supprimer, exporter, renommer, fork) n'ont que des SVG sans texte visible. Ils ont des title attributs mais pas d'aria-label.
Exemples :
ConversationSidebar.tsx lignes 441-501 : 6 boutons avec seulement titleChatMessages.tsx ligne 255 : bouton fork avec seulement titleEventCard.tsx ligne 371 : bouton fork avec seulement titlelayout.tsx lignes 314-316 : bouton annuler sans labeltitle est lu par les lecteurs d'ecran inconsistement (certains l'ignorent). aria-label est le standard.
Classification : BUG PROBABLE (violation WCAG)
routes/help/index.tsx, routes/history/index.tsx, monitor/EventStream.tsx utilisent des <div onClick$> sans role="button" ni tabIndex={0} ni onKeyDown$ pour Enter/Space.ConversationSidebar.tsx ligne 383) sont des <div onClick$> non focusables au clavier.routes/help/index.tsx n'ont pas role="region" / aria-expanded.Classification : RISQUE THEORIQUE (fonctionnel mais inaccessible au clavier)
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/ChatMessages.tsx
Lignes : 294-310 (streaming indicator)
Le statut streaming qui change en temps reel (routing, thinking, tool_call) n'a aucun aria-live="polite" ou role="status". Les utilisateurs de lecteurs d'ecran ne sont pas informes de la progression.
Classification : RISQUE THEORIQUE
Le systeme repose sur ~15 types de CustomEvent dispatches sur document :
| Evenement | Emetteur | Recepteur(s) |
|---|---|---|
ulias:event |
layout | ChatMessages, EventStream, Decisions, ConversationSidebar |
ulias:send |
ChatInput, SuggestionsBar, History, EventCard | layout (WS) |
ulias:user-message |
ChatInput, SuggestionsBar, routes/index | ChatMessages |
ulias:command |
(non utilise dans le code lu) | layout (WS) |
ulias:send-raw |
Decisions/EventCard | layout (WS) |
ulias:disconnect |
layout (logout button) | layout |
ulias:switch-conversation |
Sidebar, ChatMessages, EventCard | layout |
ulias:load-history |
layout | ChatMessages |
ulias:prepend-history |
layout | ChatMessages |
ulias:load-older |
ChatMessages | layout |
ulias:clear-chat |
layout | ChatMessages |
ulias:conversation-created |
layout, Sidebar, EventCard | Sidebar |
ulias:send-template-message |
Sidebar | layout |
ulias:message-corrected |
EventCard | ChatMessages |
Probleme : Aucun typage TypeScript sur les evenements CustomEvent. Un changement de nom ou de structure du detail casse silencieusement la communication. Pas de centralisation ni de documentation.
Classification : RISQUE THEORIQUE
Recommendation : Creer un module events.ts avec des fonctions typees dispatchSend(content: string), onEvent(handler: (e: ServerEvent) => void), etc.
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/lib/markdown.ts
Ligne : 21
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, ...);
Le [\s\S]*? est lazy mais cherche le premier ``` de fermeture. Si le markdown contient des backticks triples imbriques ou des code blocks sans fermeture, le rendu sera casse. Plus important : le escapeHtml est applique AVANT le parsing des code blocks, ce qui signifie que & dans le code est deja transforme en &, et sera affiche tel quel.
Mais aussi : la regex cherche ```\n (backtick + newline). Si l'input est ```js (sans newline car le newline a deja ete escapeHtml), la regex ne matchera pas.
Hmm, en fait escapeHtml ne touche pas \n, donc ca fonctionne.
Classification : RISQUE THEORIQUE (edge case avec markdown malformed)
javascript: URI dans les liens markdown.window.location.href = '/' force un full reloadFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/history/index.tsx
Ligne : 82
const retryObjective = $((description: string) => {
document.dispatchEvent(new CustomEvent('ulias:send', { detail: { content: description } }));
window.location.href = '/';
});
window.location.href force un reload complet de la page. L'evenement ulias:send dispatche juste avant sera perdu car le listener dans le layout sera detruit au reload. Le message ne sera jamais envoye.
Classification : BUG CERTAIN
Fix : Utiliser useNavigate() au lieu de window.location.href et s'assurer que le message est envoye apres la navigation.
checking.value early return dans le render de loginFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/login/index.tsx
Lignes : 82-88
if (checking.value) {
return (
<div class="flex items-center justify-center h-screen bg-base-200">
<span class="loading loading-spinner loading-lg text-primary" />
</div>
);
}
Ce return conditionnel dans un component$() est un pattern valide en Qwik.
Classification : FAUX POSITIF (pattern correct)
Note : Reclassifie.
decisions.value.filter() dans le corps du render, potentielle recalculation excessiveFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/decisions/index.tsx
Lignes : 95-96
const pendingDecisions = decisions.value.filter(d => !d.response);
const resolvedDecisions = decisions.value.filter(d => d.response);
Ces .filter() sont appeles a chaque render et ne sont pas memoises. Pas un bug fonctionnel mais un impact perf si la liste est longue. Cependant, .filter() ne mute PAS le tableau source, donc pas de boucle infinie.
Classification : RISQUE THEORIQUE (performance, pas fonctionnel)
totalAgents calcule dans le render bodyFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/teams/index.tsx
Ligne : 55
const totalAgents = teams.value.reduce((sum, t) => sum + (t.agents?.length ?? 0), 0);
Meme pattern que 9.3. Recalcule a chaque render. Acceptable pour de petites listes.
Classification : RISQUE THEORIQUE
teams/index.tsx n'envoie pas l'API keyFichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/teams/index.tsx
Ligne : 44
const res = await fetch(`${API_URL}/api/teams`);
Contrairement a toutes les autres pages qui envoient { 'X-API-Key': apiKey }, la page teams n'envoie aucun header d'authentification. Si l'endpoint /api/teams est protege, la requete echouera avec un 401.
Classification : BUG PROBABLE Impact : La page equipes pourrait ne rien afficher si l'API requiert une authentification.
| # | Severite | Fichier | Description |
|---|---|---|---|
| 1.3 | BUG CERTAIN | ConversationSidebar.tsx:77,327,334 | searchTimerRef jamais nettoye au cleanup |
| 1.4 | BUG CERTAIN | ConversationSidebar.tsx:151-156 | Polling 500ms infini apres toggle archive |
| 2.1 | BUG CERTAIN | markdown.ts:46, EventCard.tsx:407,423, history:376 | XSS via javascript: URI dans liens markdown |
| 9.1 | BUG CERTAIN | history/index.tsx:82 | window.location.href perd le message envoye |
| 1.5 | BUG PROBABLE | layout.tsx:167-169 | Race condition setTimeout(500) pour load history |
| 2.5 | BUG PROBABLE | EventCard.tsx, ChatMessages.tsx | Types TS incomplets, acces as any partout |
| 5.1 | BUG PROBABLE | 15+ fichiers | Tous les catch silencieux, pas de feedback utilisateur |
| 5.3 | BUG PROBABLE | layout.tsx:97-105 | Pas de gestion des 401/500 sur les fetch REST |
| 6.1 | BUG PROBABLE | prompts/index.tsx:186-266 | Modal sans trap de focus ni ARIA |
| 6.2 | BUG PROBABLE | Sidebar, EventCard, ChatMessages | Boutons SVG sans aria-label |
| 9.5 | BUG PROBABLE | teams/index.tsx:44 | API key manquante dans la requete fetch |
| 1.2 | RISQUE THEORIQUE | ChatMessages.tsx:22 | useStore mute depuis handler vanilla JS |
| 1.6 | RISQUE THEORIQUE | ConversationSidebar.tsx:201-203 | setTimeout(500) pour template message |
| 2.2 | RISQUE THEORIQUE | 15+ fichiers | API key en localStorage sans rotation |
| 2.3 | RISQUE THEORIQUE | ws-manager.ts:93-101 | API key envoyee dans chaque message WS |
| 3.3 | RISQUE THEORIQUE | EventCard.tsx:174-188 | 9 signaux par EventCard, centaines d'instances |
| 4.3 | RISQUE THEORIQUE | ConversationSidebar.tsx:289-297 | revokeObjectURL trop rapide |
| 6.3 | RISQUE THEORIQUE | history, decisions, help, monitor, sidebar | Navigation clavier impossible sur les div cliquables |
| 6.4 | RISQUE THEORIQUE | ChatMessages.tsx:294-310 | Pas de live region pour le streaming |
| 7.1 | RISQUE THEORIQUE | Architecture globale | Bus evenementiel non type, fragile |
Total : 4 BUG CERTAIN, 7 BUG PROBABLE, 10 RISQUE THEORIQUE
XSS (2.1) — Ajouter une validation href.startsWith('http') dans le regex des liens markdown, ou filtrer les protocoles dangereux (javascript:, data:, vbscript:).
Polling infini (1.4) — Remplacer le setInterval par un watcher reactif ou mettre a jour origShowArchived apres detection.
Timer leak (1.3) — Ajouter clearTimeout(searchTimerRef.value) dans le cleanup de useVisibleTask$.
Message perdu (9.1) — Remplacer window.location.href = '/' par useNavigate()('/').
API key manquante (9.5) — Ajouter l'header d'authentification dans teams/index.tsx.
Feedback utilisateur (5.1) — Ajouter des toasts ou alertes pour les erreurs reseau.
Accessibilite (6.1, 6.2) — Ajouter role="dialog", aria-modal, aria-label sur le modal et les boutons.