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>
113 lines
3.8 KiB
PHP
113 lines
3.8 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Entity\CoastalPoint;
|
|
use App\Entity\PushSubscription;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
|
|
#[Route('/api/push', name: 'api_push_')]
|
|
class PushController extends AbstractController
|
|
{
|
|
public function __construct(
|
|
private EntityManagerInterface $em,
|
|
private RateLimiterFactory $apiPushLimiter,
|
|
#[Autowire('%env(VAPID_PUBLIC_KEY)%')] private string $vapidPublicKey,
|
|
) {}
|
|
|
|
/**
|
|
* GET /api/push/vapid-public-key
|
|
* Retourne la clé publique VAPID pour l'abonnement push côté client.
|
|
*/
|
|
#[Route('/vapid-public-key', name: 'vapid_key', methods: ['GET'])]
|
|
public function vapidKey(): JsonResponse
|
|
{
|
|
return $this->json(['publicKey' => $this->vapidPublicKey]);
|
|
}
|
|
|
|
/**
|
|
* POST /api/push/subscribe
|
|
* Enregistre un abonnement push.
|
|
*
|
|
* {
|
|
* "endpoint": "https://...",
|
|
* "keys": { "auth": "...", "p256dh": "..." },
|
|
* "coastalPointId": "uuid" // optionnel
|
|
* }
|
|
*/
|
|
#[Route('/subscribe', name: 'subscribe', methods: ['POST'])]
|
|
public function subscribe(Request $request): JsonResponse
|
|
{
|
|
$limiter = $this->apiPushLimiter->create($request->getClientIp() ?? 'unknown');
|
|
if (!$limiter->consume()->isAccepted()) {
|
|
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();
|
|
|
|
$sub->setEndpoint($data['endpoint']);
|
|
$sub->setAuthToken($data['keys']['auth']);
|
|
$sub->setP256dhKey($data['keys']['p256dh']);
|
|
|
|
if (!empty($data['coastalPointId'])) {
|
|
$spot = $this->em->getRepository(CoastalPoint::class)->find($data['coastalPointId']);
|
|
$sub->setCoastalPoint($spot);
|
|
}
|
|
|
|
$this->em->persist($sub);
|
|
$this->em->flush();
|
|
|
|
return $this->json(['id' => (string) $sub->getId()], 201);
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/push/subscribe
|
|
* Supprime un abonnement push.
|
|
*/
|
|
#[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);
|
|
}
|
|
|
|
$sub = $this->em->getRepository(PushSubscription::class)
|
|
->findOneBy(['endpoint' => $data['endpoint']]);
|
|
|
|
if ($sub !== null) {
|
|
$this->em->remove($sub);
|
|
$this->em->flush();
|
|
}
|
|
|
|
return $this->json(null, 204);
|
|
}
|
|
}
|