YOUR DIGITAL HUB
← Retour au blog

OWASP Top 10 (2025) : implémentation concrète avec Symfony 7

· 11 min de lecture
Visuel de couverture — OWASP Top 10 2025 et 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 :

  1. Les valeurs passent toujours en paramètres liés. Jamais de concaténation, même pour un entier supposé sûr.
  2. Les identifiants (colonnes, tables, ORDER BY) passent par une whitelist. DBAL ne les protège pas, c'est au code applicatif de valider.
  3. Activer PHPStan SQL strict via staabm/phpstan-dba pour 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=0 et APP_ENV=prod en production, vérifiés par un healthcheck qui échoue si debug est actif.
  • debug_mode désactivé sur Profiler et Web Debug Toolbar, retirés du build de prod.
  • .env.local non commité, .env ne contient que des placeholders.
  • Permissions Linux : /var/www en 755, fichiers en 644, jamais 777.
  • open_basedir configuré 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 (ou Strict selon 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 :

  1. Si possible, utiliser un proxy dédié (Smokescreen, egress-gateway Kubernetes) qui fait le filtrage centralement.
  2. 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 audit sur 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é.