# WAF für eine Next.js-App: die Managed Rules, die legitimen Traffic blockieren, und was wir dagegen getan haben

AWS WAF vor einer Webanwendung einzuschalten ist ein CDK-Construct und eine Handvoll Häkchen bei Managed Rule Groups. Es ist außerdem, nach unserer Erfahrung, garantiert, dass es in der ersten Woche etwas Echtes blockiert: eine Formularübermittlung, einen Webhook, einen Bild-Upload, ein Rich-Text-Feld. Die Regeln sind nicht falsch. Sie sind generisch, und Ihre Anwendung ist es nicht.

Das hier ist die WAF-Konfiguration, die wir vor einer öffentlichen Next.js-Anwendung auf App Runner betreiben, die fünf Managed Rules, die legitimen Traffic blockiert haben, der Grund, warum jede ausgelöst hat, und der Override, der es behoben hat, ohne die Regel für alles abzuschalten.

## Das Setup

CloudFront davor, App Runner dahinter, WAF an die CloudFront-Distribution gehängt, sodass sie jeden Request sieht, bevor die Origin es tut. Drei Managed Rule Groups plus ein Rate-Limit:

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

Kosten: $5 für die Web ACL, $1 pro Rule Group, $0,60 pro Million Requests. Etwa $16 im Monat bei uns, die billigste Sicherheitskontrolle auf der Rechnung nach der [Data Protection Policy](/de/blog/cloudwatch-data-protection-policies-pii).

## Erst zählen, später blockieren

Wir haben nicht im Block-Modus angefangen. Jede Managed Group lief zwei Wochen lang mit auf `COUNT` überschriebener Aktion, das Sampled-Request-Log ging nach S3 und ein CloudWatch-Dashboard zeigte Treffer pro Regel. Diese Phase hat die Liste unten hervorgebracht. Ohne sie hätten wir jeden dieser Punkte gefunden, indem ein Nutzer ein kaputtes Formular meldet, und so finden es die meisten Teams.

Die Dashboard-Abfrage ist einfach: Anzahl passender Requests pro `terminatingRuleId` und `ruleGroupList[].ruleId`, gefiltert auf die mit Aktion `COUNT`. Alles mit echtem Volumen, das nicht offensichtlich ein Angriff ist, wird von Hand geprüft: welcher Pfad, welcher Body, welcher Client.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Request-Pfad und WAF-Auswertung. Ein Request von einem Browser oder Webhook-Sender erreicht CloudFront, wo die WAF ihn gegen die IP-Reputationsliste, bekannte schädliche Eingaben, das Common Rule Set mit fünf auf bestimmten Pfaden auf Count überschriebenen Regeln und ein Rate-Limit von 2.000 Requests pro 5 Minuten pro IP prüft. Erlaubte Requests gehen an App Runner. Blockierte bekommen 403 und einen Sample-Log-Eintrag in S3. Treffer im Count-Modus werden geloggt, aber erlaubt.">
<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 · IP-Reputationsliste · 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 · bekannte schädliche Eingaben · 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 · Common Rule Set · 5 Regeln → COUNT auf bestimmten Pfaden</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 · Rate-Limit · 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">die Next.js-App</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">Sample-Request → S3-Log</text>
<text x="450" y="222" text-anchor="middle" fill="#9aa3c7">Zuerst zwei Wochen im COUNT-Modus. Das Log dessen, was blockiert worden wäre, ist die gesamte Designgrundlage.</text>
</g>
</svg>
</div>

## Die fünf Regeln, die echten Traffic blockiert haben

Alle fünf stehen im `AWSManagedRulesCommonRuleSet`, der Gruppe, die alle einschalten und die die meisten Meinungen darüber hat, wie ein Request auszusehen hat.

**1. `SizeRestrictions_BODY`: jeder Request-Body über 8 KB.** Die Regel existiert, weil übergroße Bodies ein häufiger Angriffsvektor sind und weil WAF ohnehin nur die ersten 8 KB inspiziert (16 KB auf CloudFront in neueren Konfigurationen). Unser Profil-Bearbeitungsformular sendet einen JSON-Payload mit einem Avatar als Base64-Data-URL. Das sind 40 KB. Blockiert. Jedes Speichern eines Profils mit Foto, weg, für die zwei Wochen, die es gedauert hätte, bis jemand es meldet.

*Fix:* Die Regel bleibt global im Block-Modus und wird per Scope-Down-Statement auf dem URI-Pfad für die zwei Routen, die legitim große Bodies annehmen, auf `COUNT` überschrieben. Bessere Lösung, die wir ebenfalls umgesetzt haben: Uploads gehen per Presigned URL nach S3, und das Formular sendet einen Schlüssel, nicht die Bytes, sodass der Body wieder 2 KB hat.

**2. `CrossSiteScripting_BODY`: HTML in einem Request-Body.** Ein Rich-Text-Editor für Produktbeschreibungen sendet bereinigtes HTML. Für die XSS-Regel sind `<p>` und `<a href>` in einem Body ein Angriff. Beim Speichern blockiert.

*Fix:* Override auf `COUNT` für die zwei Admin-Routen, die HTML annehmen, überall sonst weiter blockieren. Die Anwendung bereinigt bereits serverseitig mit einer Allowlist; die WAF-Regel hat diese Prüfung mit einem stumpferen Werkzeug dupliziert.

**3. `GenericRFI_BODY`: eine URL in einem Request-Body.** Die Remote-File-Inclusion-Erkennung feuert auf `http://`- oder `https://`-Strings im Body. Ein Webhook unseres Zahlungsanbieters enthält die URL des Belegs. Ein Nutzer, der einen Link in ein Support-Formular einfügt, enthält eine URL. Beides blockiert.

*Fix:* `COUNT` auf `/api/webhooks/*` und der Support-Formular-Route. Das ist die Regel mit der höchsten False-Positive-Rate auf jeder App, die Freitext annimmt, und die erste, die man sich ansieht, wenn Formulare rätselhaft scheitern.

**4. `NoUserAgent_HEADER`: Request ohne User-Agent.** Browser senden immer einen. Manche Webhook-Sender nicht, und einer von unseren tat es nicht. Jede Zahlungsbestätigung dieses Anbieters wurde blockiert, was wir innerhalb eines Tages fanden, weil es den Checkout brach, nicht innerhalb von zwei Wochen.

*Fix:* `COUNT`, eingeschränkt auf `/api/webhooks/*`. Webhook-Routen werden ohnehin per Signatur authentifiziert; die User-Agent-Prüfung bringt dort nichts.

**5. `EC2MetaDataSSRF_BODY`: der String `169.254.169.254` in einem Body.** Ein Feld, in das ein Operator Log-Auszüge für ein Support-Ticket einfügt, enthielt eine Instance-Metadata-URL aus einer Debug-Sitzung. Blockiert. Einmal. Wir nehmen es auf, weil es ein gutes Beispiel für eine Regel ist, die *richtig* liegt, was der String ist, und *falsch*, ob es eine Rolle spielt.

*Fix:* keiner. Ein Block in sechs Monaten auf einer internen Route ist in Ordnung. Nicht jedes False Positive braucht einen Override; der Override ist ein dauerhaftes Loch, und der Block war ein Einzelfall.

<div class="article-figure">
<svg viewBox="0 0 900 250" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Tabelle der fünf Regeln: SizeRestrictions_BODY blockierte Profil-Speicherungen mit Base64-Avataren, behoben mit Scope-Down-Override und Presigned Uploads. CrossSiteScripting_BODY blockierte Rich-Text-Speicherungen, Override auf zwei Admin-Routen. GenericRFI_BODY blockierte Webhooks und Support-Formulare mit URLs, Override auf Webhook- und Support-Routen. NoUserAgent_HEADER blockierte einen Zahlungsanbieter-Webhook, Override auf Webhook-Routen. EC2MetaDataSSRF_BODY blockierte ein internes Support-Ticket, kein 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">Common Rule Set · was auslöste, worauf, und der Override</text>
<g fill="#9aa3c7"><text x="20" y="50" font-weight="700" fill="#f1f3ff">Regel</text><text x="260" y="50" font-weight="700" fill="#f1f3ff">blockiert</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">Profil-Speicherung mit Base64-Avatar, 40 KB</text><text x="560" y="80" fill="#4fffb0">COUNT auf 2 Routen · Presigned-S3-Uploads</text>
<text x="20" y="108" fill="#ff6b8a">CrossSiteScripting_BODY</text><text x="260" y="108" fill="#f1f3ff">Rich-Text-Produktbeschreibung</text><text x="560" y="108" fill="#4fffb0">COUNT auf 2 Admin-Routen · Server bereinigt</text>
<text x="20" y="136" fill="#ff6b8a">GenericRFI_BODY</text><text x="260" y="136" fill="#f1f3ff">Webhook mit Beleg-URL · Support-Formular-Links</text><text x="560" y="136" fill="#4fffb0">COUNT auf /api/webhooks/* und Support</text>
<text x="20" y="164" fill="#ff6b8a">NoUserAgent_HEADER</text><text x="260" y="164" fill="#f1f3ff">Zahlungsanbieter-Webhook ohne UA</text><text x="560" y="164" fill="#4fffb0">COUNT auf /api/webhooks/* · Signatur-Auth</text>
<text x="20" y="192" fill="#ff6b8a">EC2MetaDataSSRF_BODY</text><text x="260" y="192" fill="#f1f3ff">ein Support-Ticket mit Metadata-URL</text><text x="560" y="192" fill="#ffd166">keiner · ein Block in sechs Monaten ist okay</text>
<line x1="20" y1="204" x2="880" y2="204" stroke="#2a3150"/>
<text x="20" y="230" fill="#9aa3c7">Jeder Override ist auf einen Pfad beschränkt. Die Regel blockiert überall sonst weiter. Nichts wurde abgeschaltet.</text>
</g>
</svg>
</div>

## Wie ein Override geschrieben wird

Die wichtige Eigenschaft: Die Regel wird *nicht* deaktiviert. Sie wird nur auf `COUNT` geändert, wenn ein Scope-Down-Statement passt, und blockiert überall sonst. In CDK, im Statement der Managed Rule Group:

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

Das ist eine Instanz der Gruppe für die Webhook-Pfade mit zwei Overrides und eine zweite Instanz derselben Gruppe mit niedrigerer Priorität und ohne Overrides für alles andere. Zwei Kopien der Gruppe, $2 im Monat, und die Regeln gelten für 99 % des Traffics vollständig.

## Das Rate-Limit ist die Regel, die sich verdient macht

Die Managed Groups haben stetig ein paar hundert Requests am Tag an Scanner-Rauschen blockiert, das die Anwendung ohnehin verkraftet hätte. Die ratenbasierte Regel, 2.000 Requests pro fünf Minuten pro IP, hat drei Credential-Stuffing-Versuche gegen die Login-Route und einen Scraper blockiert, der jede Produktseite in einer Schleife abzog. Das sind die Vorfälle, die etwas gekostet hätten. Wenn Sie eine Regel einschalten, dann diese, und setzen Sie eine engere (100 pro fünf Minuten) speziell auf `/api/auth/*`.

## Was wir einem Team sagen würden, das morgen WAF einschaltet

- Zwei Wochen in `COUNT`, mit Logging, bevor eine einzige Regel blockiert. Das Log nach Regel und Pfad lesen.
- Erwarten Sie die fünf Regeln oben, ungefähr in dieser Wahrscheinlichkeitsreihenfolge, auf jeder App mit Formularen, Uploads oder Webhooks.
- Override per Pfad, nie global. Muss eine Regel überall aus, hat die Anwendung ein größeres Problem als die Regel.
- Große Payloads aus Request-Bodies herausnehmen (Presigned Uploads), statt das Body-Limit zu erweitern. Das behebt das WAF-Problem und die Bandbreitenrechnung zugleich.
- Ein enges Rate-Limit auf Authentifizierungsrouten. Es ist die eine Kontrolle hier, die einen echten Angriff gestoppt hat.
- Die Count-Metriken monatlich lesen. Managed Groups aktualisieren ihre Regeln, ohne es zu sagen, und ein neues False Positive zeigt sich als neue Spitze.

Sechzehn Dollar im Monat und ein Nachmittag Log-Lesen. Wenn Sie die zwei Wochen lieber überspringen und mit einer Konfiguration starten möchten, die die fünf Regeln bereits kennt, [sprechen Sie mit uns](/contact).
