33800 Docs

← Retour

Audit approfondi ulias-org-web (frontend Qwik)

Date : 15/02/2026 Status : IMPLEMENTEE Auditeur : Claude Opus 4.6 Methode : Lecture integrale de chaque fichier, tracage des code paths reels Scope : 16 fichiers source + 2 composants complementaires


Synthese

Le projet est globalement bien structure. L'architecture "custom event bus via document.dispatchEvent" est coherente et evite les pieges Qwik de serialisation. Le WebSocket est correctement isole dans un useVisibleTask$ (pas dans useSignal). Cependant, plusieurs problemes meritent attention, classes par severite.


FINDINGS CONFIRMES

F01 — Sidebar polling 500ms (SEVERITE : MOYENNE)

Fichier : /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);

Analyse : Ce n'est PAS une boucle infinie de requetes reseau. Le setInterval tourne toutes les 500ms, mais il compare showArchived.value avec sa valeur initiale origShowArchived. La requete loadConversations() ne se declenche QUE si la valeur a change.

Probleme reel : L'intervalle est un workaround pour detecter un changement de signal Qwik depuis l'interieur d'un useVisibleTask$. C'est un pattern sous-optimal pour deux raisons :

  1. Il poll en boucle toutes les 500ms pour une condition qui change rarement (un clic utilisateur)
  2. origShowArchived est capture a la creation. Apres le premier changement, showArchived.value !== origShowArchived reste TOUJOURS vrai, donc chaque tick de 500ms declenchera un loadConversations() en boucle indefiniment.

Scenario : L'utilisateur clique "Voir archivees". showArchived passe a true. A partir de ce moment, le setInterval appelle loadConversations() toutes les 500ms sans jamais s'arreter.

De plus : Le bouton "Voir archivees" (lignes 537-548) fait deja un fetch direct dans son onClick$. Donc le premier rechargement se fait immediatement, mais ensuite le polling 500ms continue a re-fetcher en boucle.

Verdict : BUG CONFIRME. Apres le premier toggle de showArchived, les conversations sont re-fetched toutes les 500ms indefiniment.

Fix suggere : Supprimer le archiveInterval. Le bouton "Voir archivees" fait deja le fetch directement dans son handler onClick$ (lignes 537-548). Pas besoin de polling.


F02 — WS reconnexion limitee sans recovery (SEVERITE : MOYENNE)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/lib/ws-manager.ts Lignes : 6-7, 114-125

const MAX_RECONNECT_ATTEMPTS = 5;
const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];

Analyse : Apres 5 tentatives echouees (total ~31 secondes), le WS ne tentera plus jamais de se reconnecter. L'utilisateur verra un badge "Deconnecte" sans possibilite de retablir la connexion autrement qu'en rechargeant la page.

Probleme : Pas de mecanisme de recovery. Pas de bouton "Reconnecter". Une fois les 5 tentatives consommees, l'application est en mode dead-socket jusqu'au prochain F5.

Le compteur reconnectAttempts est reset a 0 sur auth_success (ligne 71), ce qui est correct pour les reconnexions reussies. Mais si le serveur est down 2 minutes (maintenance rapide), l'utilisateur est bloque.

Fix suggere :


F03 — Suppression de conversation sans confirmation (SEVERITE : MOYENNE)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/sidebar/ConversationSidebar.tsx Lignes : 211-225

const deleteConversation = $(async (convId: string) => {
    const apiKey = localStorage.getItem('ulias_api_key');
    if (!apiKey) return;
    try {
      await fetch(`${API_URL}/api/conversations/${convId}`, {
        method: 'DELETE',
        headers: { 'X-API-Key': apiKey },
      });
      conversations.value = conversations.value.filter(c => c.id !== convId);
      ...

Analyse : Le DELETE est execute immediatement, sans confirm() ni modal de confirmation. Un clic accidentel sur le bouton supprimer (visible au hover) detruit la conversation definitivement.

Meme probleme dans : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/history/index.tsx lignes 85-95 (deleteObjective).

Fix suggere : Ajouter un if (!confirm('Supprimer cette conversation ?')) return; ou une modale.


F04 — Markdown : double-echappement possible mais NON CONFIRME comme bug (SEVERITE : FAIBLE)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/lib/markdown.ts Lignes : 15-18

export function renderMarkdown(md: string): string {
  if (!md) return '';
  let html = escapeHtml(md);
  // Code blocks (``` ... ```)
  html = html.replace(/```(\w*)\n([\s\S]*?)```/g, ...);

Analyse : L'echappement HTML est fait EN PREMIER, puis les patterns markdown sont appliques. Voyons le flux pour un input contenant du code :

  1. Input : `<div>test</div>`
  2. Apres escapeHtml : `&lt;div&gt;test&lt;/div&gt;`
  3. Apres regex inline code : <code class="bg-base-300 px-1 rounded text-xs font-mono">&lt;div&gt;test&lt;/div&gt;</code>

Le HTML est correctement echappe dans le code inline. Pas de double-echappement ici, le flux est correct.

MAIS il y a un vrai probleme avec les code blocks :

  1. Input : ```\n<div>test</div>\n```
  2. Apres escapeHtml : les backticks deviennent ``` (inchanges) mais \n aussi inchange, <div> devient &lt;div&gt;
  3. La regex /```(\w*)\n([\s\S]*?)```/g cherche un vrai \n. Or escapeHtml ne touche pas les newlines, donc la regex fonctionne.

Resultat : Le code HTML dans les blocs affichera &lt;div&gt; au lieu de <div>. C'est le comportement ATTENDU (on veut voir le code source, pas l'executer). Pas de double-echappement.

Verdict : PAS de bug de double-echappement. Le flux escapeHtml-avant-markdown est correct pour ce cas d'usage. Mais il y a un probleme connexe :


F05 — API key dans les messages WS (SEVERITE : FAIBLE)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/lib/ws-manager.ts Lignes : 93-101

send(content: string) {
    if (this._status !== 'connected') return;
    this.sendRaw({ type: 'message', apiKey: this.apiKey, content });
}
sendCommand(cmd: string) {
    if (this._status !== 'connected') return;
    this.sendRaw({ type: 'command', apiKey: this.apiKey, cmd });
}

Analyse : L'API key est envoyee dans CHAQUE message WS, pas seulement dans le message d'auth initial. Cela augmente la surface d'exposition si les logs du serveur capturent les messages WS.

Contexte atenuant : La connexion est WSS (chiffree), et le service est en private:true (nginx). Le risque est faible.

Fix suggere : Apres authentification reussie, ne plus inclure l'apiKey dans les messages subsequents (utiliser un token de session cote serveur).


F06 — Race condition au chargement initial (SEVERITE : FAIBLE)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/layout.tsx Lignes : 166-169

setTimeout(() => {
    document.dispatchEvent(new CustomEvent('ulias:load-history', { detail: { messages: items } }));
}, 500);

Analyse : Le chargement de la derniere conversation est retarde de 500ms avec setTimeout pour "laisser ChatMessages mount". Si le composant ChatMessages n'est pas monte en 500ms (appareil lent, tab en arriere-plan), l'evenement sera dispatche mais personne ne l'ecoutera, et l'historique ne s'affichera pas.

Inversement, si la page est rapide, l'utilisateur voit un chat vide pendant 500ms avant que l'historique n'apparaisse.

Fix suggere : Utiliser un pattern "ready" : ChatMessages dispatche un evenement ulias:chat-ready dans son useVisibleTask$, et layout attend cet evenement avant de charger l'historique.


F07 — Erreurs silencieuses partout (SEVERITE : FAIBLE)

Fichiers concernes : Presque tous

Pattern observe :

} catch { /* silent */ }

Ce pattern est present dans :

Analyse : Aucune erreur reseau n'est jamais affichee a l'utilisateur pour les operations secondaires. Si le serveur repond 500 ou si le reseau coupe, l'utilisateur ne sait pas que son action a echoue.

Cas le plus critique : deleteConversation (F03) — si le DELETE echoue cote serveur, la conversation est quand meme supprimee de la vue locale (ligne 220: conversations.value = conversations.value.filter(...)). L'utilisateur croit avoir supprime mais la conversation est toujours la cote serveur.

Fix suggere : Au minimum, ne modifier l'etat local qu'APRES verification de res.ok. Eventuellement afficher un toast d'erreur.


F08 — useStore dans ChatMessages streaming (SEVERITE : FAIBLE, mais a surveiller)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/ChatMessages.tsx Ligne : 22

const streamInfo = useStore<StreamingInfo>({ ... });

Analyse : useStore est utilise pour les infos de streaming, et chaque propriete est modifiee individuellement (lignes 41-85). Les proprietes sont lues dans le JSX render (lignes 297-308).

Le risque du useStore dans le render est le meme que pour .sort() : si une mutation est faite PENDANT le render, cela peut causer une boucle. Ici, les mutations se font dans les event listeners (pas dans le render), donc pas de boucle infinie.

Verdict : Pas de bug. Le pattern est correct car les mutations sont declenchees par des events, pas par le render.


F09 — Equipes : endpoint sans authentification (SEVERITE : FAIBLE)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/teams/index.tsx Lignes : 42-52

useVisibleTask$(async () => {
    try {
      const res = await fetch(`${API_URL}/api/teams`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);

Analyse : Le fetch vers /api/teams ne passe PAS de header X-API-Key, contrairement a tous les autres endpoints. C'est soit un oubli (et la requete echouera si l'API exige l'auth), soit l'endpoint est volontairement public.

Risque : Si l'endpoint est public, il expose la liste des equipes et agents a quiconque. Si l'endpoint exige l'auth, cette page sera toujours en erreur.

Fix suggere : Ajouter le header X-API-Key comme les autres pages.


F10 — deleteObjective sans confirmation (SEVERITE : MOYENNE)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/history/index.tsx Lignes : 85-95

const deleteObjective = $(async (id: string) => {
    const apiKey = localStorage.getItem('ulias_api_key');
    if (!apiKey) return;
    try {
      await fetch(`${API_URL}/api/objectives/${id}`, {
        method: 'DELETE',
        headers: { 'X-API-Key': apiKey },
      });
      objectives.value = objectives.value.filter(o => o.id !== id);
    } catch { /* silent */ }
  });

Meme probleme que F03 : suppression immediate sans confirmation. De plus, l'etat local est modifie AVANT verification du succes de la requete (le catch ignore les erreurs).


F11 — XSS via dangerouslySetInnerHTML (SEVERITE : FAIBLE dans le contexte)

Fichiers :

dangerouslySetInnerHTML={renderMarkdown(summary)}

Analyse : Le renderMarkdown fait un escapeHtml en premier, ce qui neutralise les balises HTML injectees. Cependant, la regex des liens (ligne 46 de markdown.ts) genere des <a href="...">. Si un attaquant controle l'output d'un agent (via tool_result par exemple), il pourrait injecter un lien avec un scheme javascript: :

Input : [click](javascript:alert(1)) Apres escapeHtml : [click](javascript:alert(1)) (pas de caractere HTML a echapper) Apres regex : <a href="javascript:alert(1)" ...>click</a>

Toutefois : Le target="_blank" rel="noopener" est present, et les navigateurs modernes bloquent javascript: dans les liens. Le risque reel est minimal, mais le pattern est a noter.

Fix suggere : Filtrer les URLs dans la regex de liens pour n'accepter que http:, https: et mailto:.


F12 — Settings sauvegarde via PUT sans GET prealable (SEVERITE : FAIBLE)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/settings/index.tsx Lignes : 52-60

const res = await fetch(`${API_URL}/api/profile`, {
    method: 'PUT',
    headers: { ... },
    body: JSON.stringify({ preferences: profile.value }),
});

Analyse : Le PUT envoie { preferences: profile.value }. Si le profil a d'autres champs que preferences, le PUT pourrait les ecraser. Cependant, le body ne contient que preferences, donc si le backend ignore les champs absents, c'est OK.

Risque reel : Depend de l'implementation du backend. Si le backend fait un merge, pas de probleme. Si le backend remplace le profil entier, les champs non-preferences seront perdus.

Conforme a la regle : Le CLAUDE.md note "PUT = remplace TOUT". Mais ici, le frontend envoie un objet partiel sous la cle preferences, pas le profil entier. Le risque est faible si le backend traite preferences comme un sous-objet.


F13 — Negative feedback : double envoi possible (SEVERITE : TRES FAIBLE)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/EventCard.tsx Lignes : 486-494

onClick$={() => {
    feedbackGiven.value = -1;
    showComment.value = true;
}}

Puis bouton "Passer" (lignes 543-549) :

onClick$={() => {
    submitFeedback(-1);
    showComment.value = false;
}}

Analyse : Quand l'utilisateur clique "-" (negative feedback), feedbackGiven.value est mis a -1 MAIS submitFeedback n'est PAS appele. Un formulaire de commentaire s'affiche. Si l'utilisateur clique "Passer", submitFeedback(-1) est appele.

Si l'utilisateur clique "Envoyer" (avec commentaire), submitWithComment() est appele, qui fait un second POST. Pas de double envoi dans le flow normal.

Verdict : Pas de bug. Le flow est correct : le premier clic sur "-" n'envoie rien, il attend le commentaire ou le "Passer".


F14 — Layout WS connection sur toutes les pages sauf /login (SEVERITE : INFO)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/layout.tsx Lignes : 29-30

if (window.location.pathname === '/login' || window.location.pathname === '/login/') return;

Analyse : Le check est fait avec window.location.pathname (string exact), pas avec un match pattern. Si une future route comme /login/callback est ajoutee, le WS se connectera dessus. Ceci est une remarque de robustesse, pas un bug actuel.


F15 — Conversation sidebar : etat local desynchronise apres delete echoue (SEVERITE : MOYENNE)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/sidebar/ConversationSidebar.tsx Lignes : 215-220

await fetch(`${API_URL}/api/conversations/${convId}`, {
    method: 'DELETE',
    headers: { 'X-API-Key': apiKey },
});
conversations.value = conversations.value.filter(c => c.id !== convId);

Analyse : La modification de l'etat local (filter) est faite APRES le fetch, mais sans verification de res.ok. Si le serveur repond 403, 404 ou 500, le fetch ne throw pas (seules les erreurs reseau throw). La conversation est quand meme retiree de la vue.

Meme probleme dans : archiveConversation, togglePin, saveRename (mais pour ceux-ci, la consequence est moins grave — la vue sera corrigee au prochain rechargement).

Fix suggere :

const res = await fetch(...);
if (res.ok) {
  conversations.value = conversations.value.filter(c => c.id !== convId);
}

F16 — History page : retryObjective via window.location.href (SEVERITE : FAIBLE)

Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/history/index.tsx Lignes : 79-83

const retryObjective = $((description: string) => {
    document.dispatchEvent(new CustomEvent('ulias:send', { detail: { content: description } }));
    window.location.href = '/';
});

Analyse : window.location.href = '/' fait un rechargement complet de la page (full page navigation), pas un routing Qwik. Cela :

  1. Detruit le WS existant (le layout.tsx cleanup s'execute)
  2. Reconstruit tout (nouveau WS, re-auth)
  3. L'evenement ulias:send dispatche AVANT la navigation sera perdu car les listeners seront detruits par la navigation

Verdict : L'evenement ulias:send est dispatche puis immediatement la page est rechargee. Le message WS peut ou non etre envoye avant la destruction du WS. Race condition reelle.

Fix suggere : Utiliser le useNavigate() de Qwik-City au lieu de window.location.href, et envoyer le message apres la navigation (ou stocker dans localStorage pour le reprendre).


FINDINGS NON CONFIRMES (prevus dans l'audit precedent mais invalides)

P22 — "Sidebar polling 500ms boucle infinie de requetes"

Partiellement confirme. Ce n'est pas une boucle infinie de requetes des le demarrage. C'est une boucle infinie de requetes APRES le premier toggle de showArchived. Voir F01 pour les details.

P08 — "Markdown double echappement"

Non confirme. Le flux escapeHtml-avant-markdown est correct. Voir F04 pour l'analyse detaillee.


RESUME DES FINDINGS PAR SEVERITE

ID Severite Description
F01 MOYENNE Sidebar polling 500ms: boucle infinie de requetes apres toggle showArchived
F02 MOYENNE WS: 5 tentatives max sans recovery, utilisateur bloque
F03 MOYENNE Suppression conversation sans confirmation
F10 MOYENNE Suppression objectif sans confirmation
F15 MOYENNE Etat local desynchronise apres operation serveur echouee
F06 FAIBLE Race condition chargement initial (setTimeout 500ms)
F07 FAIBLE Erreurs reseau silencieuses partout
F09 FAIBLE Endpoint /api/teams sans auth header
F11 FAIBLE XSS potentiel via javascript: dans les liens markdown
F16 FAIBLE retryObjective: window.location.href perd le message
F04 FAIBLE Markdown: pas de bug mais liens avec & echappes en &amp; dans href
F05 FAIBLE API key dans chaque message WS (pas seulement auth)
F12 FAIBLE Settings PUT sans certitude que le backend merge
F14 INFO Check /login en string exact, pas en prefix

POINTS POSITIFS OBSERVES

  1. WebSocket correctement isole : Le WsManager est une classe vanilla JS, pas stocke dans un useSignal. Pattern conforme aux bonnes pratiques Qwik.
  2. Communication inter-composants via CustomEvent : Evite les problemes de serialisation Qwik. Bien pense.
  3. Cleanup systematique : Tous les useVisibleTask$ ont un cleanup() qui retire les event listeners et clear les intervals.
  4. Pas de .sort() dans le render : Aucune mutation de tableau dans le JSX. Conforme au bug connu documente.
  5. Infinite scroll bien implemente : Le pattern prepend + restore scroll position dans ChatMessages est correct.
  6. Types bien definis : types.ts couvre tous les events WS avec des interfaces distinctes.

RECOMMANDATIONS PRIORITAIRES

  1. F01 : Supprimer le archiveInterval dans ConversationSidebar — le fetch est deja fait dans le handler onClick$.
  2. F03 + F10 : Ajouter confirm() avant les suppressions.
  3. F15 : Verifier res.ok avant de modifier l'etat local apres un fetch.
  4. F02 : Ajouter un bouton "Reconnecter" ou un slow-retry apres les 5 tentatives.
  5. F09 : Ajouter le header X-API-Key dans la page teams.