# Edge protection: WAF & DDoS

> Deploy and tune a WAF without breaking users, virtual-patch while a fix ships, and absorb L3 to L7 DDoS attacks at the edge.

DevSecOps Engineer roadmap · Secure the platform · 20 min read · https://levelcode.org/roadmaps/devsecops/waf-and-ddos

Your public hostname receives attack traffic all day: scanners probing for injection, bots stuffing stolen passwords into the login form, and occasionally a flood big enough to take you offline. The **edge** (a CDN, load balancer or proxy in front of your application) is where you filter that traffic before it costs you CPU, money or data. This lesson covers what a web application firewall (WAF) inspects and what it can't fix, managed rules and tuning, virtual patching, rate and bot rules, the three kinds of DDoS attack, keeping attackers from bypassing the edge, and a runbook for an attack in progress.

## What a WAF inspects, and what it can't fix

A WAF is a layer 7 filter: it parses each HTTP request and evaluates rules against its parts before allowing, blocking, challenging or logging it.

| Part of the request | Example rules |
|---|---|
| Method, path, query string | Path traversal (`../`), SQL fragments in `?q=`, probes for `/.env` |
| Headers and cookies | Scanner user agents, oversized headers, injection in cookie values |
| Body (form, JSON, XML, multipart) | SQL injection or XSS payloads, dangerous uploads |
| Connection metadata | Client IP, country, ASN, IP reputation, TLS fingerprint (JA3/JA4) |
| Request rate | Too many requests from one IP, session or API key |

It can run at the CDN edge (Cloudflare, CloudFront with AWS WAF, Azure Front Door), on a cloud load balancer (AWS WAF on an ALB, Google Cloud Armor), or in your own proxy with **ModSecurity** or **Coraza** (OWASP's Go engine for Caddy, Envoy and Traefik). The concepts are the same everywhere.

The figure shows the layout this lesson builds towards. Watch where each request stops: the injection attempt never reaches the origin, and neither does the attempt to go around the edge.

```sequence
title: Two requests through the edge
participants: Customer, Attacker, Edge (CDN + WAF), Origin
Customer -> Edge (CDN + WAF): GET /products/42
> DNS for shop.example.com points at the CDN, not your servers. TLS terminates at the edge.
Edge (CDN + WAF) -> Edge (CDN + WAF): WAF rules: anomaly score 0
> No rule matched, so the request is allowed.
Edge (CDN + WAF) -> Origin: GET /products/42 + origin secret
> The edge adds a header only it knows, so the origin can recognise genuine edge traffic.
Origin --> Edge (CDN + WAF): 200 OK, Cache-Control: max-age=60
Edge (CDN + WAF) --> Customer: 200 OK
> For the next minute this page is served from cache and never touches the origin.
Attacker -> Edge (CDN + WAF): GET /search?q=1' UNION SELECT ...
Edge (CDN + WAF) -> Edge (CDN + WAF): Rule 942100 matched: score 5
> A critical SQL injection rule adds 5 points, reaching the blocking threshold of 5.
Edge (CDN + WAF) --> Attacker: 403 Forbidden
> Blocked and logged at the edge. The origin spends nothing on it.
Attacker -> Origin: GET /search direct to origin IP
> The attacker found the origin's address in an old DNS record and tries to skip the WAF.
Origin --> Attacker: Dropped: source is not the CDN
> The origin accepts only the CDN's ranges, and rejects requests without the secret.
```

Now the limits. A WAF sees requests, not intent, and knows nothing about your authorisation model or business rules:

| Vulnerability | Can a WAF help? |
|---|---|
| SQL injection, XSS, traversal with common payloads | Partly: known patterns, not every encoding or nested JSON trick |
| A known CVE you can't patch today | Yes, temporarily, with a virtual patch |
| IDOR and broken access control | No: `GET /invoices/1043` from the wrong user looks legitimate |
| Business logic (negative quantities, coupon reuse) | No: the requests are well formed |
| Weak password storage, missing MFA | No |
| Credential stuffing and scraping | Partly, through rate and bot rules |

A WAF is a **compensating control**. It buys time and cuts noise, but the fix for every vulnerability in the secure coding lesson is still in the code. If anyone argues a parameterised query is unnecessary "because the WAF blocks SQL injection", the WAF has made you less secure.

> [!WARNING]
> WAFs inspect only part of the body: AWS WAF reads the first 8 KB on an ALB, and 16 KB (configurable up to 64 KB) on CloudFront. A payload padded past the limit isn't inspected unless the rule's **oversize handling** treats it as a match. ModSecurity and Coraza have equivalent limits (`SecRequestBodyLimit`). Decide deliberately.

## Managed rules: CRS, paranoia levels and anomaly scoring

You start from a **managed rule set** and tune it. The most important open one is the **OWASP Core Rule Set (CRS)**, used by ModSecurity and Coraza and the basis of Azure WAF's Default Rule Set, Cloud Armor's preconfigured rules and Cloudflare's OWASP ruleset. AWS WAF has its own managed groups, such as `AWSManagedRulesCommonRuleSet` and `AWSManagedRulesSQLiRuleSet`. CRS rule IDs encode the attack: 930 file inclusion, 932 command execution, 941 XSS, 942 SQL injection.

**Paranoia levels (PL1 to PL4)** set strictness. PL1, the default, has very few false positives. PL2 catches more evasions and suits sites with sensitive data. PL3 and PL4 need substantial tuning. Higher levels catch more attacks *and* block more real users.

**Anomaly scoring** means CRS doesn't block on the first match. Each matching rule adds points by severity (critical 5, error 4, warning 3, notice 2), and rule 949110 blocks once the total reaches the **inbound threshold**, 5 by default. One critical match blocks; so do two warnings. Both settings live in `crs-setup.conf` (CRS 4 names):

```text
SecAction "id:900000,phase:1,pass,nolog,setvar:tx.blocking_paranoia_level=1"
SecAction "id:900110,phase:1,pass,nolog,\
    setvar:tx.inbound_anomaly_score_threshold=5,\
    setvar:tx.outbound_anomaly_score_threshold=4"
```

CRS 4 also has a **detection paranoia level**: set it above the blocking level and the extra rules log without blocking, so you can measure what PL2 would cost before enabling it.

## Tuning false positives and virtual patching

Never switch a WAF straight to blocking on a busy application. You'll block customers, someone will turn the WAF off, and it won't come back. Roll out in stages:

1. **Detection only**: `SecRuleEngine DetectionOnly` in ModSecurity and Coraza, or **Count** mode for AWS WAF rule groups. Everything is logged, nothing blocked.
2. **Collect a business cycle** of real traffic, including month-end jobs and admin workflows.
3. **Fix each false positive** with the narrowest exclusion that removes it.
4. **Block** (`SecRuleEngine On`), optionally with a high threshold such as 100 at first, stepping down to 5.

### Worked example: SQL in support tickets

Customers paste error output into `POST /api/tickets`. After a week in detection mode, the logs show this (abridged) on dozens of genuine tickets:

```text
ModSecurity: Warning. detected SQLi using libinjection with fingerprint 's&sos' [id "942100"]
  [msg "SQL Injection Attack Detected via libinjection"]
  [data "Matched Data: s&sos found within ARGS:message: ERROR: column \"total\" does not exist ... SELECT id, total FROM orders WHERE"]
  [severity "CRITICAL"] [uri "/api/tickets"]
ModSecurity: Warning. [id "949110"] [msg "Inbound Anomaly Score Exceeded (Total Score: 5)"] [uri "/api/tickets"]
```

The rule is right that the field contains SQL; it just isn't an attack. Your options, widest first:

| Option | Effect | Verdict |
|---|---|---|
| Disable rule 942100 | No libinjection SQL detection anywhere | Far too wide |
| `SecRuleUpdateTargetById 942100 "!ARGS:message"` | Ignores every `message` parameter on every path | Too wide |
| `ctl:ruleRemoveById=942100` on `/api/tickets` | Ignores the rule for every field on that path | Still too wide |
| `ctl:ruleRemoveTargetById=942100;ARGS:message` on `/api/tickets` | Ignores one field on one path | Right |

The narrow version is a **runtime** exclusion placed before CRS (in `REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf`):

```text
# Support tickets contain pasted SQL error output. The handler stores it with a
# parameterised INSERT and renders it escaped. Ticket SEC-412, reviewed 2026-10-02.
SecRule REQUEST_FILENAME "@streq /api/tickets" \
    "id:10010,\
    phase:1,\
    pass,\
    nolog,\
    ctl:ruleRemoveTargetById=942100;ARGS:message"
```

`REQUEST_FILENAME` is the path without the query string, `@streq` requires an exact match, and `ctl` changes CRS's behaviour for this request only. IDs 1 to 99,999 are reserved for your own rules; CRS uses 900,000 and up. If several SQL rules fire on the field, `ctl:ruleRemoveTargetByTag=attack-sqli;ARGS:message` covers the whole 942 family.

The comment matters. Every exclusion is a hole you chose to open; it's safe here only because the code behind it is correct (parameterised and output-encoded). In AWS WAF, the equivalent is a **scope-down statement** on the managed rule group, or setting one rule to Count and re-blocking its label everywhere except that path.

### Virtual patching

A **virtual patch** is a temporary rule that blocks exploitation of one known vulnerability while the real fix ships. Suppose `GET /api/export?format=` passes `format` into a shell command (the command injection from the secure coding lesson). The release takes days; this takes minutes:

```text
# VIRTUAL PATCH for SEC-431 (command injection in export). Remove when v2.14.1 is live.
SecRule REQUEST_FILENAME "@streq /api/export" \
    "id:10100,\
    phase:2,\
    deny,\
    status:403,\
    log,\
    msg:'Virtual patch SEC-431: export format allowlist',\
    chain"
    SecRule ARGS:format "!@rx ^(?:csv|json)$" "t:none"
```

With `chain`, both rules must match: the path is `/api/export` *and* some `format` argument isn't exactly `csv` or `json`. An allowlist of valid values beats listing dangerous characters. Test it with `format=csv` (allowed), `format=csv;id` (blocked), and encoded and repeated variants. Give every virtual patch an owner and an expiry in the ticket, or it becomes a permanent rule nobody understands.

## Rate limits, bots and IP reputation

**Rate-based rules** count requests per key over a window and act above a limit. The api-gateways lesson covers rate-limiting algorithms and per-client quotas; at the edge, rate rules are the blunt first line against floods and password guessing. This AWS WAF rule (as written in the console's JSON editor) blocks any IP sending more than 100 login requests in 5 minutes:

```json
{
  "Name": "login-rate-limit",
  "Priority": 10,
  "Statement": {
    "RateBasedStatement": {
      "Limit": 100,
      "EvaluationWindowSec": 300,
      "AggregateKeyType": "IP",
      "ScopeDownStatement": {
        "ByteMatchStatement": {
          "SearchString": "/login",
          "FieldToMatch": { "UriPath": {} },
          "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
          "PositionalConstraint": "STARTS_WITH"
        }
      }
    }
  },
  "Action": { "Block": {} },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "login-rate-limit"
  }
}
```

The **scope-down statement** counts only matching requests, so heavy browsing doesn't trip the login limit. `EvaluationWindowSec` accepts 60, 120, 300 or 600. Besides `IP`, `AggregateKeyType` can be `FORWARDED_IP`, `CONSTANT` (one global counter) or `CUSTOM_KEYS` (a header, cookie or query argument, so you can limit per session or API key). Check your peak legitimate rate first: an office NAT or mobile carrier can put thousands of users behind one IP.

**Bots** are harder. Credential stuffing, scraping and card testing run through residential proxies, so each IP sends only a few requests. Bot management (AWS WAF Bot Control, Cloudflare Bot Management, reCAPTCHA Enterprise with Cloud Armor, Azure's Bot Manager rules) combines signals: verified good crawlers, TLS and header fingerprints (`python-requests` doesn't look like Chrome whatever its `User-Agent` says), JavaScript or proof-of-work challenges a real browser solves invisibly, and behaviour such as one client trying thousands of usernames. Prefer **challenge** to **block** when unsure: a real person passes and carries on.

**Geo and IP reputation** rules block or challenge by country, ASN (hosting providers hitting a consumer login page) or reputation lists such as `AWSManagedRulesAmazonIpReputationList`. They cut noise cheaply but are blunt: attackers rent servers anywhere, and customers travel and use VPNs. Use them to reduce volume and as emergency levers, not as a security boundary.

## DDoS: three layers of attack

A distributed denial-of-service (DDoS) attack uses thousands or millions of sources, usually a botnet, to make you unavailable, so no single address can be blocked.

| Type | Exhausts | Examples | Measured in |
|---|---|---|---|
| Volumetric (L3/L4) | Network bandwidth | UDP floods; reflection and amplification via DNS, NTP or memcached, where a small spoofed request triggers a large response aimed at you | Bits per second, now multiple Tbps |
| Protocol (L3/L4) | Connection state in servers, firewalls, load balancers | SYN floods, ACK floods, fragmented packets | Packets per second |
| Application (L7) | CPU, database, threads | HTTP floods on search or login; random query strings that bust the cache; Slowloris; HTTP/2 Rapid Reset (CVE-2023-44487) | Requests per second |

You can't stop the first two on your own servers. If your origin has a 10 Gbps uplink and 400 Gbps arrives, the link is full before your firewall sees a packet. Defence has to happen on a network **bigger than the attack**. Large CDNs and cloud edges announce the same IPs from hundreds of locations (**anycast**), so internet routing splits the attack across all of them; each location scrubs spoofed UDP and SYN floods before forwarding anything; and every cached response is a request the origin never handles.

L7 floods look like real requests, so they need the L7 tools: rate rules, challenges, signatures and caching. The major providers bundle these:

| Provider | Network DDoS (L3/4) | WAF and L7 | Origin protection |
|---|---|---|---|
| AWS | Shield Standard, free and automatic; Shield Advanced adds L7 auto-mitigation, a response team and cost protection | AWS WAF on CloudFront, ALB, API Gateway; Bot Control | CloudFront origin-facing prefix list; VPC origins; secret header |
| Cloudflare | Unmetered mitigation on all plans | Managed and OWASP rulesets; rate limiting; Bot Management | Authenticated Origin Pulls (mTLS); Cloudflare Tunnel |
| Azure | Built-in infrastructure protection; DDoS Protection tiers for public IPs | Azure WAF on Front Door or Application Gateway; Bot Manager rules | `AzureFrontDoor.Backend` service tag plus `X-Azure-FDID` check; Private Link |
| Google Cloud | Always on for external load balancers; Cloud Armor Enterprise adds response support and bill protection | Cloud Armor with CRS-based rules, `throttle` and `rate_based_ban`, Adaptive Protection | Backends with no public IP, firewall allowing only Google front ends |

**Autoscaling cuts both ways.** Scaling out under an L7 flood may keep you up, but you pay for every replica serving bots; the autoscaling lesson covers this "denial of wallet" and the limits that bound it. Filter first, scale second: traffic blocked at the CDN is cheap, traffic absorbed with compute isn't.

## Protecting the origin

None of this helps if attackers can reach the origin directly. Origin IPs leak through old DNS records, email headers and historical DNS services. Close the gap in two layers.

**1. Accept connections only from the edge.** On AWS, reference CloudFront's managed prefix list in the load balancer's security group, so it stays current without scripts:

```hcl
data "aws_ec2_managed_prefix_list" "cloudfront" {
  name = "com.amazonaws.global.cloudfront.origin-facing"
}

resource "aws_vpc_security_group_ingress_rule" "alb_https_from_cloudfront" {
  security_group_id = aws_security_group.alb.id
  description       = "HTTPS from CloudFront only"
  prefix_list_id    = data.aws_ec2_managed_prefix_list.cloudfront.id
  from_port         = 443
  to_port           = 443
  ip_protocol       = "tcp"
}
```

Better still, give the origin no public address: CloudFront VPC origins, Front Door Private Link and Cloudflare Tunnel all connect the edge to a private origin.

**2. Prove each request came through *your* configuration.** CDN ranges are shared: an attacker can create their own distribution or zone pointing at your origin and arrive from the very ranges you allowed, without your WAF rules. So also require something only your configuration sends: a **client certificate** (Cloudflare Authenticated Origin Pulls with a per-zone certificate, not the global one), an **origin secret** header such as `X-Origin-Verify` checked by the load balancer and rotated from a secrets manager, or a **provider ID** such as Front Door's `X-Azure-FDID`.

> [!IMPORTANT]
> After moving behind a CDN, assume the old origin IP is known. Replace it once the allowlist is in place, and remove DNS records that point straight at the origin, including forgotten ones like `direct.shop.example.com`.

## Runbook: an ongoing L7 attack

Write this in advance, keep it with your incident runbooks, and follow the incident lifecycle: declare early, assign an incident commander, keep a timeline.

1. **Confirm it's an attack.** Requests per second, origin latency and errors up; cache hit ratio down. Rule out a flash crowd (a launch, a marketing email) before blocking anyone.
2. **Declare and staff.** Page the edge configuration owner, and open a case with your provider's DDoS response team if your plan includes one.
3. **Characterise the traffic.** Group edge logs by path, ASN, country, user agent, TLS fingerprint and header patterns. Attacks usually hammer one expensive path with a shared fingerprint.
4. **Mitigate in escalating steps**, watching each change's effect: cache more (longer lifetimes, ignore unknown query strings); rate-limit the targeted path; challenge the matching fingerprint or ASN; block a precise signature; as a last resort, challenge the whole site.
5. **Check the origin.** Any traffic in origin logs that didn't come through the edge means a protection gap. Cap autoscaling.
6. **Communicate** on a fixed schedule, and **expect adaptation**: attackers change paths and tools in waves.
7. **Recover and learn.** Remove temporary rules deliberately, keep the attack signatures as detections, and ask in the postmortem why that endpoint was expensive enough to target.

## Practice

Acme runs `shop.example.com` on CloudFront with AWS WAF in front of an ALB, and an internal portal behind Coraza with CRS 4. Three findings:

1. On the portal, rule 941100 (XSS via libinjection) blocks product managers saving HTML in the `description` field of `POST /admin/products`. Descriptions are sanitised server-side with an allowlist HTML sanitiser.
2. On the shop, a credential-stuffing run sent 40,000 attempts to `POST /account/login` from 9,000 IPs overnight.
3. A penetration tester reached the ALB directly at its public DNS name and bypassed the WAF.

For each, write the rule or configuration you'd add and state the residual risk.

**Solution**

**1. A scoped exclusion**, one rule, one field, one path, placed before CRS:

```text
# Product descriptions are rich HTML, sanitised server-side with an allowlist
# sanitiser before storage. Ticket SEC-455, approved by appsec 2026-10-09.
SecRule REQUEST_FILENAME "@streq /admin/products" \
    "id:10020,\
    phase:1,\
    pass,\
    nolog,\
    ctl:ruleRemoveTargetById=941100;ARGS:description"
```

If other XSS rules fire too, use `ctl:ruleRemoveTargetByTag=attack-xss;ARGS:description`. Residual risk: the sanitiser is now the only control on that field, so it needs XSS test cases, and the admin path needs strong authentication.

**2. Layered bot defence.** 40,000 attempts from 9,000 IPs is about 4 or 5 per IP, so an IP rate limit alone won't stop it. Add one as a floor: the lesson's rule with `"Limit": 20`, `"SearchString": "/account/login"` and `"PositionalConstraint": "EXACTLY"`. Then add what actually targets this attack: AWS WAF's account takeover prevention rule group (`AWSManagedRulesATPRuleSet`), which inspects login requests and checks credentials against a stolen-credential database; Bot Control or a challenge for clients failing fingerprint checks; the IP reputation group; and per-account throttling in the application. Residual risk: stuffing succeeds wherever passwords are reused, so MFA is the real fix.

**3. Origin protection.** Restrict the ALB security group to the CloudFront origin-facing prefix list (the lesson's Terraform) and remove any `0.0.0.0/0` rule. Because every CloudFront distribution uses those ranges, also add a custom origin header such as `X-Origin-Verify` with a random value from Secrets Manager; make the ALB listener's default action a fixed 403 response, with a rule forwarding only requests that carry the correct header. Better still, move the ALB to private subnets behind a CloudFront VPC origin. Retest: a direct request should time out, and one through another distribution should get 403.

## Key takeaways

- A WAF filters layer 7 requests and stops common attack patterns, but can't see access control or business logic flaws: it's a compensating control, never a substitute for secure code.
- Start from a managed rule set such as the OWASP CRS; paranoia levels set strictness, and anomaly scoring blocks when the matched rules' points reach a threshold.
- Roll out in detection or Count mode, and fix false positives with the narrowest exclusion (one rule, one field, one path) and a recorded reason.
- Virtual patches block a known vulnerability while the fix ships; use allowlists, test them, and give them an owner and an expiry.
- Volumetric and protocol DDoS must be absorbed upstream by anycast networks bigger than the attack; L7 floods need rate rules, challenges, signatures and caching. Filter before you scale.
- Lock the origin to your edge: allow only the CDN's ranges *and* require a secret, certificate or provider ID that only your configuration sends.

## Quiz

1. A WAF with the OWASP CRS is in blocking mode in front of an API. Which vulnerability is it least able to stop?

   A. A reflected XSS payload in a query parameter
   B. A path traversal attempt using ../../etc/passwd
   C. A user changing the invoice ID in GET /invoices/1043 to read another customer's invoice
   D. A SQL injection probe using UNION SELECT

   **Answer: C.** IDOR requests are syntactically identical to legitimate ones; only the application knows the invoice belongs to someone else. The other three contain recognisable attack patterns that CRS rules target.

2. With CRS's default inbound anomaly threshold of 5, a request matches two rules of WARNING severity (3 points each). What happens in blocking mode?

   A. It is allowed, because no single rule was critical
   B. It is blocked, because the total score of 6 reaches the threshold
   C. It is blocked by the first rule that matched
   D. It is allowed but logged, because warnings never block

   **Answer: B.** Anomaly scoring adds the points of every matching rule and blocks once the total reaches the threshold. Two warnings (3 + 3) exceed 5, even though neither alone would.

3. Your origin's security group only allows CloudFront's origin-facing IP ranges. Why is that not sufficient origin protection on its own?

   A. CloudFront's ranges change hourly and the prefix list is not updated
   B. Security groups cannot reference prefix lists
   C. IP allowlists don't work over HTTPS
   D. An attacker can create their own CloudFront distribution pointing at your origin, arriving from the same ranges without your WAF rules

   **Answer: D.** CDN ranges are shared by all customers. You also need something only your configuration sends, such as a secret origin header, an mTLS client certificate or a provider-issued profile ID.

4. A 600 Gbps UDP reflection attack targets a service hosted on servers with a 10 Gbps uplink. Which defence actually helps?

   A. Absorbing and filtering the traffic upstream on an anycast CDN or provider network larger than the attack
   B. Adding nftables rules on the servers to drop UDP
   C. Autoscaling the application to more replicas
   D. Lowering the WAF's paranoia level

   **Answer: A.** The uplink is saturated before packets reach the servers, so local firewall rules and more replicas can't help, and a WAF works at layer 7. Only a network larger than the attack can absorb and scrub it.
