Skip to content
WAF pour une application Next.js : les règles managées qui bloquent du trafic légitime, et ce que nous en avons fait
← ← Retour aux Réflexions Cloud

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 :

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.

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.

clientnavigateur · webhook CloudFront + WAF 10 · liste de réputation IP · block 20 · entrées connues comme malveillantes · block 30 · jeu commun · 5 règles → COUNT sur des chemins précis 40 · limite de débit · 2 000 req / 5 min / IP · block allow App Runnerl'application Next.js block 403requête échantillonnée → log S3 Deux semaines en mode COUNT d'abord. Le log de ce qui aurait été bloqué est toute la matière de conception.

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é.

Jeu de règles commun · ce qui s'est déclenché, sur quoi, et la surcharge règlea bloquésurcharge SizeRestrictions_BODYsauvegarde de profil avec avatar base64, 40 KoCOUNT sur 2 routes · envois S3 présignés CrossSiteScripting_BODYdescription de produit en texte enrichiCOUNT sur 2 routes admin · le serveur assainit GenericRFI_BODYwebhook avec URL de reçu · liens de supportCOUNT sur /api/webhooks/* et support NoUserAgent_HEADERwebhook du prestataire de paiement, sans UACOUNT sur /api/webhooks/* · auth par signature EC2MetaDataSSRF_BODYun ticket de support avec une URL de métadonnéesaucune · un blocage en six mois, c'est acceptable Chaque surcharge est limitée à un chemin. La règle continue de bloquer partout ailleurs. Rien n'a été désactivé.

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 :

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.