Date : 15/02/2026
Status : IMPLEMENTEE
Auditeur : Claude Opus 4.6 (audit independant)
Projet : /stock_8to/33800-stack/projects/ulias-org-web/
Backend de reference : /stock_8to/33800-stack/projects/ulias-org/packages/server/src/index.ts
| Severite | Nombre |
|---|---|
| BUG CERTAIN | 4 |
| BUG PROBABLE | 7 |
| RISQUE THEORIQUE | 10 |
| Total | 21 |
Les findings les plus critiques concernent :
dangerouslySetInnerHTML sur du contenu genere par le markdown renderer, dont l'echappement est insuffisant (F-06)/stock_8to/33800-stack/projects/ulias-org-web/src/lib/types.ts:52-61RoutingEvent.data ne declare que { agent: string; team: string }, mais le backend emet { agent, team, confidence, reasoning, multi_step, pipeline, model }. Le code EventCard.tsx et ChatMessages.tsx accede directement a event.data.confidence, event.data.model, event.data.reasoning, event.data.pipeline sans que ces champs existent dans le type. Cela ne casse pas a l'execution (acces optionnel via ?.) mais TypeScript devrait rejeter ces acces sauf si event est as any — ce qui est effectivement le cas dans getEventSummary() car event.data est type { agent: string; team: string } mais le code accede a .confidence, .model, .reasoning, .pipeline.
// types.ts — definition incomplete
export interface RoutingEvent {
data: { agent: string; team: string; };
}
// EventCard.tsx:72-76 — accede a des champs inexistants dans le type
case 'routing': {
const d = event.data;
const conf = d.confidence ? (${Math.round(d.confidence * 100)}%) : '';
const model = d.model ? via ${d.model} : '';
const pipeline = d.pipeline ? | pipeline: ${d.pipeline} : '';
- **Impact** : TypeScript est contourne ; le code fonctionne uniquement parce que les types sont trop larges en pratique (union type avec `data: any` dans certains cas). Si un refactoring futur applique des types stricts, ces acces casseront.
- **Fix suggere** : Completer l'interface `RoutingEvent.data` avec tous les champs emis par le backend : `confidence: number; reasoning: string; multi_step: boolean; pipeline: string | null; model: string;`.
---
### F-02 : Types ThinkingEvent incomplets — champs `model`, `tools`, `agent` absents
- **Severite** : BUG CERTAIN
- **Fichier** : `/stock_8to/33800-stack/projects/ulias-org-web/src/lib/types.ts:63-72`
- **Description** : L'interface `ThinkingEvent.data` ne declare que `{ iteration: number; maxIterations: number }`, mais le backend emet `{ iteration, maxIterations, model, agent, tools }`. Le code `ChatMessages.tsx:52` accede a `d.model` et `EventCard.tsx:80-82` accede a `d.model` et `d.tools`, qui n'existent pas dans le type.
- **Code concerne** :
```typescript
// types.ts
data: { iteration: number; maxIterations: number; };
// ChatMessages.tsx:52
streamInfo.model = d?.model || streamInfo.model;
// EventCard.tsx:81
const model = d.model ? ` [${d.model}]` : '';
const tools = d.tools ? ` ${d.tools} outils` : '';
model?: string; agent?: string; tools?: number; a ThinkingEvent.data.duration, success absents/stock_8to/33800-stack/projects/ulias-org-web/src/lib/types.ts:85-94ToolResultEvent.data ne declare que { tool: string; result: string }, mais le backend emet { tool, result, duration, success }. Le code EventCard.tsx:87-90 accede a d.duration et d.success, et ChatMessages.tsx:65 accede a event.data?.duration.
// types.ts
data: { tool: string; result: string; };
// EventCard.tsx:87-89
const dur = d.duration ? (${formatDuration(d.duration)}) : '';
const status = d.success === false ? ' ERREUR' : '';
// ChatMessages.tsx:65 const dur = event.data?.duration;
- **Impact** : Meme que F-01.
- **Fix suggere** : Ajouter `duration?: number; success?: boolean;` a `ToolResultEvent.data`.
---
### F-04 : Types SubTaskStartedEvent/CompletedEvent incomplets — champs `model`, `duration`, `toolCalls`, `iterations` absents
- **Severite** : BUG CERTAIN
- **Fichier** : `/stock_8to/33800-stack/projects/ulias-org-web/src/lib/types.ts:156-181`
- **Description** : `SubTaskStartedEvent.data` ne declare pas `model`. `SubTaskCompletedEvent.data` ne declare pas `duration`, `toolCalls`, `iterations`. Le code `EventCard.tsx:105-112` accede a ces champs.
- **Code concerne** :
```typescript
// EventCard.tsx:105
const model = d.model ? ` [${d.model}]` : '';
// EventCard.tsx:110-112
const dur = formatDuration(d.duration);
return `Etape ... — ${dur}, ${d.toolCalls || 0} outils, ${d.iterations || 0} iter`;
getEventSummary accede a event.data?.title et event.data?.summary pour les events WS de conversation, mais ces events n'ont pas de champ data/stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/EventCard.tsx:130-134conversation_set, conversation_created, conversation_title_updated, conversation_summary_updated n'ont PAS de champ data dans les types — ils utilisent des champs top-level (conversationId, title, summary). Le code accede a event.data?.title || event.data?.summary qui retournera toujours '' car event.data est undefined pour ces types d'events.case 'conversation_set':
case 'conversation_created':
case 'conversation_title_updated':
case 'conversation_summary_updated':
return event.data?.title || event.data?.summary || '';
(event as any).title || (event as any).summary || ''.dangerouslySetInnerHTML — injection potentielle via liens MarkdownSeverite : BUG PROBABLE
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/lib/markdown.ts:46
Description : Le rendu Markdown effectue escapeHtml() en premier (bien), puis applique les regexes de transformation. CEPENDANT, la regex pour les liens [text](url) reconstruit une balise <a href="$2"> avec le contenu de $2 provenant du Markdown DEJA HTML-echappe. Cela signifie que l'URL a ete echappee (& au lieu de &), ce qui corrompt les URLs avec des parametres de requete.
Plus important : l'echappement HTML se fait AVANT la transformation Markdown, donc les backticks du code inline sont echappes en premier, mais les regexes de liens sont appliquees APRES. Si le contenu LLM genere un lien malicieux comme [click](javascript:alert(1)), l'echappement ne protege pas car javascript:alert(1) ne contient pas de caracteres HTML speciaux — il sera injecte tel quel dans le href.
Code concerne :
// markdown.ts:46
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" class="link link-primary" target="_blank" rel="noopener">$1</a>');
Impact : XSS via javascript: URIs dans les reponses Markdown de l'IA. Le vecteur est : le LLM genere une reponse contenant [click](javascript:alert(document.cookie)), le Markdown renderer le transforme en lien cliquable, l'utilisateur clique.
Fix suggere : Ajouter une validation du protocole URL : rejeter les href qui ne commencent pas par http://, https://, ou /. Par exemple : if (!url.match(/^https?:\/\/|^\//)) return text_only;.
& a cause de l'echappement HTML pre-transformation/stock_8to/33800-stack/projects/ulias-org-web/src/lib/markdown.ts:9-13 + 46escapeHtml() est applique AVANT les transformations Markdown. Cela signifie que [lien](https://example.com?a=1&b=2) devient [lien](https://example.com?a=1&b=2) avant la regex des liens, et le href final contient & au lieu de &.function escapeHtml(str: string): string {
return str.replace(/&/g, '&').replace(/</g, '<')...
}
export function renderMarkdown(md: string): string {
let html = escapeHtml(md); // <<< tout est echappe en premier
// ... puis les regexes reconstruisent du HTML
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" ...>$1</a>'); // $2 contient des & au lieu de &
}
ObjectiveCompletedEvent.data.originalMessage accede mais absent du type/stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/EventCard.tsx:306,319event.data?.originalMessage pour les events objective_completed et objective_failed, mais le type ObjectiveCompletedEvent.data ne contient pas de champ originalMessage, et l'examen du backend (director/index.ts:330-350) montre que les events objective_completed et objective_failed emis par le backend ne contiennent PAS de originalMessage dans leur data. Ce champ est donc toujours undefined.// EventCard.tsx:319
const hasOriginalMessage = (event.type === 'objective_completed' || event.type === 'objective_failed')
&& event.data?.originalMessage;
// Toujours falsy car originalMessage n'est jamais emis
originalMessage dans les events objective_completed/failed, soit supprimer cette feature du frontend.Severite : BUG PROBABLE
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/sidebar/ConversationSidebar.tsx:151-156
Description : Le code capture showArchived.value au moment de l'initialisation dans origShowArchived, puis cree un setInterval qui compare showArchived.value !== origShowArchived toutes les 500ms. Mais origShowArchived est la valeur capturee lors de la premiere execution du task visible, et showArchived.value est un signal Qwik. La comparaison ne fonctionne pas comme attendu car le setInterval ne re-read pas dynamiquement la valeur du signal — il capture la reference du signal une fois.
En realite, l'appel a showArchived.value dans le setInterval DEVRAIT lire la valeur reactive, mais le vrai probleme est que la comparaison est toujours false car origShowArchived est la valeur initiale (false), et la premiere fois qu'on clique sur le bouton "Voir archivees", le handler onClick$ du bouton (ligne 537-547) effectue DEJA le fetch et met a jour conversations.value. Le setInterval fait donc un reload redondant et inutile.
De plus, le setInterval ne reset jamais origShowArchived apres detection d'un changement, donc apres le premier changement, il reload les conversations toutes les 500ms indefiniment.
Code concerne :
const origShowArchived = showArchived.value;
const archiveInterval = setInterval(() => {
if (showArchived.value !== origShowArchived) {
loadConversations(); // << appele toutes les 500ms des que ca change
}
}, 500);
Impact : Apres le premier toggle de "Voir archivees", le code appelle loadConversations() toutes les 500ms indefiniment, causant un flood de requetes API inutiles vers /api/conversations.
Fix suggere : Supprimer ce setInterval entierement. Le handler onClick$ du bouton effectue deja le fetch.
useStore pour streamInfo dans ChatMessages — risque de serialisation QwikSeverite : BUG PROBABLE
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/components/chat/ChatMessages.tsx:22
Description : streamInfo utilise useStore<StreamingInfo> avec des proprietes simples (toutes des strings). Ce n'est pas un bug de serialisation en soi (les strings sont serialisables). Cependant, le useStore est mute directement dans les handlers du useVisibleTask$ (ex: streamInfo.agent = '', streamInfo.model = d?.model). Ces mutations se font dans un handler d'event DOM vanilla (pas dans un handler Qwik $), ce qui est correct dans un useVisibleTask$ car le code tourne entierement cote client.
Le vrai risque est que si Qwik serialise/deserialise ce store pendant le rendu SSR, les valeurs seront perdues car l'event listener n'est attache que cote client.
Code concerne :
const streamInfo = useStore<StreamingInfo>({
agent: '', model: '', text: '', iteration: '', maxIterations: '', tool: '', toolDuration: ''
});
Impact : Risque faible — le store est correctement initialise avec des valeurs par defaut vides, et les mutations se font uniquement cote client via useVisibleTask$. Pas de bug observe, mais la granularite du store (chaque champ declenche un re-render) pourrait causer des re-renders excessifs pendant le streaming.
Fix suggere : Considerer remplacer par un useSignal<StreamingInfo> avec remplacement complet de l'objet plutot que des mutations champ par champ.
/ws/cli au lieu de /ws/webui/stock_8to/33800-stack/projects/ulias-org-web/conf.prod.gouroubleu.yml:16PUBLIC_WS_URL est wss://ulias-org.33800.nowhere84.com/ws/cli, et le fallback dans layout.tsx:9 est identique. Le backend expose DEUX endpoints WebSocket : /ws/cli (pour le CLI) et /ws/webui (pour le Web UI). Bien que les deux endpoints utilisent le meme handler (handleWsMessage), la distinction existe dans les logs du backend ([WS] CLI connected vs [WS] WebUI connected). Le frontend Web utilise le endpoint CLI, ce qui fait que les logs backend identifient les connexions Web comme des connexions CLI.
# conf.prod.gouroubleu.yml:16
PUBLIC_WS_URL en wss://ulias-org.33800.nowhere84.com/ws/webui./api/teams appele sans authentification/stock_8to/33800-stack/projects/ulias-org-web/src/routes/teams/index.tsx:44/api/teams ne passe pas le header X-API-Key. Cote backend, l'endpoint /api/teams (ligne 564 de index.ts) ne verifie pas l'authentification (headers['x-api-key'] n'est pas lu). Ce n'est donc pas un bug fonctionnel, mais c'est incoherent avec toutes les autres pages qui envoient l'API key.// teams/index.tsx:44
const res = await fetch(`${API_URL}/api/teams`);
// Pas de headers X-API-Key
headers: { 'X-API-Key': apiKey } et proteger l'endpoint cote backend./api/status appele sans authentification (verification API key dans login)/stock_8to/33800-stack/projects/ulias-org-web/src/routes/login/index.tsx:25-28/api/status avec le header X-API-Key pour verifier si une cle est encore valide. Cote backend, /api/status (ligne 456) ne verifie PAS le header et retourne toujours 200 OK avec le status. La "verification" de la cle API fonctionne uniquement parce que res.ok est toujours true — la cle n'est jamais vraiment validee.// login/index.tsx:25-28
fetch(`${API_URL}/api/status`, {
headers: { 'X-API-Key': existingKey },
}).then(res => {
if (res.ok) nav('/'); // Toujours true car /api/status ne verifie pas la cle
/ au lieu du login. Le WebSocket echouera ensuite avec auth_error, ce qui renverra vers /login. L'experience utilisateur est degradee (flash d'ecran)./api/profile) ou ajouter la verification d'auth a /api/status.decision_request ecoute dans decisions/index.tsx mais jamais emis par le layout/stock_8to/33800-stack/projects/ulias-org-web/src/routes/decisions/index.tsx:52ulias:event et filtre event?.type === 'decision_request'. Le backend emet effectivement des events de type decision_request (via l'AgentRunner quand un outil necessite approbation), mais ces events sont emis directement via WS et re-dispatches par le layout comme ulias:event. Le type decision_request n'est PAS dans le type union ServerEvent dans types.ts, et n'est donc pas dans le discriminant union — mais cela fonctionne quand meme car l'event est un objet JSON brut.// decisions/index.tsx:52
if (event?.type === 'decision_request') {
fetchDecisions(); // refresh on new decision
}
decision_request est absent de ServerEvent dans types.ts.DecisionRequestEvent au type union ServerEvent.decision_response envoye via ulias:send-raw sans champ apiKeySeverite : RISQUE THEORIQUE
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/decisions/index.tsx:63-68
Description : Les reponses aux decisions sont envoyees via ulias:send-raw avec le format { type: 'decision_response', decisionId, response }. Le sendRaw dans ws-manager envoie directement le message tel quel. Le backend dans handleWsMessage (ligne 401) cherche msg.type === 'decision_response' et accede a msg.decisionId et msg.response — il n'a PAS besoin de apiKey pour ce type de message (pas de session lookup). Cela fonctionne correctement.
Cependant, le sendRaw est type comme ClientMessage qui ne contient pas de decision_response type. Le layout utilise as any pour contourner.
Code concerne :
// ws-manager.ts:108
sendRaw(msg: ClientMessage) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
// layout.tsx:77 — le handleSendRaw passe detail directement sans verification de type
- **Impact** : Faible. Le type `ClientMessage` ne couvre pas tous les messages reellement envoyes, mais le code fonctionne car les handlers passent `as any`.
- **Fix suggere** : Ajouter `DecisionResponseMessage` a `ClientMessage` et `SetConversationMessage` egalement.
---
### F-16 : Reconnexion WebSocket limitee a 5 tentatives sans possibilite de reset manuel
- **Severite** : RISQUE THEORIQUE
- **Fichier** : `/stock_8to/33800-stack/projects/ulias-org-web/src/lib/ws-manager.ts:6-7,114-125`
- **Description** : Apres 5 tentatives de reconnexion echouees, le WebSocket reste deconnecte definitivement. Il n'y a pas de bouton "Reconnecter" dans l'UI (le `ConnectionBadge` est en read-only). L'utilisateur doit recharger la page.
- **Code concerne** :
```typescript
const MAX_RECONNECT_ATTEMPTS = 5;
private scheduleReconnect() {
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
// ...
}
reconnectAttempts et appelle connect(), ou implementer un reset periodique du compteur (ex: reset apres 5 min de deconnexion).window.location.href au lieu de useNavigate dans history/index.tsx/stock_8to/33800-stack/projects/ulias-org-web/src/routes/history/index.tsx:82retryObjective utilise window.location.href = '/' pour naviguer vers la page chat. Cela force un rechargement complet de la page (full page navigation), ce qui detruit l'etat WebSocket, l'etat de toutes les pages, et force une reconnexion.const retryObjective = $((description: string) => {
document.dispatchEvent(new CustomEvent('ulias:send', { detail: { content: description } }));
window.location.href = '/'; // Full page reload
});
ulias:send sera perdu car le listener n'existe plus apres le reload.useNavigate() de Qwik City. Mais attention : le ulias:send doit etre envoye APRES la navigation, pas avant.loadLastConversation utilise un setTimeout de 500ms fragile/stock_8to/33800-stack/projects/ulias-org-web/src/routes/layout.tsx:167-169setTimeout(500) pour attendre que le composant ChatMessages soit monte et que son useVisibleTask$ ait eu le temps de s'attacher aux events. Ce delai est arbitraire et pourrait etre trop court sur des machines lentes ou trop long sur des machines rapides.setTimeout(() => {
document.dispatchEvent(new CustomEvent('ulias:load-history', { detail: { messages: items } }));
}, 500);
ulias:chat-ready quand il est pret, et le layout attend cet event avant d'envoyer l'historique).setConversation envoie un message WS avec type non couvert par ClientMessage/stock_8to/33800-stack/projects/ulias-org-web/src/lib/ws-manager.ts:103-106setConversation() appelle sendRaw() avec { type: 'set_conversation', apiKey, conversationId } mais utilise as any car ClientMessage ne contient pas ce type.setConversation(conversationId: string | null) {
if (this._status !== 'connected') return;
this.sendRaw({ type: 'set_conversation', apiKey: this.apiKey, conversationId } as any);
}
set_conversation.SetConversationMessage a ClientMessage./stock_8to/33800-stack/projects/ulias-org-web/src/components/sidebar/ConversationSidebar.tsx:537-547onClick$ du bouton "Voir archivees" fait son propre fetch (lignes 541-547) en plus de toggler showArchived.value. Le setInterval (F-09) detecte aussi le changement et lance loadConversations(). Il y a donc potentiellement deux fetches concurrents au moment du toggle.onClick$={() => {
showArchived.value = !showArchived.value;
// Inline fetch
const status = !showArchived.value ? 'active' : 'all';
fetch(`${API_URL}/api/conversations?status=${status}`, ...)
.then(...)
}}
Severite : RISQUE THEORIQUE
Fichier : /stock_8to/33800-stack/projects/ulias-org-web/src/routes/login/index.tsx:69
Description : L'API key est stockee dans localStorage sous la cle ulias_api_key. Toutes les requetes API utilisent cette cle dans le header X-API-Key. Le localStorage est accessible a tout JavaScript sur le meme domaine, y compris les scripts injectes via XSS. Combine avec F-06 (XSS potentiel via liens Markdown), un attaquant pourrait voler l'API key.
Il n'y a pas de protection CSRF sur les mutations (POST, PUT, PATCH, DELETE) car l'authentification est par header et non par cookie. Cela est en fait une bonne chose pour CSRF — les headers personnalises ne sont pas envoyes automatiquement par les navigateurs.
Code concerne :
localStorage.setItem('ulias_api_key', data.apiKey);
// Utilise ensuite via:
localStorage.getItem('ulias_api_key');
Impact : Risque d'exfiltration de l'API key si XSS est exploitee. L'infra est privee (private: true dans nginx), ce qui reduit considerablement le risque.
Fix suggere : Mitiger le XSS (cf F-06). Considerer sessionStorage au lieu de localStorage pour limiter la persistence.
Le projet utilise un pattern de communication inter-composants base sur document.dispatchEvent(new CustomEvent('ulias:...')). Voici la cartographie complete :
| Event | Emetteur | Listener(s) |
|---|---|---|
ulias:event |
layout.tsx (onEvent WS) | ChatMessages, EventStream, ConversationSidebar, decisions/index, layout.tsx (handleClearActive) |
ulias:send |
ChatInput, index.tsx, history, ChatMessages (fork) | layout.tsx |
ulias:user-message |
ChatInput, index.tsx (onSuggestionSelect), layout.tsx (handleTemplateMessage) | ChatMessages |
ulias:command |
(aucun emetteur visible dans le code) | layout.tsx |
ulias:send-raw |
decisions/index | layout.tsx |
ulias:disconnect |
layout.tsx (bouton Deconnexion) | layout.tsx |
ulias:switch-conversation |
ConversationSidebar, ChatMessages (fork), EventCard (fork) | layout.tsx |
ulias:load-older |
ChatMessages | layout.tsx |
ulias:load-history |
layout.tsx | ChatMessages |
ulias:prepend-history |
layout.tsx | ChatMessages |
ulias:clear-chat |
layout.tsx | ChatMessages |
ulias:message-corrected |
EventCard | ChatMessages |
ulias:conversation-created |
layout.tsx, ChatMessages (fork), EventCard (fork), ConversationSidebar | ConversationSidebar |
ulias:send-template-message |
ConversationSidebar | layout.tsx |
Event orphelin : ulias:command a un listener dans layout.tsx mais aucun emetteur dans le code frontend.
Le WsManager gere correctement le cycle de vie WebSocket :
destroy() appelee dans le cleanup du useVisibleTask$Aucun probleme de serialisation d'objets non-serialisables (WebSocket, DOM elements, etc.) n'a ete detecte. Les composants utilisent correctement useVisibleTask$ pour le code browser-only et useSignal/useStore pour les donnees reactives. La MEMORY.md mentionne le bug connu "WebSocket dans useSignal" — le code actuel evite correctement ce pattern en gardant le WsManager dans le scope de useVisibleTask$.
Aucune mutation de tableau (.sort(), .reverse(), .splice()) dans le code JSX n'a ete detectee. Le filtering dans history/index.tsx (lignes 173-184) et decisions/index.tsx (lignes 95-96) utilise .filter() qui retourne un nouveau tableau sans muter l'original.