Files
radarsargasses/backend/src/Controller/FeedbackController.php
Gwadaking cda9a7a8ff 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>
2026-04-10 00:49:15 -04:00

93 lines
3.1 KiB
PHP

<?php
namespace App\Controller;
use App\Entity\CoastalPoint;
use App\Entity\UserFeedback;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/feedback', name: 'api_feedback_')]
class FeedbackController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
private RateLimiterFactory $apiFeedbackLimiter,
) {}
/**
* POST /api/feedback
*
* Corps attendu :
* {
* "lat": 14.65,
* "lng": -61.05,
* "hasSeaweed": true,
* "density": "medium", // optionnel : low|medium|high
* "coastalPointId": "uuid" // optionnel
* }
*/
#[Route('', name: 'create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$limiter = $this->apiFeedbackLimiter->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 (!is_array($data)) {
return $this->json(['error' => 'Invalid JSON body'], 400);
}
// Validation minimale
if (!isset($data['lat'], $data['lng'], $data['hasSeaweed'])) {
return $this->json(['error' => 'Missing required fields: lat, lng, hasSeaweed'], 422);
}
$lat = filter_var($data['lat'], FILTER_VALIDATE_FLOAT);
$lng = filter_var($data['lng'], FILTER_VALIDATE_FLOAT);
if ($lat === false || $lng === false) {
return $this->json(['error' => 'lat and lng must be valid floats'], 422);
}
if ($lat < -90 || $lat > 90 || $lng < -180 || $lng > 180) {
return $this->json(['error' => 'lat/lng out of valid range'], 422);
}
$feedback = new UserFeedback();
$feedback->setLocation(sprintf('SRID=4326;POINT(%f %f)', $lng, $lat));
$feedback->setHasSeaweed((bool) $data['hasSeaweed']);
if (isset($data['density']) && in_array($data['density'], ['low', 'medium', 'high'], true)) {
$feedback->setDensity($data['density']);
}
// Fingerprint anonyme : IP hachée (pas de données personnelles)
$ip = $request->getClientIp() ?? 'unknown';
$feedback->setSource(hash('sha256', $ip . date('Y-m-d')));
if (!empty($data['coastalPointId'])) {
$spot = $this->em->getRepository(CoastalPoint::class)->find($data['coastalPointId']);
if ($spot !== null) {
$feedback->setCoastalPoint($spot);
}
}
$this->em->persist($feedback);
$this->em->flush();
return $this->json(['id' => (string) $feedback->getId()], 201);
}
}