The response plan from 04-04 always ended in the same place. Marta identifies the pattern of the attack —requests to /buscar with random parameters, from 8,400 addresses across 61 countries, with a forged user agent that is identical in 91 % of cases— and at that point she needs a tool capable of acting on exactly that. Not on an IP address. Not on a port. On the content of the HTTP request.

Neither sg-mercadofresco-tienda nor the NACL on the public subnets can do it, and not for lack of features: they simply work at a different layer. A security group sees a TCP packet heading for port 443 and has no way of knowing whether it carries a search from a customer in Getafe or an SQL injection. Shield, for its part, deals with volume and malformed packets: an attack made up of perfectly valid HTTP requests is invisible to it.

AWS WAF (Web Application Firewall) is the firewall that does look inside. It inspects the method, the path, the query string, the headers, the cookies and the body of every request, and then decides. This lesson puts it in front of the E2QWERTY123ABC distribution and of alb-mercadofresco-tienda, and closes the security module with it.

Warning. The approach in this lesson is strictly defensive: protecting your own application. The examples are teaching material and are simplified. Any WAF configuration that is going to be applied to a real environment with customer data —especially where the GDPR or PCI DSS are involved— must be reviewed by a security or compliance professional before it goes into production. A badly calibrated rule blocks legitimate customers and can cost more than the attack it is meant to protect you from; the deployment procedure described here is not an optional recommendation.

Contents

  1. What WAF sees that a security group cannot
  2. Where a web ACL is associated
  3. Components: web ACL, rules and rule groups
  4. Capacity units (WCU)
  5. Actions and evaluation priority
  6. The journey of a request
  7. Match statements
  8. Text transformations
  9. AWS managed rule groups
  10. The deployment method: Count first, Block later
  11. Rate-based rules
  12. MercadoFresco's web ACL in full
  13. Associating the web ACL with CloudFront and the ALB
  14. WAF logs and their analysis
  15. Metrics and samples of blocked requests
  16. False positives: diagnosis and exclusions
  17. Cost and the calculation for MercadoFresco
  18. Cleanup
  19. MercadoFresco's security posture
  20. What is missing: nobody is watching

What WAF sees that a security group cannot

Security group NACL AWS WAF
OSI layer 3/4 3/4 7 (application)
Inspects IP, protocol, port IP, protocol, port Method, path, headers, cookies, body, query
State Stateful Stateless Stateful per request
Applies to ENI (instance, ALB, RDS) Subnet CloudFront, ALB, API Gateway, AppSync, Cognito
Detects SQL injection No No Yes
Blocks by country No No Yes
Rate-limits by IP No No Yes
Tells a browser from a bot No No Yes
Cost Free Free 5 USD/month + rules + requests

A concrete example that sums up the difference. This request:

GET /buscar?q=tomate'%20OR%20'1'='1 HTTP/1.1
Host: mercadofresco.example
User-Agent: sqlmap/1.7

For sg-mercadofresco-alb this is a TCP connection to port 443 from some IP address: it allows it, because that is exactly what it is supposed to allow. For WAF it is a request with an SQL injection pattern in the query string and an attack tool declared in the user agent: it blocks it.

The three tools are complementary and none of them replaces another:

Threat Tool
Access to the database's port 5432 from the internet Security group (03-02)
A 200 Gbps volumetric flood Shield (04-04)
SQL injection, XSS, malicious scripts WAF
An HTTP flood against /buscar WAF with a rate-based rule
Stolen credentials used correctly IAM (04-01); no firewall will help

Where a web ACL is associated

Service Scope Notes
CloudFront CLOUDFRONT The web ACL must be created in us-east-1
Application Load Balancer REGIONAL In the ALB's region
API Gateway (REST) REGIONAL Per stage
AppSync REGIONAL GraphQL
Cognito REGIONAL User pools
App Runner, Verified Access REGIONAL Less common

The first row hides the trap that wastes the most time: a web ACL for CloudFront is created in us-east-1 with --scope CLOUDFRONT, no matter where the rest of your infrastructure lives. It is the same rule we already saw with ACM certificates in 03-04 and with CloudFront metrics in 04-04. A regional web ACL created in eu-west-1 cannot be associated with a distribution.

For MercadoFresco we set up two web ACLs:

Web ACL Scope Region Protects
waf-mercadofresco-cdn CLOUDFRONT us-east-1 Distribution E2QWERTY123ABC
waf-mercadofresco-alb REGIONAL eu-west-1 alb-mercadofresco-tienda

Why two, if all the traffic goes through CloudFront? Defence in depth. The ALB one is the safety net for the case where somebody manages to bypass the edge, and it also protects internal or test traffic that arrives directly. It is the same reasoning that in 04-04 led us to lock the ALB down with the verified header as well as with the prefix list.

Components: web ACL, rules and rule groups

flowchart TD
    A["Web ACL<br/>waf-mercadofresco-cdn"] --> B["Rule 1 - priority 0<br/>IP set: permanent block"]
    A --> C["Rule 2 - priority 10<br/>Managed group:<br/>AmazonIpReputationList"]
    A --> D["Rule 3 - priority 20<br/>Managed group:<br/>CommonRuleSet"]
    A --> E["Rule 4 - priority 30<br/>Managed group:<br/>SQLiRuleSet"]
    A --> F["Rule 5 - priority 40<br/>Rate-based: /login"]
    A --> G["Rule 6 - priority 50<br/>Rate-based: /api/pedidos"]
    A --> H["Default action:<br/>ALLOW"]

The four concepts:

  • Web ACL (Web Access Control List): the container. It has an ordered list of rules and a default action that applies to anything matching none of them.
  • Rule: a match statement plus an action. It has a numeric priority.
  • Rule group: a reusable set of rules. They can be AWS managed, managed by third parties (from the Marketplace) or your own.
  • IP set and regex pattern set: reusable lists of addresses or regular expressions that rules reference by ARN.

The default action defines the model:

Default action Model When
Allow Blocklist: everything is allowed except what matches a rule Public sites such as a shop
Block Allowlist: everything is blocked except what is explicitly permitted Internal panels, private APIs

MercadoFresco uses Allow by default on the public shop. For admin.mercadofresco.example, the administration subdomain we created in 03-05, the right model would be Block by default with a rule that only allows the office IP 192.168.10.0/24 and its public egress range.

Capacity units (WCU)

Every rule consumes WCU (Web ACL Capacity Units), a measure of the compute cost of evaluating it. A web ACL has a limit of 1,500 WCU by default (raisable to 5,000 on request).

Element Approximate WCU
IP set match 1
Simple string match 1-5
Each text transformation +10 for each one
Regular expression 25-35
Geo match 1
Rate-based rule 2
AWSManagedRulesCommonRuleSet 700
AWSManagedRulesKnownBadInputsRuleSet 200
AWSManagedRulesSQLiRuleSet 200
AWSManagedRulesLinuxRuleSet 200
AWSManagedRulesAmazonIpReputationList 25
AWSManagedRulesAnonymousIpList 50
AWSManagedRulesBotControlRuleSet 50

The budget runs out sooner than it looks: CommonRuleSet alone eats almost half of it. It is worth planning ahead:

aws wafv2 describe-managed-rule-group \
  --vendor-name AWS --name AWSManagedRulesCommonRuleSet \
  --scope CLOUDFRONT --region us-east-1 \
  --query '{Capacity:Capacity, Rules:Rules[].Name}' \
  --profile mercadofresco-dev

And checking how much you have used so far:

aws wafv2 get-web-acl --name waf-mercadofresco-cdn --scope CLOUDFRONT \
  --id <id> --region us-east-1 --query 'WebACL.Capacity' --profile mercadofresco-dev

Actions and evaluation priority

Action What it does Cost to the customer
Allow Allows the request and stops the evaluation None
Block Rejects with a 403 (customisable) and stops the evaluation Request lost
Count Counts and carries on evaluating. It blocks nothing None
CAPTCHA Shows a visual challenge; if solved, it issues a token valid for a few minutes High friction
Challenge Silent JavaScript challenge; the browser solves it on its own Almost no friction

Two behaviours you need to be completely clear about:

  1. Allow and Block are terminal. As soon as a rule matches with either of those two actions, the evaluation stops and the following rules are never looked at. That is why priority matters.
  2. Count is never terminal. It records the match and carries on. It is the basis of the safe deployment method.

On CAPTCHA versus Challenge: Challenge is almost always the right option. It verifies that a real browser is running JavaScript without bothering the user, filters out practically all simple bots and does not hurt conversion. Save CAPTCHA for critical actions —/login after several failed attempts, or the final payment step if you detect fraud— because every CAPTCHA shown to a real customer is a percentage of lost sales.

Priority is evaluated from the lowest number to the highest. They do not have to be consecutive: using 0, 10, 20, 30 instead of 0, 1, 2, 3 lets you insert rules later without renumbering everything.

The journey of a request

flowchart TD
    A["HTTPS request from a customer"] --> B{"Priority 0<br/>IP on the block list?"}
    B -->|"Yes"| Z["BLOCK 403 - end"]
    B -->|"No"| C{"Priority 10<br/>IP with a bad reputation?"}
    C -->|"Yes"| Z
    C -->|"No"| D{"Priority 20<br/>CommonRuleSet: XSS, paths,<br/>size, bad agents?"}
    D -->|"Yes"| Z
    D -->|"No"| E{"Priority 30<br/>SQL injection?"}
    E -->|"Yes"| Z
    E -->|"No"| F{"Priority 40<br/>More than 100 requests to /login<br/>in 5 min from this IP?"}
    F -->|"Yes"| Y["CAPTCHA"]
    F -->|"No"| G{"Priority 50<br/>More than 2000 to /api/pedidos<br/>in 5 min?"}
    G -->|"Yes"| Z
    G -->|"No"| H["Default action: ALLOW"]
    H --> I["CloudFront serves<br/>from cache or origin"]

Notice the order, which is no accident:

  • The cheapest and safest first: checking an IP against a set costs 1 WCU and produces no false positives.
  • The managed rules in the middle: expensive in WCU but very effective.
  • The rate-based rules at the end: it only makes sense to count requests that have already got past all the previous filters.

Match statements

Statement What it compares Example use
ByteMatch A string in a request component The user agent contains sqlmap
RegexPatternSet A regular expression Paths matching ^/admin/.*
SizeConstraint The size of a component A body larger than 8 KB on /api/pedidos
GeoMatch Country of origin (by GeoIP) Blocking countries with no customers
IPSet An IP or range in a list Always allow the office
SqliMatch SQL injection patterns Query string and body
XssMatch Cross-site scripting patterns Forms
LabelMatch A label set by an earlier rule Combining managed rules with your own logic
RateBased Requests per time window Protecting /login
And / Or / Not Logical combination "Spain and path /admin"

The request components that can be inspected: UriPath, QueryString, SingleHeader, AllHeaders, Cookies, Method, Body (the first 8 KB on the ALB, up to 64 KB on CloudFront with configuration), JsonBody (with JSON parsing) and SingleQueryArgument.

Here is a custom rule that blocks access to /admin from outside Spain:

{
  "Name": "AdminSoloDesdeEspana",
  "Priority": 5,
  "Statement": {
    "AndStatement": {
      "Statements": [
        {
          "ByteMatchStatement": {
            "SearchString": "/admin",
            "FieldToMatch": { "UriPath": {} },
            "TextTransformations": [
              { "Priority": 0, "Type": "LOWERCASE" },
              { "Priority": 1, "Type": "URL_DECODE" }
            ],
            "PositionalConstraint": "STARTS_WITH"
          }
        },
        {
          "NotStatement": {
            "Statement": {
              "GeoMatchStatement": { "CountryCodes": ["ES"] }
            }
          }
        }
      ]
    }
  },
  "Action": { "Block": {} },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "AdminSoloDesdeEspana"
  }
}

AndStatement requires both conditions to hold: the path starts with /admin and the country is not Spain. PositionalConstraint accepts EXACTLY, STARTS_WITH, ENDS_WITH, CONTAINS and CONTAINS_WORD.

A warning about GeoMatchStatement: IP geolocation is not infallible and a VPN sidesteps it without effort. It is good for reducing background noise, not as serious access control. And be careful with blocking countries: a Spanish customer on holiday in France would stop being able to buy.

Text transformations

They are essential and they are constantly forgotten. A rule looking for the string <script> does not match any of these variants, all of which do exactly the same thing:

Variant Technique
<SCRIPT> Upper case
%3Cscript%3E URL encoding
<scr\x00ipt> Inserted null byte
<script > Extra whitespace
&lt;script&gt; HTML entities
<scr<script>ipt> Nesting

Text transformations normalise the value before it is compared:

Transformation What it does
NONE Nothing
LOWERCASE Everything to lower case
URL_DECODE Decodes %3C to <
HTML_ENTITY_DECODE Decodes &lt; to <
COMPRESS_WHITE_SPACE Collapses multiple spaces into one
REMOVE_NULLS Removes null bytes
CMD_LINE Normalises command-line syntax
BASE64_DECODE Decodes base64
NORMALIZE_PATH Resolves ../ and // in paths

They are applied in Priority order and can be chained. The standard defensive combination:

"TextTransformations": [
  { "Priority": 0, "Type": "URL_DECODE" },
  { "Priority": 1, "Type": "HTML_ENTITY_DECODE" },
  { "Priority": 2, "Type": "LOWERCASE" },
  { "Priority": 3, "Type": "REMOVE_NULLS" },
  { "Priority": 4, "Type": "COMPRESS_WHITE_SPACE" }
]

A rule without text transformations is a rule you dodge by typing in upper case. Each transformation costs 10 WCU, and that is the price of the rule being worth something.

The AWS managed rule groups already include the appropriate transformations: that is one of the reasons for starting with them.

AWS managed rule groups

AWS maintains and updates these sets. Most of them are free: you only pay for the WCU they consume inside your web ACL, not a separate charge.

Group WCU Cost What it protects For MercadoFresco?
AWSManagedRulesCommonRuleSet 700 Free OWASP baseline: XSS, malicious paths, anomalous sizes, empty user agents Yes, essential
AWSManagedRulesKnownBadInputsRuleSet 200 Free Inputs from known vulnerabilities (Log4Shell, deserialisation) Yes, essential
AWSManagedRulesSQLiRuleSet 200 Free SQL injection in the query string, body and cookies Yes: there is PostgreSQL behind
AWSManagedRulesLinuxRuleSet 200 Free LFI, inclusion of /etc/passwd, command execution Yes: the instances are Linux
AWSManagedRulesUnixRuleSet 100 Free Shell commands Redundant with the previous one
AWSManagedRulesWindowsRuleSet 200 Free PowerShell, Windows commands No: there is no Windows
AWSManagedRulesPHPRuleSet 100 Free PHP injection Only if the shop uses PHP
AWSManagedRulesWordPressRuleSet 100 Free WordPress vulnerabilities No
AWSManagedRulesAmazonIpReputationList 25 Free IPs with known malicious activity and botnet nodes Yes, cheap and very effective
AWSManagedRulesAnonymousIpList 50 Free VPNs, Tor, proxies, cloud egress With care: some legitimate customers use a VPN
AWSManagedRulesBotControlRuleSet 50 10 USD/month + 1 USD/million Classifies bots: search engines, crawlers, tools Evaluate later on
AWSManagedRulesATPRuleSet 50 10 USD/month + 1 USD/1,000 attempts Account Takeover Prevention: credential stuffing, leaked credentials Interesting for /login
AWSManagedRulesACFPRuleSet 50 Paid Fraudulent account creation prevention Not for now

Two nuances about the paid ones:

  • Bot Control has two levels: the common one, which identifies bots that declare themselves (search engines, monitoring), and the targeted one, which detects bots posing as browsers using fingerprinting and challenges. The targeted level is markedly more expensive and only pays off when there is a real scraping or resale problem.
  • ATP checks the credentials of every login attempt against a database of leaked credentials, and detects stuffing patterns. For MercadoFresco, with customer accounts that store delivery addresses, it is the first extension to consider once there is budget.

Always start with the free ones. CommonRuleSet + KnownBadInputs + SQLi + IpReputationList come to 1,125 WCU, are free, and cover the vast majority of what you will see.

The deployment method: Count first, Block later

This section is the most important one in the lesson. Turning on CommonRuleSet in Block mode on a Friday afternoon and discovering that one of its rules blocks the order form is a self-inflicted disaster worse than any attack.

The correct procedure has four phases:

flowchart TD
    A["Phase 1: create the web ACL with ALL<br/>the rules in Count and default action Allow"] --> B["Phase 2: leave it 7-14 days,<br/>covering at least two Fridays"]
    B --> C["Phase 3: analyse the logs<br/>Which rules are counting?<br/>Against which real requests?"]
    C --> D{"Any false<br/>positives?"}
    D -->|"Yes"| E["Add exclusions for specific<br/>rules or narrow the scope"]
    E --> C
    D -->|"No"| F["Phase 4: move to Block<br/>one at a time, starting<br/>with the safest"]
    F --> G["Watch for 24-48 h<br/>after each change"]
    G --> H["Web ACL in production"]

Why each phase:

  • Phase 1. Count records the match and lets the request through. Zero risk: if you get it wrong, nothing happens.
  • Phase 2. Two weeks include both Friday peaks and, with luck, some campaign. A deployment based on three days of Tuesday traffic does not see the rare cases, and the rare cases are precisely the false positives.
  • Phase 3. The logs say exactly which rule would have blocked which real request, with its URI, its headers and its IP. That is the information you cannot guess.
  • Phase 4. One at a time. If something breaks, you know exactly which one it was.

Recommended order for moving to Block, from lowest to highest risk of a false positive:

Order Rule Risk
1 Your own blocking IP set None: you put it there
2 AmazonIpReputationList Very low
3 SQLiRuleSet Low, if you have no forms with legitimate SQL
4 KnownBadInputsRuleSet Low
5 Rate-based rules Medium: the threshold needs calibrating
6 LinuxRuleSet Medium
7 CommonRuleSet The highest: it is the broadest
8 AnonymousIpList High: it blocks customers on a VPN

A rule in Count inside a managed group is configured with RuleActionOverrides, which lets you move individual rules of the group to Count without disabling the whole thing:

{
  "Name": "ConjuntoComun",
  "Priority": 20,
  "Statement": {
    "ManagedRuleGroupStatement": {
      "VendorName": "AWS",
      "Name": "AWSManagedRulesCommonRuleSet",
      "RuleActionOverrides": [
        { "Name": "SizeRestrictions_BODY", "ActionToUse": { "Count": {} } },
        { "Name": "NoUserAgent_HEADER",     "ActionToUse": { "Count": {} } }
      ]
    }
  },
  "OverrideAction": { "None": {} },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "ConjuntoComun"
  }
}

Two fields that always get confused:

  • OverrideAction (rule groups only): {"None": {}} respects the group's actions; {"Count": {}} puts the whole group into count mode. Phase 1 uses Count.
  • RuleActionOverrides: changes the action of specific rules inside the group. It is the precision tool, the one used in phase 3 to neutralise a false positive without giving up the rest of the group.

Rate-based rules

They count the requests matching a condition within a time window and act when a threshold is exceeded. They are the direct answer to the attack in 04-04.

Parameter Values Comment
Limit 10 to 2,000,000,000 Requests per window
EvaluationWindowSec 60, 120, 300, 600 300 (5 minutes) by default
AggregateKeyType IP, FORWARDED_IP, CUSTOM_KEY, CONSTANT How the count is grouped
ScopeDownStatement Any statement Which requests the count applies to

AggregateKeyType deserves an explanation:

  • IP: by source address. The usual choice.
  • FORWARDED_IP: uses X-Forwarded-For. Essential if the WAF sits on the ALB behind CloudFront, because otherwise every request will look as if it came from CloudFront's IPs and they will all count as a single source.
  • CUSTOM_KEY: groups by session cookie, header, query parameter or a combination. It allows limiting "per user account" instead of "per IP", far more precise against distributed attackers.
  • CONSTANT: counts everything together, without grouping. Useful for putting a global ceiling on an expensive path.

ScopeDownStatement is the piece that makes it useful: without it, the rule would count all the site's requests, and a customer browsing the catalogue would generate hundreds of legitimate ones. With it, only the requests going to the protected path are counted.

Protecting /login with a CAPTCHA rather than a block:

{
  "Name": "LimiteLogin",
  "Priority": 40,
  "Statement": {
    "RateBasedStatement": {
      "Limit": 100,
      "EvaluationWindowSec": 300,
      "AggregateKeyType": "IP",
      "ScopeDownStatement": {
        "ByteMatchStatement": {
          "SearchString": "/login",
          "FieldToMatch": { "UriPath": {} },
          "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
          "PositionalConstraint": "STARTS_WITH"
        }
      }
    }
  },
  "Action": { "Captcha": {} },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "LimiteLogin"
  }
}

100 login attempts in 5 minutes from the same IP is an enormous amount for a person and very little for a credential-stuffing attack. Captcha is chosen rather than Block deliberately: an office behind a shared NAT can legitimately cross the threshold, and a CAPTCHA lets it through whereas a block would stop it buying.

Protecting /api/pedidos, where we do block:

{
  "Name": "LimiteApiPedidos",
  "Priority": 50,
  "Statement": {
    "RateBasedStatement": {
      "Limit": 2000,
      "EvaluationWindowSec": 300,
      "AggregateKeyType": "IP",
      "ScopeDownStatement": {
        "ByteMatchStatement": {
          "SearchString": "/api/pedidos",
          "FieldToMatch": { "UriPath": {} },
          "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
          "PositionalConstraint": "STARTS_WITH"
        }
      }
    }
  },
  "Action": { "Block": {} },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "LimiteApiPedidos"
  }
}

How a threshold is worked out, with MercadoFresco's figures: the Friday peak is 900 orders per hour, that is 15 a minute or 75 in the 5-minute window spread across all the customers. A single customer making 2,000 requests to /api/pedidos in 5 minutes is not a customer. The threshold has a margin of more than 25 times the whole site's traffic: it is deliberately conservative, because a threshold set too tight blocks the unusual customer before it blocks the attacker.

MercadoFresco's web ACL in full

This is the CloudFront web ACL file, in phase 1 of the deployment: everything in Count.

{
  "Name": "waf-mercadofresco-cdn",
  "Scope": "CLOUDFRONT",
  "DefaultAction": { "Allow": {} },
  "Description": "Protection for the MercadoFresco shop - observation phase",
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "wafMercadofrescoCdn"
  },
  "Rules": [
    {
      "Name": "BloqueoManual",
      "Priority": 0,
      "Statement": {
        "IPSetReferenceStatement": {
          "ARN": "arn:aws:wafv2:us-east-1:111122223333:global/ipset/ipset-mercadofresco-bloqueo/abc123"
        }
      },
      "Action": { "Block": {} },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "BloqueoManual"
      }
    },
    {
      "Name": "ReputacionIp",
      "Priority": 10,
      "Statement": {
        "ManagedRuleGroupStatement": {
          "VendorName": "AWS",
          "Name": "AWSManagedRulesAmazonIpReputationList"
        }
      },
      "OverrideAction": { "Count": {} },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "ReputacionIp"
      }
    },
    {
      "Name": "ConjuntoComun",
      "Priority": 20,
      "Statement": {
        "ManagedRuleGroupStatement": {
          "VendorName": "AWS",
          "Name": "AWSManagedRulesCommonRuleSet"
        }
      },
      "OverrideAction": { "Count": {} },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "ConjuntoComun"
      }
    },
    {
      "Name": "EntradasMaliciosas",
      "Priority": 25,
      "Statement": {
        "ManagedRuleGroupStatement": {
          "VendorName": "AWS",
          "Name": "AWSManagedRulesKnownBadInputsRuleSet"
        }
      },
      "OverrideAction": { "Count": {} },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "EntradasMaliciosas"
      }
    },
    {
      "Name": "InyeccionSql",
      "Priority": 30,
      "Statement": {
        "ManagedRuleGroupStatement": {
          "VendorName": "AWS",
          "Name": "AWSManagedRulesSQLiRuleSet"
        }
      },
      "OverrideAction": { "Count": {} },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "InyeccionSql"
      }
    },
    {
      "Name": "LimiteLogin",
      "Priority": 40,
      "Statement": {
        "RateBasedStatement": {
          "Limit": 100,
          "EvaluationWindowSec": 300,
          "AggregateKeyType": "IP",
          "ScopeDownStatement": {
            "ByteMatchStatement": {
              "SearchString": "/login",
              "FieldToMatch": { "UriPath": {} },
              "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
              "PositionalConstraint": "STARTS_WITH"
            }
          }
        }
      },
      "Action": { "Count": {} },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "LimiteLogin"
      }
    },
    {
      "Name": "LimiteApiPedidos",
      "Priority": 50,
      "Statement": {
        "RateBasedStatement": {
          "Limit": 2000,
          "EvaluationWindowSec": 300,
          "AggregateKeyType": "IP",
          "ScopeDownStatement": {
            "ByteMatchStatement": {
              "SearchString": "/api/pedidos",
              "FieldToMatch": { "UriPath": {} },
              "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
              "PositionalConstraint": "STARTS_WITH"
            }
          }
        }
      },
      "Action": { "Count": {} },
      "VisibilityConfig": {
        "SampledRequestsEnabled": true,
        "CloudWatchMetricsEnabled": true,
        "MetricName": "LimiteApiPedidos"
      }
    }
  ]
}

Some observations about this document:

  • The priority 0 rule is in Block, and it is the only one. It is the manual blocking IP set: Marta fills it in by hand during an incident, so there is no risk of a false positive.
  • All the others are in Count. Managed groups with "OverrideAction": {"Count": {}} and your own rules with "Action": {"Count": {}}. They are different fields for the same effect, and confusing them is a common mistake.
  • SampledRequestsEnabled: true on all of them. Without it you cannot see the request samples in the console, which is exactly what you need during the analysis phase.
  • Total capacity: 1 (IP set) + 25 + 700 + 200 + 200 + 2 + 2 = 1,130 WCU out of the 1,500 available. There is room for a few more rules, but not for LinuxRuleSet (200) and AnonymousIpList (50) at the same time without asking for an increase.

Creating it from the CLI:

# 1. The manual blocking IP set (empty to begin with)
aws wafv2 create-ip-set \
  --name ipset-mercadofresco-bloqueo \
  --scope CLOUDFRONT --region us-east-1 \
  --ip-address-version IPV4 --addresses \
  --description "Manual blocking during incidents" \
  --tags Key=Proyecto,Value=mercadofresco Key=Componente,Value=waf \
  --profile mercadofresco-dev

# 2. The web ACL
aws wafv2 create-web-acl \
  --cli-input-json file:///tmp/waf-mercadofresco-cdn.json \
  --region us-east-1 \
  --tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion \
         Key=Componente,Value=waf Key=Propietario,Value=marta \
         Key=CentroCoste,Value=tecnologia \
  --profile mercadofresco-dev

Adding an IP to the blocking set during an incident requires the LockToken, which acts as optimistic concurrency control:

TOKEN=$(aws wafv2 get-ip-set --name ipset-mercadofresco-bloqueo \
  --scope CLOUDFRONT --id <id> --region us-east-1 \
  --query 'LockToken' --output text --profile mercadofresco-dev)

aws wafv2 update-ip-set --name ipset-mercadofresco-bloqueo \
  --scope CLOUDFRONT --id <id> --region us-east-1 \
  --addresses 203.0.113.45/32 198.51.100.0/24 \
  --lock-token "$TOKEN" --profile mercadofresco-dev

Careful: update-ip-set replaces the whole list, it does not append to it. You have to read the current list, add the new address and send the complete set. It is a classic mistake that silently wipes out earlier blocks.

Associating the web ACL with CloudFront and the ALB

CloudFront: it is associated by updating the distribution's configuration with the web ACL's ARN. The deployment takes a few minutes to propagate to all the points of presence.

ALB: it is associated directly and the effect is immediate.

aws wafv2 associate-web-acl \
  --web-acl-arn arn:aws:wafv2:eu-west-1:111122223333:regional/webacl/waf-mercadofresco-alb/abc123 \
  --resource-arn arn:aws:elasticloadbalancing:eu-west-1:111122223333:loadbalancer/app/alb-mercadofresco-tienda/50dc6c495c0c9188 \
  --region eu-west-1 --profile mercadofresco-dev

# Check which resources a web ACL protects
aws wafv2 list-resources-for-web-acl \
  --web-acl-arn arn:aws:wafv2:eu-west-1:111122223333:regional/webacl/waf-mercadofresco-alb/abc123 \
  --region eu-west-1 --profile mercadofresco-dev

Remember the detail about rate-based rules in the ALB web ACL: since all the traffic arrives from CloudFront, you have to use AggregateKeyType: FORWARDED_IP with the X-Forwarded-For header, or the count will not tell customers apart.

WAF logs and their analysis

Without logs, WAF is a black box and phase 3 is impossible. There are three destinations:

Destination Latency Cost When
CloudWatch Logs Seconds Higher per GB Immediate analysis, alarms
S3 Minutes The cheapest Long retention, analysis with Athena
Kinesis Data Firehose Seconds Medium Sending to an external SIEM

MercadoFresco uses CloudWatch Logs during the two weeks of observation —because it needs to query straight away— and then keeps sending to mercadofresco-registros-web for the historical record.

# The log group name must begin with aws-waf-logs-
aws logs create-log-group --log-group-name aws-waf-logs-mercadofresco \
  --region us-east-1 --profile mercadofresco-dev

aws logs put-retention-policy --log-group-name aws-waf-logs-mercadofresco \
  --retention-in-days 30 --region us-east-1 --profile mercadofresco-dev

aws wafv2 put-logging-configuration \
  --logging-configuration '{
    "ResourceArn": "arn:aws:wafv2:us-east-1:111122223333:global/webacl/waf-mercadofresco-cdn/abc123",
    "LogDestinationConfigs": ["arn:aws:logs:us-east-1:111122223333:log-group:aws-waf-logs-mercadofresco"],
    "RedactedFields": [
      {"SingleHeader": {"Name": "authorization"}},
      {"SingleHeader": {"Name": "cookie"}},
      {"SingleQueryArgument": {"Name": "password"}}
    ]
  }' \
  --region us-east-1 --profile mercadofresco-dev

The group name must begin with aws-waf-logs- or the configuration is rejected without explaining why. And RedactedFields is not optional in an environment with customer data: without it, session cookies and authorisation headers would end up in plain text in the logs, creating exactly the problem we solved in 04-03. It is a GDPR compliance point.

Phase 3 queries with CloudWatch Logs Insights:

-- Which rules are counting, and how many times?
fields @timestamp, terminatingRuleId, action, httpRequest.uri
| filter action = "COUNT" or terminatingRuleId != "Default_Action"
| stats count(*) as matches by terminatingRuleId
| sort matches desc
-- Detail of the requests a particular rule would have blocked
fields @timestamp, httpRequest.clientIp, httpRequest.uri, httpRequest.country,
       httpRequest.headers.0.value
| filter @message like /SizeRestrictions_BODY/
| sort @timestamp desc
| limit 100
-- The 20 IPs with the most matches
fields httpRequest.clientIp
| filter action = "COUNT"
| stats count(*) as attempts by httpRequest.clientIp
| sort attempts desc
| limit 20

That first query is the one that decides the deployment: if SQLiRuleSet counts 4,000 times a day and every request is an obvious injection attempt, move it to Block without hesitating. If CommonRuleSet counts 300 times and 280 of those are legitimate requests from your own order form, you have a false positive to sort out first.

Metrics and samples of blocked requests

WAF publishes into the AWS/WAFV2 namespace:

Metric What it measures
AllowedRequests Allowed requests
BlockedRequests Blocked requests
CountedRequests Matches in Count mode
CaptchaRequests CAPTCHA challenges served
PassedRequests Requests that passed a challenge
aws cloudwatch get-metric-statistics \
  --namespace AWS/WAFV2 --metric-name BlockedRequests \
  --dimensions Name=WebACL,Value=waf-mercadofresco-cdn Name=Rule,Value=ALL Name=Region,Value=CloudFront \
  --start-time 2026-08-01T00:00:00Z --end-time 2026-08-02T00:00:00Z \
  --period 3600 --statistics Sum \
  --region us-east-1 --profile mercadofresco-dev

And an alarm that warns when something changes abruptly, towards the same alertas-mercadofresco topic from 04-04:

aws cloudwatch put-metric-alarm \
  --alarm-name mercadofresco-waf-bloqueos-anomalos \
  --alarm-description "Spike of blocks in the WAF: possible attack or new false positive" \
  --namespace AWS/WAFV2 --metric-name BlockedRequests \
  --dimensions Name=WebACL,Value=waf-mercadofresco-cdn Name=Rule,Value=ALL Name=Region,Value=CloudFront \
  --statistic Sum --period 300 --evaluation-periods 2 \
  --threshold 5000 --comparison-operator GreaterThanThreshold \
  --alarm-actions arn:aws:sns:eu-west-1:111122223333:alertas-mercadofresco \
  --region us-east-1 --profile mercadofresco-dev

Notice that a spike in blocks has two possible readings: you are being attacked, or you have just introduced a false positive. Both call for a look, and that is why the alarm is useful either way.

On top of that, the console offers request samples: up to 100 requests from the last 3 hours that matched each rule, with their full headers. It is the quickest tool for diagnosing a false positive, and it only works if SampledRequestsEnabled is set to true.

aws wafv2 get-sampled-requests \
  --web-acl-arn arn:aws:wafv2:us-east-1:111122223333:global/webacl/waf-mercadofresco-cdn/abc123 \
  --rule-metric-name ConjuntoComun --scope CLOUDFRONT \
  --time-window StartTime=2026-08-02T08:00:00Z,EndTime=2026-08-02T10:00:00Z \
  --max-items 100 --region us-east-1 --profile mercadofresco-dev

False positives: diagnosis and exclusions

A false positive is a legitimate request that a rule blocks. The four cases you will see:

Symptom Rule usually responsible Cause
Large product photos cannot be uploaded SizeRestrictions_BODY The body exceeds the default limit
A product with quotes or apostrophes in its name fails SQLi_QUERYARGUMENTS The apostrophe looks like injection
An internal integration stops working NoUserAgent_HEADER The client sends no user agent
Customers on a corporate VPN cannot get in AnonymousIpList Their egress is catalogued as anonymous

Diagnosis procedure:

  1. Identify the exact rule. The terminatingRuleId field in the logs, or the column in the console, gives the specific name inside the group.
  2. Look at the full request in the samples: URI, headers, size.
  3. Decide the minimum scope for the exception. Never disable the whole group.
  4. Apply and verify.

Three ways of resolving it, from narrowest to broadest:

a) Move only that rule to Count:

"RuleActionOverrides": [
  { "Name": "SizeRestrictions_BODY", "ActionToUse": { "Count": {} } }
]

b) Exclude that rule on one specific path only, combining it with ScopeDownStatement. This is the right option: the rule carries on protecting the rest of the site.

{
  "Name": "ConjuntoComunSalvoSubidas",
  "Priority": 20,
  "Statement": {
    "ManagedRuleGroupStatement": {
      "VendorName": "AWS",
      "Name": "AWSManagedRulesCommonRuleSet",
      "ScopeDownStatement": {
        "NotStatement": {
          "Statement": {
            "ByteMatchStatement": {
              "SearchString": "/admin/productos/subir",
              "FieldToMatch": { "UriPath": {} },
              "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
              "PositionalConstraint": "STARTS_WITH"
            }
          }
        }
      }
    }
  },
  "OverrideAction": { "None": {} },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "ConjuntoComunSalvoSubidas"
  }
}

c) Explicitly allow it earlier, with a rule of lower priority and the Allow action. This is the most dangerous of the three: Allow is terminal, so that request skips every rule that comes after it, including the SQL injection ones. Use it only for fully trusted traffic, such as the traffic from the office IP address.

And the golden rule: document every exception. A web ACL with fifteen exclusions that nobody remembers the reason for is a web ACL that protects nothing. Every RuleActionOverride should have a comment in the repository with the date, the reason and a review date.

Cost and the calculation for MercadoFresco

Item Price
Web ACL 5.00 USD a month
Each rule or rule group 1.00 USD a month
Requests 0.60 USD per million
Free managed groups 0 USD (they only count as a rule)
Bot Control 10 USD/month + 1 USD per million analysed
ATP 10 USD/month + 1 USD per 1,000 login attempts
Logs to CloudWatch Logs / S3 Cost of the destination service

The calculation for MercadoFresco. Key point: most of the traffic is served from the CloudFront cache, and those requests do go through WAF. We count 4 million requests a month on the distribution and 400,000 reaching the ALB:

Item Quantity Monthly cost
CloudFront web ACL 1 5.00 USD
Rules in the CloudFront web ACL 7 7.00 USD
Requests (CloudFront) 4,000,000 2.40 USD
ALB web ACL 1 5.00 USD
Rules in the ALB web ACL 5 5.00 USD
Requests (ALB) 400,000 0.24 USD
Logs in CloudWatch Logs ~3 GB ~1.50 USD
Total 26.14 USD/month

It is worth putting that figure in context:

Option Monthly cost What it covers
Shield Standard 0 USD L3/L4 volumetric
WAF with this configuration 26 USD SQL injection, XSS, bots, L7 flood
Shield Advanced 3,000 USD The above plus automation and credits

26 dollars a month cover the most likely threat to MercadoFresco. It is the module's best value-for-money security decision after KMS, and it confirms the assessment from 04-04: the money was far better invested here than in Shield Advanced.

If the budget were tight, it can be trimmed: using a single web ACL on CloudFront and doing without the ALB one saves 10.24 USD a month at the price of losing defence in depth. It is a defensible trade-off if the ALB is properly locked down with the verified header from 04-04.

Cleanup

The order matters: a web ACL associated with a resource cannot be deleted.

# 1. Disassociate it from each resource
aws wafv2 disassociate-web-acl \
  --resource-arn arn:aws:elasticloadbalancing:eu-west-1:111122223333:loadbalancer/app/alb-mercadofresco-tienda/50dc6c495c0c9188 \
  --region eu-west-1 --profile mercadofresco-dev

# In CloudFront: remove the WebACLId from the distribution configuration
#    and wait for the deployment to finish

# 2. Remove the logging configuration
aws wafv2 delete-logging-configuration \
  --resource-arn arn:aws:wafv2:us-east-1:111122223333:global/webacl/waf-mercadofresco-cdn/abc123 \
  --region us-east-1 --profile mercadofresco-dev

# 3. Delete the web ACL (it needs the LockToken)
TOKEN=$(aws wafv2 get-web-acl --name waf-mercadofresco-cdn --scope CLOUDFRONT \
  --id abc123 --region us-east-1 --query 'LockToken' --output text --profile mercadofresco-dev)

aws wafv2 delete-web-acl --name waf-mercadofresco-cdn --scope CLOUDFRONT \
  --id abc123 --lock-token "$TOKEN" --region us-east-1 --profile mercadofresco-dev

# 4. Delete the IP set and the log group
aws wafv2 delete-ip-set --name ipset-mercadofresco-bloqueo --scope CLOUDFRONT \
  --id <id> --lock-token <token> --region us-east-1 --profile mercadofresco-dev

aws logs delete-log-group --log-group-name aws-waf-logs-mercadofresco \
  --region us-east-1 --profile mercadofresco-dev

If you leave the web ACL created but not associated, it still costs 5 USD a month plus 1 USD per rule. It is one of the most common phantom charges on AWS bills.

MercadoFresco's security posture

This lesson closes the module. This is the complete security architecture:

flowchart TD
    A["Customer on the internet"] --> B["Route 53 mercadofresco.example<br/>Shield Standard"]
    B --> C["CloudFront E2QWERTY123ABC<br/>Shield Standard + cache + TLS from ACM"]
    C --> D["AWS WAF waf-mercadofresco-cdn<br/>Managed + rate-based rules"]
    D --> E["ALB alb-mercadofresco-tienda<br/>Verified header + CloudFront prefixes<br/>waf-mercadofresco-alb"]
    E --> F["ASG asg-mercadofresco-tienda<br/>Private subnets app-a/-b<br/>rol-mercadofresco-tienda: least privilege"]
    F --> G["Secrets Manager<br/>mercadofresco/produccion/rds/mfadmin<br/>rotation every 30 days"]
    F --> H["Parameter Store<br/>/mercadofresco/produccion/*"]
    G --> I["RDS mercadofresco-pedidos<br/>Multi-AZ, encrypted with KMS<br/>Subnets datos-a/-b with no egress"]
    F --> I
    C -.->|"OAC oac-mercadofresco-catalogo"| J["S3 mercadofresco-catalogo-fotos<br/>SSE-KMS + bucket key"]
    K["KMS alias/mercadofresco-datos<br/>Marta administers, the roles use<br/>annual rotation"] -.-> I
    K -.-> J
    K -.-> G

The four layers, and what each one contributes:

Layer Services What it guarantees
Identity IAM, roles, groups, MFA Nobody has more permissions than they need; nothing carries permanent keys
Data KMS, SSE-KMS, encrypted EBS and RDS An exposed disk, snapshot or bucket reveals nothing
Secrets Secrets Manager, Parameter Store No credential lives in a file; they rotate on their own every 30 days
Edge Shield Standard, WAF, OAC, SG Malicious traffic is discarded far away and the exposed surface is minimal

And the total cost of module 4:

Service Monthly cost
IAM, Identity Center, external Access Analyzer 0.00 USD
KMS (1 key + requests) 1.00 USD
Secrets Manager (2 secrets) + Parameter Store 0.82 USD
Shield Standard 0.00 USD
WAF (2 web ACLs, 12 rules, 4.4 M requests) 26.14 USD
CloudWatch alarms 0.40 USD
Total 28.36 USD/month

Less than 30 dollars a month for minimal identities, encrypted data, rotated secrets and a protected edge. Compared with the 3,000 of Shield Advanced —or with the cost of a notifiable breach under the GDPR— it is the best investment decision in the whole course.

Common Mistakes and Tips

Creating the CloudFront web ACL in the wrong region. It must be us-east-1 with --scope CLOUDFRONT. A regional web ACL cannot be associated with a distribution, and the error does not say so in any obvious way.

Deploying straight into Block. The most expensive mistake in this discipline. Two weeks in Count, log analysis, exclusions and only then Block, one rule at a time.

Forgetting the text transformations. A rule looking for <script> without LOWERCASE or URL_DECODE is dodged with %3CSCRIPT%3E. They cost 10 WCU each and they are what makes the rule worth something.

Confusing OverrideAction with Action. Rule groups use OverrideAction; your own rules use Action. Putting in the wrong field makes the API reject it or, worse, means the Count mode you thought you had set is not set at all.

Using AggregateKeyType: IP in the ALB's WAF behind CloudFront. Every request will look as if it came from CloudFront's IPs and the rate-based rule will count all the site's traffic as a single source. There you have to use FORWARDED_IP.

Running out of WCU without noticing. CommonRuleSet uses 700 of the 1,500. Plan the budget before adding groups, and check the capacity with describe-managed-rule-group.

Replacing the IP set instead of extending it. update-ip-set substitutes the whole list. Read first, add, and send the complete set.

Putting a broad Allow rule right at the top. Allow is terminal: that request skips every SQL injection and rate-based rule that comes afterwards. Reserve an explicit Allow for fully trusted traffic.

Not configuring RedactedFields in the logs. Session cookies and authorisation headers would end up in plain text, undoing the work of 04-03 and creating a GDPR problem.

Leaving a web ACL created and unassociated. It still costs 5 USD a month plus 1 USD per rule.

Tip: version the web ACL in Git. The full JSON in the repository, reviewed by somebody else before it is applied, with a comment for every exclusion explaining the date, the reason and the planned review. In 09-01 and 09-02 it will become a CloudFormation template or a CDK construct.

Tip: review the exclusions every quarter. They pile up, and each one is a hole somebody opened for a reason that probably no longer exists.

Tip: prefer Challenge to CAPTCHA. It filters out simple bots with no friction for the customer. Save the CAPTCHA for the genuinely critical paths.

Tip: have the emergency rule ready in advance. A JSON ready to go with an aggressive rate-based rule covering the whole site, which Marta can apply in a minute during an incident, saves half an hour of writing under pressure.

Exercises

Exercise 1: designing the web ACL for admin.mercadofresco.example

The administration subdomain we created in 03-05 must only be reachable from the office (192.168.10.0/24 internally, with public egress 203.0.113.10/32) and from Marta's home working setup, on a dynamic Spanish IP. Requirements: nobody else may get through; access attempts from other countries must not even touch the application; every rejected attempt must be logged; and the solution cannot stop Marta working from home when her IP changes.

Design the web ACL: the default action model, the list of rules with their priorities and actions, and justify the trade-off between security and usability in the last condition.

Exercise 2: analysing the observation phase and deciding on the move to Block

After 14 days with waf-mercadofresco-cdn in Count, the logs give these results:

Rule Matches Request sample
AmazonIpReputationList 12,400 Scans of /wp-login.php, /.env, /admin.php
SQLiRuleSet 3,100 3,050 with ' OR '1'='1; 50 are searches for "L'Escala"
KnownBadInputsRuleSet 890 All with Log4Shell strings
CommonRuleSet / SizeRestrictions_BODY 420 415 are photo uploads from /admin/productos/subir
CommonRuleSet / NoUserAgent_HEADER 310 295 from Marta's internal monitor; 15 from scanners
CommonRuleSet / CrossSiteScripting_BODY 45 All malicious
LimiteLogin 8 6 from one IP with 400 attempts; 2 from the office at peak time
LimiteApiPedidos 0

For each rule, decide: move it to Block, keep it in Count, or apply a specific exclusion. For those needing an exclusion, write the JSON. Justify every decision and say in what order you would apply the changes.

Exercise 3: writing the response to an incident in the heat of the moment

On a Friday at 18:40, right at the peak, MercadoFresco takes a layer 7 attack: 30,000 requests per minute to /buscar?q=<random> from 5,000 IPs in 40 countries, with varied and realistic user agents. The cache hit rate has collapsed to 8 % and DatabaseConnections is at 185 out of 200. The web ACL is deployed and in Block for the managed rules.

Write the exact sequence of actions Marta takes during the first 15 minutes, including the JSON of the rules she would apply, the risk of each action to the legitimate customers who are buying right then, and what she would check after each step. Bear in mind that it is Friday at 18:40: the worst possible moment to block real traffic.

Solutions

Solution 1

Model: default action Block (allowlist). It is an administration panel, not a public site: the right thing to do is to deny everything and allow what is explicit.

Priority Rule Statement Action
0 OficinaPermitida IPSetReferenceStatementipset-mercadofresco-oficina (203.0.113.10/32) Allow
10 SoloEspana NotStatement(GeoMatchStatement ES) Block
20 MartaConDesafio ByteMatchStatement on /admin Challenge
30 ProteccionesBase AWSManagedRulesCommonRuleSet + KnownBadInputs Block
Default Block

How the journey works: traffic from the office matches at priority 0 and is allowed immediately, skipping everything else. Traffic from outside Spain is blocked at priority 10. What is left —Spanish traffic that is not from the office, that is to say, potentially Marta at home— gets a silent Challenge at 20, which a real browser solves on its own and a script does not. Everything else falls into the default Block.

The trade-off in the last condition. The maximally secure option would be to allow only the IPs in the IP set, but that forces Marta to call somebody every time her provider changes her IP, which always ends with somebody adding 0.0.0.0/0 "temporarily" on a Sunday. The proposed solution accepts a controlled risk: anybody with a Spanish IP who reaches /admin passes the Challenge if they use a browser. But that does not give them access: there is still MFA authentication behind it, and WAF is only the first barrier. What it achieves is removing 99.9 % of the automated noise without blocking the person who administers the system.

The better alternative, if you want to raise the bar without losing usability, is to put the panel behind a VPN or a Verified Access client with a fixed egress IP, and go back to the strict allowlist model. Logging: the web ACL's logging configuration captures every block, with RedactedFields on authorization and cookie.

Solution 2

Rule Decision Justification
AmazonIpReputationList Block 12,400 matches, zero false positives: pure scans against paths MercadoFresco does not even have
SQLiRuleSet Block with an exclusion 98 % are real attacks, but 50 are legitimate searches for place names with an apostrophe
KnownBadInputsRuleSet Block 890 Log4Shell attempts, no false positives
SizeRestrictions_BODY Path exclusion 415 of the 420 are legitimate product photo uploads
NoUserAgent_HEADER Exclusion or fix the monitor 295 of the 310 are the internal monitor
CrossSiteScripting_BODY Block 45 matches, all malicious
LimiteLogin Block... no: Captcha 6 of the 8 are a clear attack, but 2 are the office
LimiteApiPedidos Block Zero matches in 14 days: the threshold of 2,000 is generous and there is no risk

Exclusion for SizeRestrictions_BODY (option b, the right one: the rule stays active on the rest of the site):

{
  "Name": "ConjuntoComun",
  "Priority": 20,
  "Statement": {
    "ManagedRuleGroupStatement": {
      "VendorName": "AWS",
      "Name": "AWSManagedRulesCommonRuleSet",
      "ScopeDownStatement": {
        "NotStatement": {
          "Statement": {
            "ByteMatchStatement": {
              "SearchString": "/admin/productos/subir",
              "FieldToMatch": { "UriPath": {} },
              "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
              "PositionalConstraint": "STARTS_WITH"
            }
          }
        }
      },
      "RuleActionOverrides": [
        { "Name": "NoUserAgent_HEADER", "ActionToUse": { "Count": {} } }
      ]
    }
  },
  "OverrideAction": { "None": {} },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "ConjuntoComun"
  }
}

For SQLiRuleSet, the 50 searches for "L'Escala" do not justify disabling SQL injection protection on a shop with PostgreSQL behind it. There are two ways out, and the good one is not WAF:

  • The right one: fix the front end so it encodes the apostrophe properly before sending it, or use POST with JSON instead of the query string. The false positive disappears on its own.
  • Acceptable as a temporary patch: apply RuleActionOverrides only to SQLi_QUERYARGUMENTS and only on the /buscar path, leaving the rest of the group active. With a review date written down.

For NoUserAgent_HEADER, the underlying fix is not an exception in WAF but fixing the monitor so that it sends an identifying user agent (MercadoFresco-Monitor/1.0). In the meantime, the exclusion above keeps it in Count.

For LimiteLogin, Captcha instead of Block: the 2 matches from the office are a shared NAT at peak time, and a block would stop the whole team working. The CAPTCHA lets people through and stops the 400-attempt attack.

Order of application, following the risk table:

  1. AmazonIpReputationListBlock. Watch for 24 h.
  2. KnownBadInputsRuleSetBlock. Watch for 24 h.
  3. LimiteApiPedidosBlock and LimiteLoginCaptcha. Watch for 24 h.
  4. Fix the monitor and the search front end (application changes, not WAF ones).
  5. SQLiRuleSetBlock, with the /buscar exclusion if the front end is not fixed yet.
  6. CommonRuleSetBlock with the path exclusion for the uploads. The last one and the most closely watched: it is the broadest group.

And none of these changes is applied on a Friday.

Solution 3

The context that governs everything: Friday 18:40, a peak of 900 orders an hour, real customers buying. Every minute of indiscriminate blocking is lost orders. The priority is keeping the service up, not winning the fight.

Minutes 0-2: confirm and characterise. Nothing is touched yet.

curl -sS -o /dev/null -w "%{http_code} %{time_total}s\n" https://mercadofresco.example/salud

You look at the three metrics that decide things: PedidosPorHora from the MercadoFresco/Tienda namespace (if it is still at ~900, real customers are buying right now), CacheHitRate (8 %, confirmed) and DatabaseConnections (185/200, critical). With realistic user agents and 5,000 IPs spread around, the identifiable pattern is not the source but the path.

Minutes 2-4: relieve the database, which is what is going to fall over. It is the lowest-risk and highest-impact action, and it blocks nobody:

  • Raise the minimum TTL of the /buscar responses in CloudFront to 60 seconds.
  • Reduce the cache key for /buscar so it only includes a normalised q, ignoring the other random parameters. This turns part of the attack into cache hits.

Risk to legitimate customers: search results up to a minute out of date. Irrelevant. Check afterwards: CacheHitRate and DatabaseConnections over the next 3 minutes.

Minutes 4-7: a rate-based rule on /buscar, in Count first.

{
  "Name": "EmergenciaBuscar",
  "Priority": 45,
  "Statement": {
    "RateBasedStatement": {
      "Limit": 300,
      "EvaluationWindowSec": 60,
      "AggregateKeyType": "IP",
      "ScopeDownStatement": {
        "ByteMatchStatement": {
          "SearchString": "/buscar",
          "FieldToMatch": { "UriPath": {} },
          "TextTransformations": [{ "Priority": 0, "Type": "LOWERCASE" }],
          "PositionalConstraint": "STARTS_WITH"
        }
      }
    }
  },
  "Action": { "Count": {} },
  "VisibilityConfig": {
    "SampledRequestsEnabled": true,
    "CloudWatchMetricsEnabled": true,
    "MetricName": "EmergenciaBuscar"
  }
}

Risk: none, Count does not block. Check: the rule's CountedRequests and the samples. If the counted requests are the attack's and not normal customers, move on to the next step. Yes, you spend two minutes counting even in an emergency: it is exactly the moment when a mistake costs the most.

Minutes 7-9: move to Challenge, not to Block.

You change "Action": { "Count": {} } for "Action": { "Challenge": {} }. A real browser solves the JavaScript challenge invisibly and carries on shopping; a bot that does not run JavaScript does not get through.

Risk: minimal. It would only affect customers with JavaScript disabled, practically non-existent in a shop that already requires it. Check: PassedRequests against BlockedRequests, DatabaseConnections and, above all, PedidosPorHora: if it is still at 900, we are not damaging the business.

Minutes 9-12: if the attack persists, tighten only on the worst offenders.

If DatabaseConnections stays above 180, a second rate-based rule is added with a very high threshold (for example 1,000 a minute per IP on /buscar) in Block. That threshold is impossible for a person to reach, so the risk of a false positive is almost nil.

Check: that PedidosPorHora does not fall.

Minutes 12-15: capacity and communication.

  • Divert the search reads to the mercadofresco-pedidos-lectura replica if the application supports it through configuration, to take load off the primary instance.
  • Temporarily raise the ASG maximum.
  • Tell Luis and Sara the situation, and note the time of each action for the post-mortem.

What Marta does NOT do, which matters as much as what she does:

  • No blocking by country. 40 countries, and some will have Spanish customers travelling.
  • No blocking the 5,000 IPs. They come back with others, and it hits customers sharing a NAT.
  • No switching CommonRuleSet to aggressive mode or touching untested rules: a new false positive at 18:40 on a Friday is worse than the attack.
  • No turning off search entirely, which is a core feature of a grocery shop.
  • No disabling CloudFront "to see whether it is to blame": it is the only layer protecting her.
  • No permanent changes applied while hot. Emergency rules are marked as temporary and reviewed on Monday with data, not on Friday with adrenaline.

Conclusion

MercadoFresco finally has the layer it was missing. You know what AWS WAF sees that neither a security group nor Shield can see —the method, the path, the query string, the headers, the cookies and the body of every HTTP request— and why the three tools are complementary and none of them replaces another. You know the anatomy of a web ACL: rules with priorities, managed groups, IP sets, the WCU budget that CommonRuleSet eats almost half of, and the five actions —with Allow and Block terminal, Count never terminal, and Challenge as the option that filters out bots without charging the customer any friction.

You know that the CloudFront web ACL is created in us-east-1 with --scope CLOUDFRONT, the same trap as ACM and the CDN metrics. You have mastered the match statements and, above all, the text transformations: without LOWERCASE or URL_DECODE, a rule against <script> is dodged by typing %3CSCRIPT%3E, and that is why each transformation costs 10 well-spent WCU. You know the AWS managed groupsCommonRuleSet, KnownBadInputs, SQLi, Linux, AmazonIpReputationList, AnonymousIpList, and the paid BotControl and ATP— and that the first four are free and cover the vast majority of what you will see.

And above all you have mastered the deployment method, which is what separates a useful web ACL from a self-inflicted outage: everything in Count, two weeks covering two Fridays, analyse the logs, exclude with the minimum scope, and only then move to Block one rule at a time, starting with the one that produces the fewest false positives. You have set up the rate-based rules that protect /login with Captcha and /api/pedidos with Block, knowing how to work out the threshold from real traffic and to use FORWARDED_IP when the WAF sits on the ALB behind CloudFront. You have the logs in aws-waf-logs-mercadofresco with RedactedFields on cookies and authorisation headers, the Insights queries that decide the move to Block, the AWS/WAFV2 metrics with their alarm towards alertas-mercadofresco, and the procedure for diagnosing and narrowing down a false positive without disabling a whole group. All for 26.14 USD a month, against 3,000.

This closes module 4. MercadoFresco's security posture rests on four layers: minimal identities, with roles that carry no permanent keys, human groups and mandatory MFA; encrypted data, with alias/mercadofresco-datos, separation of duties and annual rotation; guarded secrets, in mercadofresco/produccion/rds/mfadmin with automatic rotation every 30 days; and an edge protected by Shield Standard, two web ACLs and an exposed surface reduced to the minimum. All of it for 28.36 USD a month, and with a reasoned, documented decision not to buy Shield Advanced.

And yet there is something deeply incomplete about all this, and it is what gives the next module its name. We have built alarms that send notices to an SNS topic, but nobody has ever checked that the notice really reaches a phone at four in the morning. The logs from WAF, from the VPC, from the ALB and from the Lambda functions are piling up in five different places with nobody correlating them. When a customer writes in saying their order takes eight seconds to confirm, Marta will have no way of knowing whether the problem is in the shop, in the order status Lambda or in the database. Nobody knows who decrypted the last database backup, or when, even though KMS recorded it scrupulously. And there is no mechanism at all to warn us if tomorrow somebody disables the encryption on a bucket or opens a security group to the world.

Put another way: everything is built, and nobody is watching. In module 5, "Monitoring and management", starting with lesson 05-01 "Amazon CloudWatch", we will build that watching: metrics, dashboards, centralised logs and alarms that really work; then end-to-end tracing of a request with X-Ray, auditing every API call with CloudTrail —where we will finally see who used kms:Decrypt and who read which secret—, the continuous compliance monitoring of AWS Config, which warns as soon as a configuration drifts from what we have just built, and the automatic recommendations of Trusted Advisor.

© Copyright 2026. All rights reserved