# Tickets correctifs — PR `feat/audit-log` (4e passe) > Issus de la review du 2026-04-23 (`REVIEW.md`). > Chaque ticket est autonome : pourquoi, quoi faire, fichiers concernes. > Commence par les P0. Un commit par ticket (message `fix(T-XXX) : description`). --- ## P0 — Urgents (securite avant mise en prod) ### T-001 — Fermer `/api/docs` en production **Pourquoi :** la doc Swagger expose aujourd'hui publiquement la liste de tous les endpoints, les permissions RBAC demandees, les filtres disponibles, les DTOs. Pour un outil interne c'est inutile et ca donne a un attaquant la carte complete de la surface d'attaque sans meme avoir a se connecter. **A faire :** 1. Ouvrir `config/packages/security.yaml`. 2. Supprimer la ligne qui ouvre `/api/docs` : ```yaml # AVANT access_control: - { path: ^/login_check, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/api/sidebar, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } # APRES (on supprime simplement la 2e ligne) access_control: - { path: ^/login_check, roles: PUBLIC_ACCESS } - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/api/modules, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/api/sidebar, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } ``` Comme `/api/docs` tombe desormais dans le dernier pattern (`^/api`), il faudra etre authentifie pour le voir. Les devs continueront de l'utiliser apres login — les attaquants non. 3. Recharger : `make cache-clear` puis `make restart`. 4. Tester : `curl -i https://coltura.malio-dev.fr/api/docs` doit retourner `401 Unauthorized` (avant : `200`). **Fichiers :** `config/packages/security.yaml` --- ### T-002 — Ajouter les en-tetes de securite HTTP de base en prod **Pourquoi :** sans `X-Frame-Options`, quelqu'un peut integrer Coltura dans une iframe sur un site tiers et faire du clickjacking (faire croire a l'utilisateur qu'il clique sur un bouton anodin alors qu'il valide une action dans Coltura). Sans `X-Content-Type-Options: nosniff`, un navigateur peut deviner le type MIME et executer un fichier qui n'aurait pas du l'etre. Ce sont 3 lignes de config Nginx pour proteger l'application. **A faire :** 1. Ouvrir `infra/prod/nginx-proxy.conf` (c'est le proxy expose au public). 2. Ajouter juste apres `server_name coltura.malio-dev.fr;` : ```nginx # En-tetes de securite applicables a toutes les reponses add_header X-Frame-Options "DENY" always; add_header X-Content-Type-Options "nosniff" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; ``` Explication : - `X-Frame-Options: DENY` : personne ne peut mettre Coltura dans une iframe. - `X-Content-Type-Options: nosniff` : le navigateur ne devine pas les types MIME, il fait confiance a ce que le serveur annonce. - `Referrer-Policy: strict-origin-when-cross-origin` : limite ce que Coltura envoie comme Referer a des sites externes (evite de leaker `/admin/users/42` a un site tiers). - `always` : envoyer ces en-tetes meme sur les reponses d'erreur (4xx/5xx). 3. Recharger Nginx : `docker restart nginx-coltura` (ou celui qui fait office de proxy public). 4. Verifier : `curl -I https://coltura.malio-dev.fr/` doit afficher ces trois en-tetes. **Note :** si un reverse proxy externe (Traefik, Cloudflare) ajoute deja ces en-tetes, les poser ici ne fait que dupliquer, c'est sans risque (meme valeur). **Fichiers :** `infra/prod/nginx-proxy.conf` --- ### T-003 — Interdire l'indexation par les moteurs de recherche **Pourquoi :** `frontend/public/robots.txt` autorise actuellement Google et compagnie a indexer toutes les pages. Pour un CRM interne, pas besoin que les URLs de pages admin, de fiches clients, etc. remontent dans les resultats de recherche. Meme si le contenu est protege par login, la simple enumeration des URLs publiques est un leak. **A faire :** 1. Ouvrir `frontend/public/robots.txt`. 2. Remplacer le contenu par : ``` User-Agent: * Disallow: / ``` **Fichiers :** `frontend/public/robots.txt` --- ## P1 — Importants (bugs silencieux + qualite) ### T-004 — Valider/typer le filtre `performed_at` sur le journal d'audit **Pourquoi :** quand un utilisateur envoie `?performed_at[after]=pasunedate`, PostgreSQL n'arrive pas a caster la chaine en timestamp et leve une erreur. L'API retourne un 500 (erreur serveur) au lieu du 400 propre (erreur client) qu'elle devrait. En plus, les logs d'erreur se remplissent inutilement. **A faire :** 1. Ouvrir `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`. 2. En haut du fichier, ajouter les imports suivants si absents : ```php use Doctrine\DBAL\Types\Types; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; ``` 3. Dans `applyFilters()`, remplacer les blocs `performed_at_after` et `performed_at_before` : ```php // AVANT if (isset($filters['performed_at_after'])) { $qb->andWhere('performed_at >= :performed_at_after') ->setParameter('performed_at_after', $filters['performed_at_after']); } if (isset($filters['performed_at_before'])) { $qb->andWhere('performed_at <= :performed_at_before') ->setParameter('performed_at_before', $filters['performed_at_before']); } ``` ```php // APRES if (isset($filters['performed_at_after'])) { try { $after = new \DateTimeImmutable($filters['performed_at_after']); } catch (\Throwable) { throw new BadRequestHttpException('performed_at[after] doit etre une date ISO 8601 valide.'); } $qb->andWhere('performed_at >= :performed_at_after') ->setParameter('performed_at_after', $after, Types::DATETIMETZ_IMMUTABLE); } if (isset($filters['performed_at_before'])) { try { $before = new \DateTimeImmutable($filters['performed_at_before']); } catch (\Throwable) { throw new BadRequestHttpException('performed_at[before] doit etre une date ISO 8601 valide.'); } $qb->andWhere('performed_at <= :performed_at_before') ->setParameter('performed_at_before', $before, Types::DATETIMETZ_IMMUTABLE); } ``` 4. Tester : `curl -i -b cookie.txt 'http://localhost:8083/api/audit-logs?performed_at%5Bafter%5D=pasunedate'` doit retourner `400 Bad Request`, plus `500`. 5. Tester aussi avec une date valide : `curl ... 'performed_at%5Bafter%5D=2026-01-01T00:00:00Z'` doit retourner `200` avec les bons resultats. **Fichiers :** `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php` --- ### T-005 — Ajouter la clause `ESCAPE` sur le filtre ILIKE `performed_by` **Pourquoi :** le filtre "recherche contient" sur le nom d'utilisateur utilise `ILIKE`. Quand un user cherche `admin_backup`, le `_` est un caractere special LIKE qui matche "n'importe quel caractere". Le code essaie d'echapper avec `\_` mais **il faut dire a PostgreSQL que `\` est l'echappement** via la clause `ESCAPE`. Sans ca, sur certaines configs PG, `\_` devient deux caracteres distincts et l'echappement est cassé. Concretement : chercher `admin_backup` peut faire remonter aussi `adminXbackup`, `admin1backup`, etc. **A faire :** 1. Ouvrir `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php`. 2. Dans `applyFilters()`, modifier le bloc `performed_by` : ```php // AVANT (lignes ~171-180) if (isset($filters['performed_by'])) { // Recherche contains insensible a la casse pour matcher "adm" → "admin". // On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient // interpretes comme caracteres litteraux (sinon `%` matche tout, `_` // matche n'importe quel caractere). Pas de clause ESCAPE : `\` est // deja le caractere d'echappement LIKE par defaut en PostgreSQL. $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']); $qb->andWhere('performed_by ILIKE :performed_by') ->setParameter('performed_by', '%'.$escaped.'%') ; } ``` ```php // APRES if (isset($filters['performed_by'])) { // Recherche contains insensible a la casse pour matcher "adm" → "admin". // On echappe `%`, `_` et `\` pour qu'ils soient traites comme caracteres // litteraux. La clause ESCAPE est EXPLICITE : en SQL standard, LIKE n'a // pas d'echappement par defaut, il faut le declarer. $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']); $qb->andWhere("performed_by ILIKE :performed_by ESCAPE '\\\\'") ->setParameter('performed_by', '%'.$escaped.'%') ; } ``` Note : les 4 `\` en PHP donnent 2 `\` dans la string SQL, qui devient 1 `\` apres parsing PostgreSQL. C'est la chaine d'echappement correcte. 3. Tester en psql : creer une entree avec `performed_by = 'admin_backup'`, puis : ```bash curl -b cookie.txt 'http://localhost:8083/api/audit-logs?performed_by=admin_backup' # → doit retourner UNIQUEMENT admin_backup, pas adminXbackup ou autres ``` **Fichiers :** `src/Module/Core/Infrastructure/ApiPlatform/State/Provider/AuditLogProvider.php` --- ### T-006 — Refuser explicitement les appelants non-`User` dans `SiteAwareInjectionProcessor` **Pourquoi :** le processor qui injecte/valide le site lors de la creation d'une entite site-aware verifie `$user instanceof User`. Si jamais l'appelant n'est pas exactement une instance de cette classe (ex: futur provider d'auth, session systeme), la condition est fausse et **la validation est silencieusement sautee** — l'utilisateur pourrait creer une entite dans un site auquel il n'appartient pas. Aujourd'hui le risque est nul car il n'y a qu'un seul provider, mais le pattern "pas le bon type = on laisse passer" est fragile. **A faire :** 1. Ouvrir `src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php`. 2. Modifier la partie validation cross-site (ligne 64-75) : ```php // AVANT if (!$this->security->isGranted('sites.bypass_scope')) { $user = $this->security->getUser(); $explicitSite = $data->getSite(); if ($user instanceof User && $explicitSite instanceof Site && !$user->hasSite($explicitSite)) { throw new AccessDeniedHttpException( 'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.' ); } } ``` ```php // APRES if (!$this->security->isGranted('sites.bypass_scope')) { $user = $this->security->getUser(); // Pas de User authentifie = rejet explicite. On ne laisse pas passer // une ecriture site-scoped si on ne peut pas valider le proprietaire. if (!$user instanceof User) { throw new AccessDeniedHttpException( 'Utilisateur non reconnu pour la validation de site.', ); } $explicitSite = $data->getSite(); if ($explicitSite instanceof Site && !$user->hasSite($explicitSite)) { throw new AccessDeniedHttpException( 'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.', ); } } ``` 3. Relancer la suite back : `make test`. Les tests existants sur `SiteAwareInjectionProcessor` doivent encore passer (l'acceptation du happy path est inchangee). **Fichiers :** `src/Module/Sites/Infrastructure/ApiPlatform/State/Processor/SiteAwareInjectionProcessor.php` --- ### T-007 — Entourer `isHandlingUnauthorized` d'un `try/finally` **Pourquoi :** dans `useApi`, un flag singleton bloque les appels concurrents a `navigateTo('/login')` quand une 401 survient. Si `navigateTo` echoue (middleware qui throw, plugin qui throw, etc.), le flag reste sur `true` indefiniment et **toutes les 401 suivantes sont ignorees silencieusement**. L'utilisateur doit hard-reload pour s'en sortir. **A faire :** 1. Ouvrir `frontend/shared/composables/useApi.ts`. 2. Dans le handler `onResponseError`, modifier le bloc 401 (lignes 124-131) : ```typescript // AVANT if (!isLoginCheck && !isLogout) { if (!isHandlingUnauthorized) { isHandlingUnauthorized = true auth.clearSession() await navigateTo('/login') isHandlingUnauthorized = false } } ``` ```typescript // APRES if (!isLoginCheck && !isLogout) { if (!isHandlingUnauthorized) { isHandlingUnauthorized = true try { auth.clearSession() await navigateTo('/login') } finally { // Garantir la remise a false meme si navigateTo throw : sinon // les 401 suivantes seraient silencieusement ignorees jusqu'a // un hard reload. isHandlingUnauthorized = false } } } ``` 3. Pas de test automatise trivial a ecrire (depend de navigateTo) — test manuel : se connecter, laisser le token JWT expirer (ou le supprimer manuellement dans le cookie), declencher deux appels quasi-simultanes et verifier la redirection `/login`. **Fichiers :** `frontend/shared/composables/useApi.ts` --- ### T-008 — Capper la pagination sur `Permission`, `Role`, `Site` + nettoyer `itemsPerPage: 999` **Pourquoi :** plusieurs composants (drawers RBAC, page admin sites) appellent l'API avec `itemsPerPage: 999`. Or, `paginationClientItemsPerPage` n'est pas active sur ces ressources, donc le 999 **est silencieusement ignore** et on recoit toujours 30 elements. Aujourd'hui les catalogues sont petits, ca marche par chance. Demain si on passe a 35 permissions, les drawers tronqueront silencieusement — bug invisible. Parallelement, si on active la pagination client sans plafond, `?itemsPerPage=99999` devient une requete qui sort tout en une fois — DoS bas effort. La solution la plus simple : desactiver la pagination pour ces catalogues (ils sont exhaustifs par nature) et retirer les `itemsPerPage: 999` cote front. **A faire :** 1. **Backend** — Ajouter `paginationEnabled: false` sur les `GetCollection` de ces trois ressources. `src/Module/Core/Domain/Entity/Permission.php` (vers ligne 28-31) : ```php // APRES new GetCollection( normalizationContext: ['groups' => ['permission:read']], security: "is_granted('core.permissions.view') or is_granted('core.users.manage') or is_granted('core.roles.manage')", paginationEnabled: false, ), ``` `src/Module/Core/Domain/Entity/Role.php` (trouver le `new GetCollection` et ajouter la meme ligne). `src/Module/Sites/Domain/Entity/Site.php` (meme chose sur le `GetCollection`) : ```php new GetCollection( normalizationContext: ['groups' => ['site:read']], security: "is_granted('sites.view')", paginationEnabled: false, ), ``` **Attention** : pour `Site`, la page admin va recuperer TOUS les sites. Tant qu'on reste sous quelques dizaines c'est OK. Si la taille explose plus tard, remettre une pagination avec un plafond explicite (`paginationClientItemsPerPage: true`, `paginationMaximumItemsPerPage: 200`). 2. **Frontend** — Retirer les `itemsPerPage: 999`. `frontend/modules/core/components/UserRbacDrawer.vue:235-236` : ```typescript // AVANT api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: true }), api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: true }), // APRES api.get<{ member: Permission[] }>('/permissions', { orphan: false }, { toast: true }), api.get<{ member: Site[] }>('/sites', {}, { toast: true }), ``` `frontend/modules/core/components/RoleDrawer.vue:149` : ```typescript // AVANT const data = await api.get<{ member: Permission[] }>( '/permissions', { 'orphan': false, itemsPerPage: 999 }, { toast: true }, ) // APRES const data = await api.get<{ member: Permission[] }>( '/permissions', { orphan: false }, { toast: true }, ) ``` `frontend/modules/sites/pages/admin/sites.vue:117` : ```typescript // AVANT const data = await api.get<{ member: Site[] }>( '/sites', { itemsPerPage: 999 }, { toast: false }, ) // APRES const data = await api.get<{ member: Site[] }>( '/sites', {}, { toast: false }, ) ``` 3. Tester : ouvrir le `UserRbacDrawer`, verifier que toutes les permissions et tous les sites s'affichent. Idem sur `RoleDrawer` et la page `admin/sites`. 4. Relancer `make test` (PHP) et `make nuxt-test` (front) pour verifier la non-regression. **Fichiers :** - `src/Module/Core/Domain/Entity/Permission.php` - `src/Module/Core/Domain/Entity/Role.php` - `src/Module/Sites/Domain/Entity/Site.php` - `frontend/modules/core/components/UserRbacDrawer.vue` - `frontend/modules/core/components/RoleDrawer.vue` - `frontend/modules/sites/pages/admin/sites.vue` --- ### T-009 — Proteger `JSON.stringify` dans `AuditLogDetail.vue` **Pourquoi :** la fonction `formatValue` serialise des valeurs venant de l'API avec `JSON.stringify`. Si un jour la valeur contient un objet avec une reference circulaire (ex: refacto ORM qui serialise une entite), `stringify` leve une erreur qui casse tout le rendu du drawer. Ajouter un try/catch defensif evite de devoir debugger ca en prod. **A faire :** 1. Ouvrir `frontend/shared/components/audit/AuditLogDetail.vue`. 2. Reperer la fonction `formatValue` (ou equivalent qui appelle `JSON.stringify`). 3. L'entourer d'un try/catch : ```typescript // AVANT (approximativement) function formatValue(value: unknown): string { if (value === null || value === undefined) return '-' if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no') if (typeof value === 'object') return JSON.stringify(value) return String(value) } ``` ```typescript // APRES function formatValue(value: unknown): string { if (value === null || value === undefined) return '-' if (typeof value === 'boolean') return value ? t('common.yes') : t('common.no') if (typeof value === 'object') { try { return JSON.stringify(value) } catch { return '[valeur non serialisable]' } } return String(value) } ``` **Fichiers :** `frontend/shared/components/audit/AuditLogDetail.vue` --- ## P2 — Qualite et conventions ### T-010 — Remplacer ` ``` ```html ``` Si `MalioButton` n'a pas de variant adapte au rendu "lien inline" actuel, garder le `