From cda9a7a8ff9756cdc1099aa3093aba935cd8b014 Mon Sep 17 00:00:00 2001 From: Gwadaking Date: Fri, 10 Apr 2026 00:49:15 -0400 Subject: [PATCH] =?UTF-8?q?security:=20appliquer=20les=20findings=20de=20l?= =?UTF-8?q?'audit=20OWASP=20(H1=E2=86=92B3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 — trusted_proxies RFC-1918 dans framework.yaml : rate limiting opérationnel derrière Traefik (IP client réelle, pas IP Traefik) H2 — En-têtes HTTP dans Caddyfile : X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy, Permissions-Policy, suppression header Server H3 — API Platform docs désactivés en when@prod (Swagger UI, ReDoc) M1 — Rate limiter sur DELETE /api/push/subscribe (manquant) M2 — Validation FILTER_VALIDATE_URL sur endpoint push avant stockage M3 — APP_ENV=prod dans backend/.env (était dev — risque si .env.local absent) M4 — Limite 4096 octets sur le body JSON (FeedbackController + PushController) M5 — Service Worker : open redirect corrigé (targetUrl validé contre l'origine) B1 — robots.txt créé (bloque /api/ et /bundles/) B3 — --time-limit=3600 sur les workers Messenger (rotation + libération mémoire) Co-Authored-By: Claude Sonnet 4.6 --- backend/.env | 2 +- backend/config/packages/api_platform.yaml | 8 +++++++- backend/config/packages/framework.yaml | 11 +++++++++++ backend/src/Controller/FeedbackController.php | 4 ++++ backend/src/Controller/PushController.php | 13 +++++++++++++ docker/php/Caddyfile | 8 ++++++++ docker/php/entrypoint.sh | 5 +++-- frontend/public/robots.txt | 5 +++++ frontend/public/sw.js | 6 +++++- 9 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 frontend/public/robots.txt diff --git a/backend/.env b/backend/.env index 3db75e4..7cb9bbd 100644 --- a/backend/.env +++ b/backend/.env @@ -15,7 +15,7 @@ # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration ###> symfony/framework-bundle ### -APP_ENV=dev +APP_ENV=prod APP_SECRET= APP_SHARE_DIR=var/share ###< symfony/framework-bundle ### diff --git a/backend/config/packages/api_platform.yaml b/backend/config/packages/api_platform.yaml index 334b085..83ca306 100644 --- a/backend/config/packages/api_platform.yaml +++ b/backend/config/packages/api_platform.yaml @@ -1,5 +1,5 @@ api_platform: - title: Hello API Platform + title: Radar Sargasses Caraïbes API version: 1.0.0 formats: json: ['application/json'] @@ -8,3 +8,9 @@ api_platform: stateless: true cache_headers: vary: ['Content-Type', 'Authorization', 'Origin'] + +when@prod: + api_platform: + enable_docs: false + enable_swagger_ui: false + enable_re_doc: false diff --git a/backend/config/packages/framework.yaml b/backend/config/packages/framework.yaml index 7e1ee1f..398f590 100644 --- a/backend/config/packages/framework.yaml +++ b/backend/config/packages/framework.yaml @@ -5,6 +5,17 @@ framework: # Note that the session will be started ONLY if you read or write from it. session: true + # Derrière Traefik (RFC-1918) — nécessaire pour que getClientIp() retourne + # l'IP réelle du client et non l'IP interne du reverse proxy. + # Sans ça, le rate limiting est inopérant (tous les clients partagent l'IP Traefik). + trusted_proxies: '127.0.0.1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16' + trusted_headers: + - 'x-forwarded-for' + - 'x-forwarded-host' + - 'x-forwarded-proto' + - 'x-forwarded-port' + - 'x-forwarded-prefix' + #esi: true #fragments: true diff --git a/backend/src/Controller/FeedbackController.php b/backend/src/Controller/FeedbackController.php index e93790a..da6dcdf 100644 --- a/backend/src/Controller/FeedbackController.php +++ b/backend/src/Controller/FeedbackController.php @@ -39,6 +39,10 @@ class FeedbackController extends AbstractController return $this->json(['error' => 'Too many requests'], 429); } + if (strlen($request->getContent()) > 4096) { + return $this->json(['error' => 'Request body too large'], 413); + } + $data = json_decode($request->getContent(), true); if (!is_array($data)) { diff --git a/backend/src/Controller/PushController.php b/backend/src/Controller/PushController.php index ac84e31..94e84c5 100644 --- a/backend/src/Controller/PushController.php +++ b/backend/src/Controller/PushController.php @@ -49,12 +49,20 @@ class PushController extends AbstractController return $this->json(['error' => 'Too many requests'], 429); } + if (strlen($request->getContent()) > 4096) { + return $this->json(['error' => 'Request body too large'], 413); + } + $data = json_decode($request->getContent(), true); if (!isset($data['endpoint'], $data['keys']['auth'], $data['keys']['p256dh'])) { return $this->json(['error' => 'Missing required fields'], 422); } + if (!filter_var($data['endpoint'], FILTER_VALIDATE_URL)) { + return $this->json(['error' => 'Invalid endpoint URL'], 422); + } + // Upsert : si l'endpoint existe déjà, on met à jour $repo = $this->em->getRepository(PushSubscription::class); $sub = $repo->findOneBy(['endpoint' => $data['endpoint']]) ?? new PushSubscription(); @@ -81,6 +89,11 @@ class PushController extends AbstractController #[Route('/subscribe', name: 'unsubscribe', methods: ['DELETE'])] public function unsubscribe(Request $request): JsonResponse { + $limiter = $this->apiPushLimiter->create($request->getClientIp() ?? 'unknown'); + if (!$limiter->consume()->isAccepted()) { + return $this->json(['error' => 'Too many requests'], 429); + } + $data = json_decode($request->getContent(), true); if (empty($data['endpoint'])) { return $this->json(['error' => 'Missing endpoint'], 422); diff --git a/docker/php/Caddyfile b/docker/php/Caddyfile index 4ddbed9..5520ce6 100644 --- a/docker/php/Caddyfile +++ b/docker/php/Caddyfile @@ -6,6 +6,14 @@ :80 { root * /app/public + header { + X-Frame-Options "DENY" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=()" + -Server + } + # Assets Symfony (webpack encore) handle /bundles/* { file_server diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index bf97b32..42a99a2 100644 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -5,13 +5,14 @@ set -e php bin/console cache:warmup --env=prod --no-debug 2>&1 || true # Worker scheduler — réveille le pipeline toutes les 6h +# --time-limit=3600 : redémarre le process toutes les heures (rotation naturelle + libération mémoire) php bin/console messenger:consume scheduler_main \ - --env=prod --no-debug --memory-limit=64M \ + --env=prod --no-debug --memory-limit=64M --time-limit=3600 \ 2>&1 | tee -a var/log/scheduler.log & # Worker async — exécute les messages d'ingestion déclenchés par le scheduler php bin/console messenger:consume async \ - --env=prod --no-debug --memory-limit=256M \ + --env=prod --no-debug --memory-limit=256M --time-limit=3600 \ 2>&1 | tee -a var/log/worker.log & # Lance FrankenPHP (comportement par défaut de l'image de base) diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..a7ade6f --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,5 @@ +User-agent: * +Disallow: /api/ +Disallow: /bundles/ + +Allow: / diff --git a/frontend/public/sw.js b/frontend/public/sw.js index f9d002f..57081c1 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -25,7 +25,11 @@ self.addEventListener('push', (event) => { self.addEventListener('notificationclick', (event) => { event.notification.close(); - const targetUrl = event.notification.data?.url ?? '/'; + const rawUrl = event.notification.data?.url ?? '/'; + // N'autoriser que les URLs relatives ou du même domaine (protection open redirect) + const targetUrl = (rawUrl.startsWith('/') || rawUrl.startsWith(self.location.origin)) + ? rawUrl + : '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {