OWASP Top 10 (2025) : implémentation concrète avec Symfony 7
Rappel des dix catégories OWASP 2025
L'OWASP Foundation a publié la version 2025 de son Top 10 en janvier 2025, première révision majeure depuis 2021. Les grandes tendances observées sur plus de 800 applications auditées dans l'année : montée des problèmes de chaîne d'approvisionnement et de configuration, et persistance des classiques que sont l'autorisation et la cryptographie.
| Rang | Code | Catégorie |
|---|---|---|
| 1 | A01:2025 | Broken Access Control |
| 2 | A02:2025 | Cryptographic Failures |
| 3 | A03:2025 | Injection |
| 4 | A04:2025 | Insecure Design |
| 5 | A05:2025 | Security Misconfiguration |
| 6 | A06:2025 | Vulnerable and Outdated Components |
| 7 | A07:2025 | Identification and Authentication Failures |
| 8 | A08:2025 | Software and Data Integrity Failures |
| 9 | A09:2025 | Security Logging and Monitoring Failures |
| 10 | A10:2025 | Server-Side Request Forgery (SSRF) |
Cet article détaille l'implémentation concrète des sept catégories qui touchent le plus directement une application Symfony 7, avec du code applicable immédiatement.
A01:2025 — Broken Access Control
Premier Top du Top. Sur nos audits 2025, 73% des applications PHP présentent au moins une vulnérabilité d'autorisation. Le pattern le plus fréquent : un controller qui vérifie que l'utilisateur est authentifié mais pas qu'il est propriétaire de la ressource demandée.
La solution Symfony : les Voters. Ne jamais écrire if ($user->getId() === $resource->getOwnerId()) directement dans un controller. Toujours passer par l'abstraction, testable unitairement, réutilisable.
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Invoice;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
final class InvoiceVoter extends Voter
{
public const VIEW = 'INVOICE_VIEW';
public const EDIT = 'INVOICE_EDIT';
public function __construct(private readonly Security $security) {}
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT], true)
&& $subject instanceof Invoice;
}
protected function voteOnAttribute(
string $attribute,
mixed $subject,
TokenInterface $token,
): bool {
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
/** @var Invoice $invoice */
$invoice = $subject;
return match ($attribute) {
self::VIEW => $invoice->getTenantId() === $user->getTenantId(),
self::EDIT => $invoice->getTenantId() === $user->getTenantId()
&& $this->security->isGranted('ROLE_ACCOUNTANT'),
default => false,
};
}
}
Dans le controller :
#[Route('/invoices/{id}', name: 'invoice_show')]
public function show(Invoice $invoice): Response
{
$this->denyAccessUnlessGranted(InvoiceVoter::VIEW, $invoice);
return $this->render('invoice/show.html.twig', ['invoice' => $invoice]);
}
Règle d'or : un controller ne fait jamais de vérification d'autorisation manuelle. Toujours via denyAccessUnlessGranted ou l'attribut #[IsGranted].
A02:2025 — Cryptographic Failures
Les erreurs cryptographiques les plus fréquentes : MD5 ou SHA-1 pour les mots de passe, clés de chiffrement en dur dans le code, TLS 1.0 ou 1.1 encore activé.
Pour les mots de passe Symfony 7, le défaut est argon2id, qui résiste au mieux aux attaques par GPU et ASIC. Configuration :
# config/packages/security.yaml
security:
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
algorithm: argon2id
memory_cost: 65536 # 64 MiB
time_cost: 4
threads: 4
Pour chiffrer des données applicatives (tokens OAuth à stocker, PII sensibles non indexables), ne jamais réinventer. Sodium est intégré à PHP 7.2+ et donne XChaCha20-Poly1305 out-of-the-box :
<?php
declare(strict_types=1);
namespace App\Security;
final class SecretVault
{
public function __construct(
#[\SensitiveParameter] private readonly string $masterKey,
) {
if (strlen($masterKey) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
throw new \RuntimeException('Master key must be exactly 32 bytes.');
}
}
public function seal(#[\SensitiveParameter] string $plaintext): string
{
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = sodium_crypto_secretbox($plaintext, $nonce, $this->masterKey);
return base64_encode($nonce . $cipher);
}
public function open(string $sealed): string
{
$raw = base64_decode($sealed, strict: true);
if ($raw === false || strlen($raw) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + 1) {
throw new \RuntimeException('Invalid sealed value.');
}
$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$clear = sodium_crypto_secretbox_open($cipher, $nonce, $this->masterKey);
if ($clear === false) {
throw new \RuntimeException('Decryption failed.');
}
return $clear;
}
}
Le master key vient de Vault ou AWS Secrets Manager, jamais d'un fichier .env commité. Côté TLS : TLS 1.2 minimum (1.3 recommandé), OCSP stapling activé, HSTS préload, pas de cipher suite en dessous de 128 bits.
A03:2025 — Injection
SQL, LDAP, template, OS, NoSQL. Symfony protège nativement contre la majorité via Doctrine DBAL, mais les équipes cassent la protection en construisant du SQL à la main.
Mauvais : concaténation directe.
// NE JAMAIS FAIRE ÇA
$orderBy = $request->query->get('sort');
$sql = "SELECT * FROM invoice ORDER BY {$orderBy}";
$conn->executeQuery($sql);
Bon : whitelist stricte du nom de colonne, paramètres liés pour les valeurs.
<?php
declare(strict_types=1);
use Doctrine\DBAL\Connection;
final class InvoiceRepository
{
private const SORTABLE = ['created_at', 'total', 'customer_name'];
public function __construct(private readonly Connection $conn) {}
/**
* @return list<array<string, scalar|null>>
*/
public function listForTenant(int $tenantId, string $sort = 'created_at'): array
{
if (!in_array($sort, self::SORTABLE, true)) {
$sort = 'created_at';
}
$qb = $this->conn->createQueryBuilder()
->select('*')
->from('invoice')
->where('tenant_id = :tenant')
->orderBy($sort, 'DESC')
->setMaxResults(100)
->setParameter('tenant', $tenantId, \PDO::PARAM_INT);
return $qb->fetchAllAssociative();
}
}
Trois règles opérationnelles :
- Les valeurs passent toujours en paramètres liés. Jamais de concaténation, même pour un entier supposé sûr.
- Les identifiants (colonnes, tables, ORDER BY) passent par une whitelist. DBAL ne les protège pas, c'est au code applicatif de valider.
- Activer PHPStan SQL strict via
staabm/phpstan-dbapour typer les résultats de requêtes et détecter les injections.
A05:2025 — Security Misconfiguration
Le plus insidieux. Headers manquants, debug activé en prod, CORS trop permissif, permissions de fichiers trop ouvertes.
Notre baseline Symfony 7 pour les en-têtes de sécurité passe par NelmioSecurityBundle :
# config/packages/nelmio_security.yaml
nelmio_security:
csp:
enabled: true
hosts: []
content_types: []
enforce:
level1_fallback: false
browser_adaptive: { enabled: true }
report-uri: '%env(CSP_REPORT_URI)%'
default-src: ["'self'"]
script-src: ["'self'", "'strict-dynamic'", "'nonce-{nonce}'"]
style-src: ["'self'", "'nonce-{nonce}'"]
img-src: ["'self'", 'data:', 'https:']
font-src: ["'self'"]
connect-src: ["'self'"]
frame-ancestors: ["'none'"]
base-uri: ["'self'"]
object-src: ["'none'"]
form-action: ["'self'"]
upgrade-insecure-requests: true
clickjacking:
paths: { '^/': DENY }
referrer_policy:
enabled: true
policies: [strict-origin-when-cross-origin]
xss_protection:
enabled: true
mode_block: true
content_type:
nosniff: true
forced_ssl:
enabled: true
hsts_max_age: 63072000
hsts_subdomains: true
hsts_preload: true
Checklist annexe :
APP_DEBUG=0etAPP_ENV=proden production, vérifiés par un healthcheck qui échoue si debug est actif.debug_modedésactivé sur Profiler et Web Debug Toolbar, retirés du build de prod..env.localnon commité,.envne contient que des placeholders.- Permissions Linux :
/var/wwwen755, fichiers en644, jamais777. open_basedirconfiguré pour limiter l'accès filesystem PHP.- Nginx ou Apache masquent les versions (
server_tokens off).
A07:2025 — Identification and Authentication Failures
Brute force sur login, absence de MFA, sessions qui ne se régénèrent pas.
Symfony 7 permet de configurer un rate limiter sur le firewall login :
# config/packages/security.yaml
security:
firewalls:
main:
login_throttling:
max_attempts: 5
interval: '15 minutes'
Le throttling est par défaut par IP + username. Sur une API, cela protège contre le brute force mais pas contre le credential stuffing distribué. Combiner avec un score ReCAPTCHA v3 ou un challenge hCaptcha au-dessus de 3 échecs dans la même session.
Pour la MFA, Scheb 2FA bundle reste la référence sur Symfony :
# config/packages/scheb_2fa.yaml
scheb_two_factor:
security_tokens:
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken
totp:
enabled: true
issuer: 'YOUR DIGITAL HUB'
digits: 6
window: 1
email:
enabled: false
trusted_device:
enabled: true
lifetime: 5184000 # 60 jours
Règles de session Symfony :
- Régénération de l'ID de session à l'authentification (activé par défaut).
- Cookie session :
Secure,HttpOnly,SameSite=Lax(ouStrictselon le flow). - Timeout d'inactivité raisonnable : 30 à 60 minutes pour du métier, 15 minutes pour les applications sensibles.
- Un logout côté serveur invalide le token côté Symfony ET côté store.
A09:2025 — Security Logging and Monitoring Failures
Sans logs, pas de détection. Sur nos audits, 40% des applications n'ont aucun journal dédié aux événements de sécurité.
Notre configuration Monolog canal security dédié :
# config/packages/monolog.yaml
monolog:
channels: ['security']
handlers:
security_file:
type: rotating_file
path: '%kernel.logs_dir%/security.log'
max_files: 90
level: info
channels: [security]
formatter: monolog.formatter.json
security_syslog:
type: syslog
level: warning
channels: [security]
ident: ydh-security
facility: auth
Événements à logger systématiquement :
- Connexion réussie et échouée, avec IP, user-agent, user ID.
- Changement de mot de passe, de MFA, d'email.
- Accès refusé par un Voter sur une ressource sensible.
- Tentative d'escalade de privilège détectée.
- Changement de rôle ou de permission.
- Export de données personnelles (pour auditabilité RGPD).
- Invalidation de session administrative.
Listener centralisé :
<?php
declare(strict_types=1);
namespace App\Security\EventListener;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Http\Event\LoginFailureEvent;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
final class AuthLogger
{
public function __construct(
private readonly LoggerInterface $securityLogger,
private readonly RequestStack $requestStack,
) {}
#[AsEventListener]
public function onLoginSuccess(LoginSuccessEvent $event): void
{
$request = $this->requestStack->getMainRequest();
$this->securityLogger->info('login.success', [
'user' => $event->getUser()->getUserIdentifier(),
'ip' => $request?->getClientIp(),
'ua' => substr((string) $request?->headers->get('User-Agent'), 0, 200),
]);
}
#[AsEventListener]
public function onLoginFailure(LoginFailureEvent $event): void
{
$request = $this->requestStack->getMainRequest();
$this->securityLogger->warning('login.failure', [
'reason' => $event->getException()->getMessageKey(),
'ip' => $request?->getClientIp(),
'ua' => substr((string) $request?->headers->get('User-Agent'), 0, 200),
]);
}
}
Les fichiers JSON sont ingérés par Wazuh ou Elastic Security pour corrélation et alerting.
A10:2025 — Server-Side Request Forgery
SSRF sur Symfony arrive quand un controller accepte une URL utilisateur et la fetch côté serveur : import d'avatar, preview de lien, webhook générique.
Symfony HttpClient expose une option resolve et un middleware pour filtrer les hôtes :
<?php
declare(strict_types=1);
namespace App\Http;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
final class SafeHttpClient
{
/**
* @param list<string> $deniedCidrs
*/
public function __construct(
private readonly HttpClientInterface $client,
private readonly array $deniedCidrs = [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.0/8',
'169.254.0.0/16',
'::1/128',
'fc00::/7',
],
) {}
public function fetch(string $url): ResponseInterface
{
$parsed = parse_url($url);
if ($parsed === false || !in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) {
throw new \InvalidArgumentException('Only http(s) URLs are allowed.');
}
$host = $parsed['host'] ?? '';
$ip = gethostbyname($host);
if ($ip === $host) {
throw new \RuntimeException('DNS resolution failed.');
}
foreach ($this->deniedCidrs as $cidr) {
if ($this->ipInCidr($ip, $cidr)) {
throw new \RuntimeException('Internal host blocked by SSRF protection.');
}
}
return $this->client->request('GET', $url, [
'timeout' => 5.0,
'max_redirects' => 2,
'max_duration' => 10.0,
'headers' => ['Accept' => 'text/html, image/*'],
'extra' => ['curl' => [\CURLOPT_PROTOCOLS => \CURLPROTO_HTTP | \CURLPROTO_HTTPS]],
]);
}
private function ipInCidr(string $ip, string $cidr): bool
{
// Implémentation simplifiée, voir symfony/http-foundation IpUtils::checkIp
return \Symfony\Component\HttpFoundation\IpUtils::checkIp($ip, $cidr);
}
}
Deux règles complémentaires :
- Si possible, utiliser un proxy dédié (Smokescreen, egress-gateway Kubernetes) qui fait le filtrage centralement.
- Traiter toute URL utilisateur comme hostile, même derrière un bouton de confiance.
Outils d'analyse à brancher en CI
Notre stack d'analyse sécurité obligatoire en CI GitHub Actions :
composer auditsur chaque build : échoue si une dépendance a une CVE connue.- Psalm TaintAnalysis (
--taint-analysis) pour suivre les sources non fiables (requête HTTP, fichier uploadé) jusqu'aux sinks (SQL, shell, header). - Snyk ou Semgrep pour le scan applicatif + dépendances avec rulesets sécurité.
- Trivy ou Grype pour le scan des images Docker au build.
- OWASP ZAP en smoke test dans un environnement de pré-prod, scan baseline 5 minutes à chaque release.
# .github/workflows/security.yml
name: security
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.3' }
- run: composer install --no-progress --prefer-dist
- run: composer audit --locked
- run: vendor/bin/psalm --taint-analysis --no-cache
- uses: aquasecurity/trivy-action@master
with:
scan-type: fs
severity: CRITICAL,HIGH
exit-code: 1
Checklist de pré-production
La checklist que notre cabinet exige avant chaque mise en production d'un projet Symfony.
| Catégorie | Contrôle | Statut attendu |
|---|---|---|
| A01 | Tous les controllers couverts par Voter ou #[IsGranted] |
PASS |
| A01 | Test automatisé d'accès cross-tenant interdit | PASS |
| A02 | argon2id sur les mots de passe, memory_cost >= 65536 |
PASS |
| A02 | TLS 1.3 activé, pas de cipher suite < 128 bits | PASS |
| A03 | Aucun SQL concaténé, whitelist sur colonnes de tri | PASS |
| A04 | Threat modeling STRIDE documenté et validé | PASS |
| A05 | NelmioSecurityBundle configuré, CSP niveau 3 testée | PASS |
| A05 | APP_DEBUG=0, Profiler supprimé du build prod |
PASS |
| A06 | composer audit vert en CI, Dependabot ou Renovate actif |
PASS |
| A06 | Images Docker scannées (Trivy), pas de CRITICAL | PASS |
| A07 | login_throttling configuré, MFA sur comptes admin |
PASS |
| A08 | Signatures des packages Composer vérifiées (composer config --global secure-http true) |
PASS |
| A09 | Canal Monolog security actif, logs ingérés par SIEM |
PASS |
| A10 | Tous les endpoints HTTP outbound passent par SafeHttpClient ou un proxy filtrant | PASS |
Si une ligne est FAIL, pas de mise en production.
Conclusion
L'OWASP Top 10 2025 ne réinvente rien, il documente ce que les équipes sérieuses appliquent déjà. La bonne nouvelle : Symfony 7 offre nativement des primitives de haut niveau pour traiter 80% des catégories. La mauvaise : la majorité des audits révèlent encore des Voters oubliés, des logs absents et des dépendances obsolètes.
Pour un audit sécurité applicatif complet sur votre stack PHP, incluant pentest gris-box et plan de remédiation priorisé, écrivez-nous à contact@your-digital-hub.com ou découvrez notre expertise Cybersécurité.