# WAF pentru o aplicație Next.js: regulile managed care blochează trafic legitim, și ce am făcut cu ele

Să pornești AWS WAF în fața unei aplicații web e un construct CDK și o bifă de grupuri de reguli managed. E și, din experiența noastră, garantat să blocheze ceva real în prima săptămână: o trimitere de formular, un webhook, un upload de imagine, un câmp de text formatat. Regulile nu greșesc. Sunt generice, iar aplicația ta nu e.

Asta e configurația de WAF pe care o rulăm în fața unei aplicații Next.js publice pe App Runner, cele cinci reguli managed care au blocat trafic legitim, motivul pentru care s-a declanșat fiecare și override-ul care a rezolvat fără să oprim regula pentru tot.

## Configurația

CloudFront în față, App Runner în spate, WAF atașat distribuției CloudFront, ca să vadă fiecare request înaintea originii. Trei grupuri de reguli managed plus o limită de rată:

```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, /* override-uri mai jos */),
    {
      name: 'rate-limit', priority: 40,
      statement: { rateBasedStatement: { limit: 2000, aggregateKeyType: 'IP' } },
      action: { block: {} },
      visibilityConfig: { sampledRequestsEnabled: true, cloudWatchMetricsEnabled: true, metricName: 'rate-limit' },
    },
  ],
});
```

Cost: $5 pentru web ACL, $1 per grup de reguli, $0,60 per milion de request-uri. Cam $16 pe lună la noi, cel mai ieftin control de securitate de pe factură după [politica de protecție a datelor](/ro/blog/cloudwatch-data-protection-policies-pii).

## Întâi numeri, apoi blochezi

N-am început în modul block. Fiecare grup managed a rulat cu acțiunea suprascrisă pe `COUNT` timp de două săptămâni, cu logul de request-uri eșantionate mergând în S3 și un dashboard CloudWatch cu numărul de potriviri per regulă. Perioada aia a produs lista de mai jos. Fără ea, am fi găsit fiecare dintre ele printr-un utilizator care raportează un formular stricat, adică felul în care le găsesc majoritatea echipelor.

Query-ul de dashboard e simplu: numărul de request-uri potrivite per `terminatingRuleId` și `ruleGroupList[].ruleId`, filtrat pe cele cu acțiunea `COUNT`. Orice are volum real și nu e evident un atac e examinat de mână: ce cale, ce body, ce client.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Calea request-ului și evaluarea WAF. Un request dintr-un browser sau de la un emițător de webhook ajunge la CloudFront, unde WAF îl evaluează contra listei de reputație IP, a input-urilor cunoscute ca rele, a setului comun de reguli cu cinci reguli suprascrise pe count pe căi specifice, și a unei limite de rată de 2.000 de request-uri per 5 minute per IP. Request-urile permise merg la App Runner. Cele blocate primesc 403 și o intrare de log eșantionată în S3. Potrivirile în mod count sunt logate, dar permise.">
<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">browser · 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 · lista de reputație 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 · input-uri cunoscute ca rele · 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 · setul comun · 5 reguli → COUNT pe căi specifice</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 · limită de rată · 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">aplicația 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">request eșantionat → log S3</text>
<text x="450" y="222" text-anchor="middle" fill="#9aa3c7">Două săptămâni în modul COUNT întâi. Logul cu ce ar fi fost blocat e tot input-ul de design.</text>
</g>
</svg>
</div>

## Cele cinci reguli care au blocat trafic real

Toate cinci sunt în `AWSManagedRulesCommonRuleSet`, grupul pe care îl activează toată lumea și cel cu cele mai multe opinii despre cum ar trebui să arate un request.

**1. `SizeRestrictions_BODY`: orice body de request peste 8 KB.** Regula există pentru că body-urile supradimensionate sunt un vector de atac comun și pentru că WAF inspectează oricum doar primii 8 KB (16 KB pe CloudFront în configurațiile mai noi). Formularul nostru de editare a profilului trimite un payload JSON cu un avatar ca data URL base64. Adică 40 KB. Blocat. Fiecare salvare de profil de la un utilizator cu poză, dispărută, pentru cele două săptămâni cât i-ar fi luat cuiva s-o raporteze.

*Rezolvare:* regula rămâne în modul block global și e suprascrisă pe `COUNT` pentru cele două rute care acceptă legitim body-uri mari, printr-un scope-down statement pe calea URI. Rezolvarea mai bună, pe care am făcut-o și noi: upload-urile merg în S3 printr-un presigned URL, iar formularul trimite o cheie, nu octeții, deci body-ul e din nou 2 KB.

**2. `CrossSiteScripting_BODY`: HTML într-un body de request.** Un editor de text formatat pentru descrieri de produse trimite HTML sanitizat. Pentru regula XSS, `<p>` și `<a href>` într-un body sunt un atac. Blocat la salvare.

*Rezolvare:* override pe `COUNT` pentru cele două rute de admin care acceptă HTML, și blocare în continuare peste tot. Aplicația sanitizează deja cu o listă albă pe server; regula WAF duplica verificarea aia cu un instrument mai grosier.

**3. `GenericRFI_BODY`: un URL într-un body de request.** Detecția de remote-file-inclusion se declanșează pe string-uri `http://` sau `https://` în body. Un webhook de la procesatorul nostru de plăți include URL-ul chitanței. Un utilizator care lipește un link într-un formular de suport include un URL. Ambele blocate.

*Rezolvare:* `COUNT` pe `/api/webhooks/*` și pe ruta formularului de suport. E regula cu cea mai mare rată de fals-pozitive pe orice aplicație care acceptă text liber, și prima la care te uiți dacă formularele pică misterios.

**4. `NoUserAgent_HEADER`: request fără User-Agent.** Browserele trimit mereu unul. Unii emițători de webhook nu, și unul de-al nostru nu trimitea. Fiecare confirmare de plată de la procesatorul ăla era blocată, ceea ce am găsit într-o zi, pentru că a stricat checkout-ul, nu în două săptămâni.

*Rezolvare:* `COUNT` restrâns la `/api/webhooks/*`. Rutele de webhook sunt oricum autentificate prin semnătură; verificarea User-Agent nu adaugă nimic acolo.

**5. `EC2MetaDataSSRF_BODY`: string-ul `169.254.169.254` într-un body.** Un câmp în care un operator lipește fragmente de log pentru un tichet de suport conținea un URL de instance metadata dintr-o sesiune de depanare. Blocat. O dată. Îl includem pentru că e un exemplu bun de regulă care are *dreptate* despre ce e string-ul și *greșește* despre dacă contează.

*Rezolvare:* niciuna. Un blocaj în șase luni pe o rută internă e în regulă. Nu fiecare fals-pozitiv are nevoie de override; override-ul e o gaură permanentă, iar blocajul a fost o întâmplare.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Tabel cu cele cinci reguli: SizeRestrictions_BODY a blocat salvările de profil cu avatare base64, rezolvat cu un override cu scope-down și upload-uri presigned. CrossSiteScripting_BODY a blocat salvările de text formatat, override pe două rute de admin. GenericRFI_BODY a blocat webhook-uri și formulare de suport cu URL-uri, override pe rutele de webhook și suport. NoUserAgent_HEADER a blocat un webhook al procesatorului de plăți, override pe rutele de webhook. EC2MetaDataSSRF_BODY a blocat un tichet de suport intern, fără override.">
<g font-family="Inter,system-ui,sans-serif" font-size="11">
<text x="20" y="22" fill="#f1f3ff" font-size="14" font-weight="700">Setul comun de reguli · ce s-a declanșat, pe ce, și override-ul</text>
<g fill="#9aa3c7"><text x="20" y="50" font-weight="700" fill="#f1f3ff">regula</text><text x="260" y="50" font-weight="700" fill="#f1f3ff">a blocat</text><text x="560" y="50" font-weight="700" fill="#f1f3ff">override</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">salvare profil cu avatar base64, 40 KB</text><text x="560" y="80" fill="#4fffb0">COUNT pe 2 rute · upload-uri presigned S3</text>
<text x="20" y="108" fill="#ff6b8a">CrossSiteScripting_BODY</text><text x="260" y="108" fill="#f1f3ff">descriere de produs cu text formatat</text><text x="560" y="108" fill="#4fffb0">COUNT pe 2 rute admin · serverul sanitizează</text>
<text x="20" y="136" fill="#ff6b8a">GenericRFI_BODY</text><text x="260" y="136" fill="#f1f3ff">webhook cu URL de chitanță · link-uri în suport</text><text x="560" y="136" fill="#4fffb0">COUNT pe /api/webhooks/* și suport</text>
<text x="20" y="164" fill="#ff6b8a">NoUserAgent_HEADER</text><text x="260" y="164" fill="#f1f3ff">webhook al procesatorului de plăți, fără UA</text><text x="560" y="164" fill="#4fffb0">COUNT pe /api/webhooks/* · auth prin semnătură</text>
<text x="20" y="192" fill="#ff6b8a">EC2MetaDataSSRF_BODY</text><text x="260" y="192" fill="#f1f3ff">un tichet de suport cu un URL de metadata</text><text x="560" y="192" fill="#ffd166">niciunul · un blocaj în șase luni e în regulă</text>
<line x1="20" y1="204" x2="880" y2="204" stroke="#2a3150"/>
<text x="20" y="230" fill="#9aa3c7">Fiecare override e restrâns la o cale. Regula blochează în continuare peste tot în rest. Nimic n-a fost oprit.</text>
</g>
</svg>
</div>

## Cum se scrie un override

Proprietatea importantă: regula *nu* e dezactivată. E schimbată pe `COUNT` doar când se potrivește un scope-down statement, și blochează peste tot în rest. În CDK, pe statement-ul grupului de reguli managed:

```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' }],
    },
  },
},
```

Asta e o instanță a grupului pentru căile de webhook, cu două override-uri, și o a doua instanță a aceluiași grup, cu prioritate mai mică, fără override-uri, pentru tot restul. Două copii ale grupului, $2 pe lună, iar regulile se aplică integral pe 99 % din trafic.

## Limita de rată e regula care își câștigă pâinea

Grupurile managed au blocat constant câteva sute de request-uri pe zi de zgomot de scanere, pe care aplicația le-ar fi gestionat oricum. Regula bazată pe rată, 2.000 de request-uri per cinci minute per IP, a blocat trei încercări de credential stuffing pe ruta de login și un scraper care trăgea fiecare pagină de produs în buclă. Alea sunt incidentele care ar fi costat ceva. Dacă activezi o singură regulă, activeaz-o pe aia, și pune una mai strânsă (100 per cinci minute) specific pe `/api/auth/*`.

## Ce i-am spune unei echipe care activează WAF mâine

- Două săptămâni în `COUNT`, cu logging pornit, înainte ca o singură regulă să blocheze. Citește logul pe regulă și pe cale.
- Așteaptă-te la cele cinci reguli de mai sus, cam în ordinea asta de probabilitate, pe orice aplicație cu formulare, upload-uri sau webhook-uri.
- Override pe cale, niciodată global. Dacă o regulă trebuie oprită peste tot, aplicația are o problemă mai mare decât regula.
- Mută payload-urile mari din body-urile de request (upload-uri presigned) în loc să lărgești limita de body. Rezolvă problema de WAF și factura de bandă deodată.
- Pune o limită de rată strânsă pe rutele de autentificare. E singurul control de aici care a oprit un atac real.
- Recitește metricile de count lunar. Grupurile managed își actualizează regulile fără să-ți spună, iar un fals-pozitiv nou apare ca un vârf nou.

Șaisprezece dolari pe lună și o după-amiază de citit loguri. Dacă preferi să sari peste cele două săptămâni și să pornești de la o configurație care știe deja de cele cinci reguli, [hai să vorbim](/contact).
