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
- What WAF sees that a security group cannot
- Where a web ACL is associated
- Components: web ACL, rules and rule groups
- Capacity units (WCU)
- Actions and evaluation priority
- The journey of a request
- Match statements
- Text transformations
- AWS managed rule groups
- The deployment method:
Countfirst,Blocklater - Rate-based rules
- MercadoFresco's web ACL in full
- Associating the web ACL with CloudFront and the ALB
- WAF logs and their analysis
- Metrics and samples of blocked requests
- False positives: diagnosis and exclusions
- Cost and the calculation for MercadoFresco
- Cleanup
- MercadoFresco's security posture
- 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:
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-devAnd 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-devActions 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:
AllowandBlockare 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.Countis 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 |
<script> |
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 < 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.
Countrecords 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 usesCount.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: usesX-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: trueon 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) andAnonymousIpList(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-devAdding 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-devCareful: 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-devRemember 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-devThe 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 20That 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-devAnd 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-devNotice 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-devFalse 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:
- Identify the exact rule. The
terminatingRuleIdfield in the logs, or the column in the console, gives the specific name inside the group. - Look at the full request in the samples: URI, headers, size.
- Decide the minimum scope for the exception. Never disable the whole group.
- Apply and verify.
Three ways of resolving it, from narrowest to broadest:
a) Move only that rule to 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-devIf 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 |
IPSetReferenceStatement → ipset-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
RuleActionOverridesonly toSQLi_QUERYARGUMENTSand only on the/buscarpath, 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:
AmazonIpReputationList→Block. Watch for 24 h.KnownBadInputsRuleSet→Block. Watch for 24 h.LimiteApiPedidos→BlockandLimiteLogin→Captcha. Watch for 24 h.- Fix the monitor and the search front end (application changes, not WAF ones).
SQLiRuleSet→Block, with the/buscarexclusion if the front end is not fixed yet.CommonRuleSet→Blockwith 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.
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
/buscarresponses in CloudFront to 60 seconds. - Reduce the cache key for
/buscarso it only includes a normalisedq, 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-lecturareplica 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
CommonRuleSetto 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 groups —CommonRuleSet, 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.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
