security: appliquer les findings de l'audit OWASP (H1→B3)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
||||||
|
|
||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
APP_ENV=dev
|
APP_ENV=prod
|
||||||
APP_SECRET=
|
APP_SECRET=
|
||||||
APP_SHARE_DIR=var/share
|
APP_SHARE_DIR=var/share
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Hello API Platform
|
title: Radar Sargasses Caraïbes API
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
formats:
|
formats:
|
||||||
json: ['application/json']
|
json: ['application/json']
|
||||||
@@ -8,3 +8,9 @@ api_platform:
|
|||||||
stateless: true
|
stateless: true
|
||||||
cache_headers:
|
cache_headers:
|
||||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||||
|
|
||||||
|
when@prod:
|
||||||
|
api_platform:
|
||||||
|
enable_docs: false
|
||||||
|
enable_swagger_ui: false
|
||||||
|
enable_re_doc: false
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ framework:
|
|||||||
# Note that the session will be started ONLY if you read or write from it.
|
# Note that the session will be started ONLY if you read or write from it.
|
||||||
session: true
|
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
|
#esi: true
|
||||||
#fragments: true
|
#fragments: true
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class FeedbackController extends AbstractController
|
|||||||
return $this->json(['error' => 'Too many requests'], 429);
|
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);
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
if (!is_array($data)) {
|
if (!is_array($data)) {
|
||||||
|
|||||||
@@ -49,12 +49,20 @@ class PushController extends AbstractController
|
|||||||
return $this->json(['error' => 'Too many requests'], 429);
|
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);
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
if (!isset($data['endpoint'], $data['keys']['auth'], $data['keys']['p256dh'])) {
|
if (!isset($data['endpoint'], $data['keys']['auth'], $data['keys']['p256dh'])) {
|
||||||
return $this->json(['error' => 'Missing required fields'], 422);
|
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
|
// Upsert : si l'endpoint existe déjà, on met à jour
|
||||||
$repo = $this->em->getRepository(PushSubscription::class);
|
$repo = $this->em->getRepository(PushSubscription::class);
|
||||||
$sub = $repo->findOneBy(['endpoint' => $data['endpoint']]) ?? new PushSubscription();
|
$sub = $repo->findOneBy(['endpoint' => $data['endpoint']]) ?? new PushSubscription();
|
||||||
@@ -81,6 +89,11 @@ class PushController extends AbstractController
|
|||||||
#[Route('/subscribe', name: 'unsubscribe', methods: ['DELETE'])]
|
#[Route('/subscribe', name: 'unsubscribe', methods: ['DELETE'])]
|
||||||
public function unsubscribe(Request $request): JsonResponse
|
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);
|
$data = json_decode($request->getContent(), true);
|
||||||
if (empty($data['endpoint'])) {
|
if (empty($data['endpoint'])) {
|
||||||
return $this->json(['error' => 'Missing endpoint'], 422);
|
return $this->json(['error' => 'Missing endpoint'], 422);
|
||||||
|
|||||||
@@ -6,6 +6,14 @@
|
|||||||
:80 {
|
:80 {
|
||||||
root * /app/public
|
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)
|
# Assets Symfony (webpack encore)
|
||||||
handle /bundles/* {
|
handle /bundles/* {
|
||||||
file_server
|
file_server
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ set -e
|
|||||||
php bin/console cache:warmup --env=prod --no-debug 2>&1 || true
|
php bin/console cache:warmup --env=prod --no-debug 2>&1 || true
|
||||||
|
|
||||||
# Worker scheduler — réveille le pipeline toutes les 6h
|
# 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 \
|
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 &
|
2>&1 | tee -a var/log/scheduler.log &
|
||||||
|
|
||||||
# Worker async — exécute les messages d'ingestion déclenchés par le scheduler
|
# Worker async — exécute les messages d'ingestion déclenchés par le scheduler
|
||||||
php bin/console messenger:consume async \
|
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 &
|
2>&1 | tee -a var/log/worker.log &
|
||||||
|
|
||||||
# Lance FrankenPHP (comportement par défaut de l'image de base)
|
# Lance FrankenPHP (comportement par défaut de l'image de base)
|
||||||
|
|||||||
5
frontend/public/robots.txt
Normal file
5
frontend/public/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /bundles/
|
||||||
|
|
||||||
|
Allow: /
|
||||||
@@ -25,7 +25,11 @@ self.addEventListener('push', (event) => {
|
|||||||
self.addEventListener('notificationclick', (event) => {
|
self.addEventListener('notificationclick', (event) => {
|
||||||
event.notification.close();
|
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(
|
event.waitUntil(
|
||||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user