# WAF pour une application Next.js : les règles managées qui bloquent du trafic légitime, et ce que nous en avons fait

Activer AWS WAF devant une application web, c'est un construct CDK et quelques cases à cocher de groupes de règles managées. C'est aussi, d'après notre expérience, la garantie de bloquer quelque chose de réel dès la première semaine : une soumission de formulaire, un webhook, un envoi d'image, un champ de texte enrichi. Les règles ne sont pas fausses. Elles sont génériques, et votre application ne l'est pas.

Voici la configuration WAF que nous exploitons devant une application Next.js publique sur App Runner, les cinq règles managées qui ont bloqué du trafic légitime, la raison pour laquelle chacune s'est déclenchée, et la surcharge qui a corrigé le problème sans désactiver la règle pour tout.

## La configuration

CloudFront devant, App Runner derrière, WAF attaché à la distribution CloudFront pour qu'il voie chaque requête avant l'origine. Trois groupes de règles managées plus une limite de débit :

```ts
const acl = new wafv2.CfnWebACL(this, 'WebAcl', {
  scope: 'CLOUDFRONT',
  defaultAction: { allow: {} },
  visibilityConfig: { sampledRequestsEnabled: true, cloudWatchMetricsEnabled: true, metricName: 'web-acl' },
  rules: [
    managedGroup('AWSManagedRulesAmazonIpReputationList', 10),
    managedGroup('AWSManagedRulesKnownBadInputsRuleSet', 20),
    managedGroup('AWSManagedRulesCommonRuleSet', 30, /* surcharges ci-dessous */),
    {
      name: 'rate-limit', priority: 40,
      statement: { rateBasedStatement: { limit: 2000, aggregateKeyType: 'IP' } },
      action: { block: {} },
      visibilityConfig: { sampledRequestsEnabled: true, cloudWatchMetricsEnabled: true, metricName: 'rate-limit' },
    },
  ],
});
```

Coût : 5 $ pour la web ACL, 1 $ par groupe de règles, 0,60 $ par million de requêtes. Environ 16 $ par mois chez nous, le contrôle de sécurité le moins cher de la facture après la [politique de protection des données](/fr/blog/cloudwatch-data-protection-policies-pii).

## Compter d'abord, bloquer ensuite

Nous n'avons pas commencé en mode blocage. Chaque groupe managé a tourné avec son action surchargée en `COUNT` pendant deux semaines, le log des requêtes échantillonnées allant vers S3 et un tableau de bord CloudWatch montrant les comptes par règle. C'est cette période qui a produit la liste ci-dessous. Sans elle, nous aurions trouvé chacun de ces cas par un utilisateur signalant un formulaire cassé, ce qui est la façon dont la plupart des équipes les trouvent.

La requête du tableau de bord est simple : nombre de requêtes correspondantes par `terminatingRuleId` et `ruleGroupList[].ruleId`, filtré sur celles avec l'action `COUNT`. Tout ce qui a un volume réel et n'est pas manifestement une attaque est examiné à la main : quel chemin, quel corps, quel client.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Chemin de la requête et évaluation WAF. Une requête d'un navigateur ou d'un émetteur de webhook atteint CloudFront, où le WAF l'évalue contre la liste de réputation IP, les entrées connues comme malveillantes, le jeu de règles commun avec cinq règles surchargées en count sur des chemins précis, et une limite de 2 000 requêtes par 5 minutes par IP. Les requêtes autorisées vont vers App Runner. Les bloquées reçoivent un 403 et une entrée de log échantillonnée vers S3. Les correspondances en mode count sont journalisées mais autorisées.">
<defs><marker id="arrW" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#4fffb0"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="15" y="80" width="130" height="60" rx="10" fill="#151b2e" stroke="#9aa3c7" stroke-width="1.5"/><text x="80" y="104" text-anchor="middle" fill="#f1f3ff" font-weight="700">client</text><text x="80" y="124" text-anchor="middle" fill="#9aa3c7" font-size="11">navigateur · webhook</text>
<line x1="147" y1="110" x2="183" y2="110" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrW)"/>
<rect x="185" y="30" width="400" height="160" rx="12" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="385" y="52" text-anchor="middle" fill="#ffd166" font-weight="700">CloudFront + WAF</text>
<rect x="200" y="64" width="370" height="24" rx="6" fill="#0d1120" stroke="#2a3150"/><text x="385" y="80" text-anchor="middle" fill="#f1f3ff" font-size="11">10 · liste de réputation IP · block</text>
<rect x="200" y="92" width="370" height="24" rx="6" fill="#0d1120" stroke="#2a3150"/><text x="385" y="108" text-anchor="middle" fill="#f1f3ff" font-size="11">20 · entrées connues comme malveillantes · block</text>
<rect x="200" y="120" width="370" height="24" rx="6" fill="#0d1120" stroke="#ff6b8a"/><text x="385" y="136" text-anchor="middle" fill="#ff6b8a" font-size="11">30 · jeu commun · 5 règles → COUNT sur des chemins précis</text>
<rect x="200" y="148" width="370" height="24" rx="6" fill="#0d1120" stroke="#2a3150"/><text x="385" y="164" text-anchor="middle" fill="#f1f3ff" font-size="11">40 · limite de débit · 2 000 req / 5 min / IP · block</text>
<line x1="587" y1="90" x2="683" y2="70" stroke="#4fffb0" stroke-width="1.5" marker-end="url(#arrW)"/><text x="635" y="68" text-anchor="middle" fill="#4fffb0" font-size="10">allow</text>
<rect x="685" y="40" width="200" height="56" rx="10" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="785" y="64" text-anchor="middle" fill="#f1f3ff" font-weight="700">App Runner</text><text x="785" y="84" text-anchor="middle" fill="#9aa3c7" font-size="11">l'application Next.js</text>
<line x1="587" y1="140" x2="683" y2="160" stroke="#ff6b8a" stroke-width="1.5" marker-end="url(#arrW)"/><text x="635" y="166" text-anchor="middle" fill="#ff6b8a" font-size="10">block</text>
<rect x="685" y="130" width="200" height="56" rx="10" fill="#151b2e" stroke="#ff6b8a" stroke-width="1.5"/><text x="785" y="154" text-anchor="middle" fill="#ff6b8a" font-weight="700">403</text><text x="785" y="174" text-anchor="middle" fill="#9aa3c7" font-size="11">requête échantillonnée → log S3</text>
<text x="450" y="222" text-anchor="middle" fill="#9aa3c7">Deux semaines en mode COUNT d'abord. Le log de ce qui aurait été bloqué est toute la matière de conception.</text>
</g>
</svg>
</div>

## Les cinq règles qui ont bloqué du vrai trafic

Les cinq sont dans `AWSManagedRulesCommonRuleSet`, le groupe que tout le monde active et celui qui a le plus d'opinions sur ce à quoi une requête devrait ressembler.

**1. `SizeRestrictions_BODY` : tout corps de requête au-delà de 8 Ko.** La règle existe parce que les corps surdimensionnés sont un vecteur d'attaque courant et parce que le WAF n'inspecte de toute façon que les 8 premiers Ko (16 Ko sur CloudFront dans les configurations plus récentes). Notre formulaire d'édition de profil envoie une charge JSON avec un avatar en data URL base64. Ça fait 40 Ko. Bloqué. Chaque sauvegarde de profil d'un utilisateur avec une photo, perdue, pendant les deux semaines qu'il aurait fallu à quelqu'un pour le signaler.

*Correctif :* la règle reste en mode blocage globalement et est surchargée en `COUNT` pour les deux routes qui acceptent légitimement de gros corps, via un scope-down statement sur le chemin URI. Meilleur correctif, que nous avons aussi appliqué : les envois vont vers S3 via une URL présignée et le formulaire envoie une clé, pas les octets, donc le corps refait 2 Ko.

**2. `CrossSiteScripting_BODY` : du HTML dans un corps de requête.** Un éditeur de texte enrichi pour les descriptions de produits soumet du HTML assaini. Pour la règle XSS, `<p>` et `<a href>` dans un corps sont une attaque. Bloqué à la sauvegarde.

*Correctif :* surcharge en `COUNT` pour les deux routes d'administration qui acceptent du HTML, et blocage maintenu partout ailleurs. L'application assainit déjà avec une liste blanche côté serveur ; la règle WAF dupliquait cette vérification avec un instrument plus grossier.

**3. `GenericRFI_BODY` : une URL dans un corps de requête.** La détection d'inclusion de fichier distant se déclenche sur les chaînes `http://` ou `https://` dans le corps. Un webhook de notre prestataire de paiement inclut l'URL du reçu. Un utilisateur qui colle un lien dans un formulaire de support inclut une URL. Les deux bloqués.

*Correctif :* `COUNT` sur `/api/webhooks/*` et sur la route du formulaire de support. C'est la règle avec le taux de faux positifs le plus élevé sur toute application qui accepte du texte libre, et la première à regarder si des formulaires échouent mystérieusement.

**4. `NoUserAgent_HEADER` : requête sans User-Agent.** Les navigateurs en envoient toujours un. Certains émetteurs de webhooks non, et l'un des nôtres n'en envoyait pas. Chaque confirmation de paiement de ce prestataire était bloquée, ce que nous avons découvert en une journée parce que ça cassait le checkout, pas en deux semaines.

*Correctif :* `COUNT` limité à `/api/webhooks/*`. Les routes de webhook sont de toute façon authentifiées par signature ; la vérification du User-Agent n'ajoute rien là.

**5. `EC2MetaDataSSRF_BODY` : la chaîne `169.254.169.254` dans un corps.** Un champ où un opérateur colle des extraits de logs pour un ticket de support contenait une URL de métadonnées d'instance issue d'une session de débogage. Bloqué. Une fois. Nous l'incluons parce que c'est un bon exemple d'une règle qui a *raison* sur ce qu'est la chaîne et *tort* sur le fait que ça compte.

*Correctif :* aucun. Un blocage en six mois sur une route interne, c'est acceptable. Tous les faux positifs n'ont pas besoin d'une surcharge ; la surcharge est un trou permanent et le blocage était un cas isolé.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Tableau des cinq règles : SizeRestrictions_BODY a bloqué les sauvegardes de profil avec avatars base64, corrigé par une surcharge avec scope-down et des envois présignés. CrossSiteScripting_BODY a bloqué les sauvegardes de texte enrichi, surcharge sur deux routes d'administration. GenericRFI_BODY a bloqué des webhooks et des formulaires de support contenant des URL, surcharge sur les routes de webhook et de support. NoUserAgent_HEADER a bloqué un webhook de prestataire de paiement, surcharge sur les routes de webhook. EC2MetaDataSSRF_BODY a bloqué un ticket de support interne, pas de surcharge.">
<g font-family="Inter,system-ui,sans-serif" font-size="11">
<text x="20" y="22" fill="#f1f3ff" font-size="14" font-weight="700">Jeu de règles commun · ce qui s'est déclenché, sur quoi, et la surcharge</text>
<g fill="#9aa3c7"><text x="20" y="50" font-weight="700" fill="#f1f3ff">règle</text><text x="260" y="50" font-weight="700" fill="#f1f3ff">a bloqué</text><text x="560" y="50" font-weight="700" fill="#f1f3ff">surcharge</text></g>
<line x1="20" y1="58" x2="880" y2="58" stroke="#2a3150"/>
<text x="20" y="80" fill="#ff6b8a">SizeRestrictions_BODY</text><text x="260" y="80" fill="#f1f3ff">sauvegarde de profil avec avatar base64, 40 Ko</text><text x="560" y="80" fill="#4fffb0">COUNT sur 2 routes · envois S3 présignés</text>
<text x="20" y="108" fill="#ff6b8a">CrossSiteScripting_BODY</text><text x="260" y="108" fill="#f1f3ff">description de produit en texte enrichi</text><text x="560" y="108" fill="#4fffb0">COUNT sur 2 routes admin · le serveur assainit</text>
<text x="20" y="136" fill="#ff6b8a">GenericRFI_BODY</text><text x="260" y="136" fill="#f1f3ff">webhook avec URL de reçu · liens de support</text><text x="560" y="136" fill="#4fffb0">COUNT sur /api/webhooks/* et support</text>
<text x="20" y="164" fill="#ff6b8a">NoUserAgent_HEADER</text><text x="260" y="164" fill="#f1f3ff">webhook du prestataire de paiement, sans UA</text><text x="560" y="164" fill="#4fffb0">COUNT sur /api/webhooks/* · auth par signature</text>
<text x="20" y="192" fill="#ff6b8a">EC2MetaDataSSRF_BODY</text><text x="260" y="192" fill="#f1f3ff">un ticket de support avec une URL de métadonnées</text><text x="560" y="192" fill="#ffd166">aucune · un blocage en six mois, c'est acceptable</text>
<line x1="20" y1="204" x2="880" y2="204" stroke="#2a3150"/>
<text x="20" y="230" fill="#9aa3c7">Chaque surcharge est limitée à un chemin. La règle continue de bloquer partout ailleurs. Rien n'a été désactivé.</text>
</g>
</svg>
</div>

## Comment s'écrit une surcharge

La propriété importante : la règle n'est *pas* désactivée. Elle passe en `COUNT` seulement quand un scope-down statement correspond, et elle bloque partout ailleurs. En CDK, sur le statement du groupe de règles managées :

```ts
managedRuleGroupStatement: {
  vendorName: 'AWS', name: 'AWSManagedRulesCommonRuleSet',
  ruleActionOverrides: [
    { name: 'GenericRFI_BODY', actionToUse: { count: {} } },
    { name: 'NoUserAgent_HEADER', actionToUse: { count: {} } },
  ],
  scopeDownStatement: {
    byteMatchStatement: {
      fieldToMatch: { uriPath: {} }, positionalConstraint: 'STARTS_WITH',
      searchString: '/api/webhooks/', textTransformations: [{ priority: 0, type: 'LOWERCASE' }],
    },
  },
},
```

C'est une instance du groupe pour les chemins de webhook avec deux surcharges, et une seconde instance du même groupe, de priorité inférieure, sans surcharge, pour tout le reste. Deux copies du groupe, 2 $ par mois, et les règles s'appliquent intégralement à 99 % du trafic.

## La limite de débit est la règle qui mérite sa place

Les groupes managés ont bloqué régulièrement quelques centaines de requêtes par jour de bruit de scanners, que l'application aurait gérées sans problème de toute façon. La règle basée sur le débit, 2 000 requêtes par cinq minutes et par IP, a bloqué trois tentatives de credential stuffing contre la route de connexion et un scraper qui aspirait chaque page produit en boucle. Ce sont les incidents qui auraient coûté quelque chose. Si vous n'activez qu'une règle, activez celle-là, et mettez-en une plus stricte (100 par cinq minutes) spécifiquement sur `/api/auth/*`.

## Ce que nous dirions à une équipe qui active WAF demain

- Deux semaines en `COUNT`, avec la journalisation, avant qu'une seule règle ne bloque. Lisez le log par règle et par chemin.
- Attendez-vous aux cinq règles ci-dessus, à peu près dans cet ordre de probabilité, sur toute application avec des formulaires, des envois ou des webhooks.
- Surchargez par chemin, jamais globalement. Si une règle doit être désactivée partout, l'application a un problème plus gros que la règle.
- Sortez les grosses charges des corps de requêtes (envois présignés) plutôt que d'élargir la limite de corps. Ça règle le problème WAF et la facture de bande passante d'un coup.
- Mettez une limite de débit stricte sur les routes d'authentification. C'est le seul contrôle ici qui a arrêté une vraie attaque.
- Relisez les métriques de count chaque mois. Les groupes managés mettent leurs règles à jour sans vous prévenir, et un nouveau faux positif apparaît comme un nouveau pic.

Seize dollars par mois et un après-midi de lecture de logs. Si vous préférez sauter les deux semaines et partir d'une configuration qui connaît déjà les cinq règles, [parlons-en](/contact).
