Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .github/avancement.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 📋 Avancement — HomeCloud API

> Dernière mise à jour : 2026-03-16
> Dernière mise à jour : 2026-03-24

> **Status git :** `main` — tout mergé, 301 tests ✅

Expand Down Expand Up @@ -43,6 +43,29 @@ src/

---

## ✅ Audit Sécurité — TERMINÉ (2026-03-24)

Score global : **8/10** — aucun point critique détecté.

| Point audité | Résultat |
|---|---|
| Upload validation (MIME, extension, path traversal) | âś… Excellent |
| JWT RS256 + Refresh Token rotation | âś… OK |
| Autorisation (OwnershipChecker + Voter) | âś… OK |
| Mots de passe (argon2id/bcrypt) | âś… OK |
| CORS (regex serrée en prod) | ✅ OK |
| Security Headers (CSP, X-Frame-Options…) | ✅ OK |
| SQL injection (QueryBuilder paramétrisé) | ✅ OK |
| Rate limiting sur /api/v1/auth/login | ❌ À faire |
| Auth failure logging | ⚠️ À améliorer |
| HSTS header | ⚠️ À faire |
| `composer audit` en CI | ⚠️ À faire |
| Assert sur DTOs | ⚠️ Basse priorité |

→ Plan de remédiation : `.github/todo-security.md`

---

## ⚠️ Bugs connus

| Priorité | Bug | Détail |
Expand Down
82 changes: 82 additions & 0 deletions .github/todo-security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Todo : Sécurité — Remédiation post-audit

> Audit réalisé le 2026-03-24 — Score initial : 8/10
> Aucun point critique. Ce plan couvre les 4 axes d'amélioration identifiés.

---

## 🔴 Priorité HAUTE

### 1. Rate Limiting sur `/api/v1/auth/login`

**Risque :** Brute force sur l'endpoint de login — aucun mécanisme de blocage en place.

- [ ] `composer require symfony/rate-limiter`
- [ ] Configurer un limiter dans `config/packages/rate_limiter.yaml` (ex : 5 tentatives / 15 min par IP)
- [ ] Activer dans `config/packages/security.yaml` → firewall `login` → `login_throttling`
- [ ] Écrire un test fonctionnel : `testLoginIsThrottledAfterFiveFailures` (RED → GREEN)
- [ ] Vérifier que le test existant `AuthTest` n'est pas cassé

---

## 🟡 Priorité MOYENNE

### 2. HSTS Header en production

**Risque :** Sans `Strict-Transport-Security`, un attaquant peut forcer une connexion HTTP.

- [ ] Ajouter dans `SecurityHeadersListener.php` : `Strict-Transport-Security: max-age=31536000; includeSubDomains`
- [ ] Conditionner à l'env `prod` uniquement (pas en dev/test — HTTPS non disponible)
- [ ] Écrire un test : vérifier que le header est présent sur une réponse API en env `prod`

---

### 3. Logging des tentatives d'authentification échouées

**Risque :** Aucune traçabilité des accès refusés — impossible de détecter une attaque a posteriori.

- [ ] Créer `AuthenticationFailureListener` écoutant `lexik_jwt_authentication.on_authentication_failure`
- [ ] Logger : email tenté, IP client, timestamp, user-agent
- [ ] Logger également les `AccessDeniedHttpException` dans un `ExceptionListener` dédié (ou l'existant)
- [ ] Enregistrer le listener dans `services.yaml` avec le tag event
- [ ] Écrire un test : vérifier que le logger est appelé lors d'un login échoué

---

### 4. `composer audit` dans le pipeline CI/CD

**Risque :** Une dépendance vulnérable peut passer inaperçue sans vérification automatique.

- [ ] Ajouter un step dans `.github/workflows/ci.yml` :
```yaml
- name: 🔍 Audit des dépendances
run: composer audit
```
- [ ] Placer ce step avant les tests (fail fast)
- [ ] Documenter dans `avancement.md` que le pipeline intègre l'audit de dépendances

---

## 🟢 Priorité BASSE

### 5. Contraintes `Assert` sur les DTOs / ApiResource inputs

**Risque :** Faible (validation métier déjà en place dans les Services), mais les messages d'erreur sont moins standardisés qu'avec le Symfony Validator.

- [ ] Identifier les champs éditables via PATCH dans les entités `File`, `Folder`, `User`
- [ ] Ajouter `#[Assert\NotBlank]`, `#[Assert\Length(max: 255)]` sur les champs texte
- [ ] Ajouter `#[Assert\Email]` sur `User::$email`
- [ ] Vérifier que API Platform retourne des erreurs 422 avec violations détaillées
- [ ] Écrire des tests : payload invalide → 422 avec message explicite

---

## 📋 Résumé

| # | Tâche | Priorité | Difficulté estimée |
|---|-------|----------|--------------------|
| 1 | Rate limiting login | đź”´ Haute | Facile (bundle natif Symfony) |
| 2 | HSTS header | 🟡 Moyenne | Trivial (1 ligne de code) |
| 3 | Auth failure logging | 🟡 Moyenne | Facile (listener + PSR-3) |
| 4 | `composer audit` en CI | 🟡 Moyenne | Trivial (1 step YAML) |
| 5 | Assert sur DTOs | 🟢 Basse | Moyen (plusieurs entités) |
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"symfony/mime": "8.0.*",
"symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*",
"symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
Expand Down
76 changes: 75 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ security:
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
login_throttling:
max_attempts: 5
interval: '15 minutes'

api:
pattern: ^/api
Expand Down
2 changes: 1 addition & 1 deletion config/reference.php
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,7 @@
* }>,
* },
* rate_limiter?: bool|array{ // Rate limiter configuration
* enabled?: bool|Param, // Default: false
* enabled?: bool|Param, // Default: true
* limiters?: array<string, array{ // Default: []
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
Expand Down
4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ services:
arguments:
$storageDir: '%app.storage_dir%'

App\EventListener\SecurityHeadersListener:
arguments:
$env: '%kernel.environment%'

App\EventListener\AuthenticationSuccessListener:
tags:
- { name: kernel.event_listener, event: lexik_jwt_authentication.on_authentication_success, method: onAuthenticationSuccess }
Expand Down
35 changes: 35 additions & 0 deletions src/EventListener/AuthenticationFailureListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace App\EventListener;

use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;

/**
* Journalise chaque tentative de connexion échouée.
*
* Enregistre : email tenté, IP client, user-agent, type d'exception.
* Permet de détecter a posteriori les attaques brute-force ou par dictionnaire.
*/
#[AsEventListener(event: LoginFailureEvent::class, priority: 0)]
final class AuthenticationFailureListener
{
public function __construct(private readonly LoggerInterface $logger)
{
}

public function __invoke(LoginFailureEvent $event): void
{
$request = $event->getRequest();

$this->logger->warning('Authentication failure', [
'email' => $request->getPayload()->getString('email'),
'ip' => $request->getClientIp(),
'user_agent' => $request->headers->get('User-Agent'),
'exception' => $event->getException()->getMessageKey(),
]);
}
}
31 changes: 31 additions & 0 deletions src/EventListener/LoginThrottlingFailureListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\TooManyLoginAttemptsAuthenticationException;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;

/**
* Retourne HTTP 429 quand le rate limiter bloque une tentative de connexion.
* Sans ce listener, le failure_handler Lexik JWT convertit tout en 401.
*/
#[AsEventListener(event: LoginFailureEvent::class, priority: 10)]
final class LoginThrottlingFailureListener
{
public function __invoke(LoginFailureEvent $event): void
{
if (!$event->getException() instanceof TooManyLoginAttemptsAuthenticationException) {
return;
}

$event->setResponse(new JsonResponse(
['code' => Response::HTTP_TOO_MANY_REQUESTS, 'message' => 'Too many login attempts. Please try again later.'],
Response::HTTP_TOO_MANY_REQUESTS
));
}
}
16 changes: 13 additions & 3 deletions src/EventListener/SecurityHeadersListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,21 @@
* Ajoute les headers de sécurité HTTP sur toutes les réponses de l'API.
*
* Headers ajoutés sur toutes les réponses :
* - X-Content-Type-Options: nosniff → empêche le MIME sniffing navigateur
* - X-Frame-Options: DENY → empêche l'embedding dans des iframes (clickjacking)
* - Referrer-Policy: no-referrer → ne transmet pas l'URL de la page précédente
* - X-Content-Type-Options: nosniff → empêche le MIME sniffing navigateur
* - X-Frame-Options: DENY → empêche l'embedding dans des iframes (clickjacking)
* - Referrer-Policy: no-referrer → ne transmet pas l'URL de la page précédente
* - Strict-Transport-Security (prod only) → force HTTPS, bloque le SSL-stripping
*
* Header ajouté uniquement sur les routes /api/* (hors /api/docs) :
* - Content-Security-Policy: default-src 'none' → l'API ne sert que du JSON
*/
#[AsEventListener(event: KernelEvents::RESPONSE)]
final class SecurityHeadersListener
{
public function __construct(private readonly string $env)
{
}

public function __invoke(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
Expand All @@ -33,6 +38,11 @@ public function __invoke(ResponseEvent $event): void
$headers->set('X-Frame-Options', 'DENY');
$headers->set('Referrer-Policy', 'no-referrer');

// HSTS : uniquement en prod — HTTPS non disponible en dev/test.
if ('prod' === $this->env) {
$headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}

$path = $event->getRequest()->getPathInfo();

// CSP strict uniquement sur l'API (JSON) — pas sur le frontend HTML.
Expand Down
27 changes: 27 additions & 0 deletions tests/Api/AuthTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ protected function setUp(): void
$conn->executeStatement('DELETE FROM users');
$conn->executeStatement('SET FOREIGN_KEY_CHECKS=1');
$this->em->clear();

// Réinitialise les compteurs de rate limiting entre chaque test
if (static::getContainer()->has('cache.rate_limiter')) {
static::getContainer()->get('cache.rate_limiter')->clear();
}
}

private function createUserWithPassword(string $email, string $plainPassword): User
Expand Down Expand Up @@ -179,4 +184,26 @@ public function testRefreshReturns401WithExpiredToken(): void

$this->assertResponseStatusCodeSame(401);
}

public function testLoginIsThrottledAfterFiveFailures(): void
{
$this->createUserWithPassword('throttle@example.com', 'password123');

$client = static::createClient();

// 5 tentatives échouées
for ($i = 0; $i < 5; $i++) {
$client->request('POST', '/api/v1/auth/login', [
'json' => ['email' => 'throttle@example.com', 'password' => 'wrongpassword'],
]);
$this->assertResponseStatusCodeSame(401);
}

// La 6ème tentative doit être bloquée par le rate limiter
$client->request('POST', '/api/v1/auth/login', [
'json' => ['email' => 'throttle@example.com', 'password' => 'wrongpassword'],
]);

$this->assertResponseStatusCodeSame(429);
}
}
Loading
Loading