Cloudflare Application Services - Lab Guide (Legacy)
Unified Lab Guide & Technical Reference. Courseware Version 4.0 (AcmeCorp Edition), Legacy / Cloudflare-Managed Lab. Prepared for AcmeCorp IT Security & Infrastructure. Scenario: Edge Security Modernization.
This edition targets the Cloudflare-managed lab (the labs.cloudflare.com "Implement: Application Services" pod). Use it only if you cannot provision the self-hosted lab environment that the primary guide targets. Because the managed lab shares one fixed origin that you do not control, a few chapters are Concept-only (Load Balancing, Advanced Caching, and Spectrum) and a few individual steps are presented as concepts rather than hands-on tasks. Everything else is fully hands-on.
You do not install anything or manage cloud servers. Your Cloudflare account and one pre-onboarded Enterprise zone are provided for you, and you run all terminal commands from the in-browser Ubuntu workstation described below.
Typographical Conventions
This guide uses the following conventions to distinguish between user input, system output, and explanatory notes.
| Convention | Meaning | Example |
|---|---|---|
| Bold | Names of selectable items in the web interface | Click Security to open the Security Rule window. |
Monospace | Text that you enter and coding examples | Enter the following command: dig example.com |
| Result text | Lab step results and explanations, expected system output | HTTP/2 200 OK |
| Italics | Contextual notes explaining "why" a task is necessary | This ensures the legacy application is not exposed directly to the internet. |
<in angle brackets> | A variable parameter. The value is defined in the guide or by your instructor. | Type ping <student-domain> and press Enter. |
How to Use This Lab Guide & The AcmeCorp Scenario
You are AcmeCorp's new Lead Application Security Engineer. Your mandate is to modernize the edge with Cloudflare Application Services, changing the origin as little as possible, and take AcmeCorp from exposed to defended. Over the next 18 labs you will prove how exposed the origin is, route and encrypt its traffic, make it fast and resilient, build a layered perimeter (WAF, bot management, rate limiting, API Shield, threat intelligence, and client-side protection), then seal the origin so the perimeter cannot be bypassed, and replay the opening attacks to prove nothing gets through.
Each lab solves one concrete part of that story. Reading the lab titles from top to bottom tells it end to end: exposed, onboarded, encrypted, fast, resilient, defended, sealed, and proven. Every lab opens with a "This chapter" box, highlighted in yellow, that states the part of the story you are about to solve.
In this managed lab, your Cloudflare account already contains one pre-onboarded Enterprise zone assigned to you (its name follows the pattern adjective-noun.sxplab.com, for example happy-balancer.sxplab.com). The zone is onboarded (Enterprise plan, Cloudflare nameservers assigned) but starts with no DNS records: you create them as the labs need them. In Lab 2 you onboard the three web hostnames the rest of the guide relies on: the apex (<student-domain>, the AcmeCorp website), ps.<student-domain> (the Page Shield demo page), and public-api.<student-domain> (the public orders API). You add a few more records in the labs that introduce them (ftp in Lab 2, payments.platform in Lab 3, and api in Lab 13). The apex and ps resolve to the shared web origin; public-api (and api) resolve to a separate orders API origin, as the table below shows.
| Component | Address | Role |
|---|---|---|
| Ubuntu workstation | in-browser (Guacamole) | The machine you drive the labs from. Open its Terminal to run curl, dig, and openssl, and to simulate attacks. It is reached through your browser; there is no SSH and nothing to install. |
| Shared web origin | 20.88.188.200 | Serves the AcmeCorp website. Both the apex (<student-domain>) and the ps Page Shield demo host point here. This is the raw origin IP you attack directly in Lab 1. It is shared by the class and you cannot reconfigure it. |
| Orders API origin | 4.157.169.241 | The backend for the public orders API. Both public-api.<student-domain> (used in the API Shield labs 10.4 and 13) and the api.<student-domain> record you create in Lab 13 point here. It is reachable only from inside the lab network. |
Your instructor assigns you a student domain (the name of your pre-onboarded zone). Type it below and every matching placeholder across this entire guide, including inside the copy blocks, is replaced with your real value. The value saves to your browser only; nothing is sent anywhere.
Lab 1 - The Exposed Origin
1.1 Confirm the Origin Is Exposed
From the workstation, request the origin directly by IP and read the response headers:
curl -sI http://20.88.188.200/ | head -n 6
Server header. An attacker learns what to target and can reach it directly, so any protection added later can simply be bypassed by talking to this IP.Now look at how the origin serves HTTPS:
echo | openssl s_client -connect 20.88.188.200:443 2>/dev/null | openssl x509 -noout -issuer -subject
HTTP/1.1 200 OK and a Server: header naming the origin's web server (for example nginx/1.29.1). The second shows the certificate's issuer and subject are the same self-signed value (both read O = Cloudflare Labs, OU = Technical Enablement, CN = 20.88.188.200), which is why a real browser would throw a certificate warning. A certificate whose Common Name is a bare IP, self-signed by the server it protects, is exactly what you replace with a real edge certificate in Lab 3. The origin is reachable, fingerprinted, and not properly encrypted.1.2 Leak Sensitive Files from the Origin
Developers accidentally left a source-control artifact on the web root. Request it directly:
curl -s http://20.88.188.200/.git/secrets.txt
.git directory. This is exactly the kind of exposure you will virtually patch with the managed WAF in Lab 7.200 and prints credentials, including USERNAME=acmeadmin and PASSWORD=#Super.Secret-5+. A file that should never be public is served straight off the origin.1.3 Scrape the Product Catalog
Simulate a competitor scraping AcmeCorp's catalog. Hammer a product category page and tally the responses:
for i in $(seq 1 50); do curl -s -o /dev/null -w "%{http_code}\n" http://20.88.188.200/services/luggages/; done | sort | uniq -c
200 (the output reads 50 200). Every scrape succeeded; nothing slowed the client down.1.4 Push a SQL Injection String Straight Through
The website has a contact form. Submit a classic SQL injection payload to its comment field and watch how the origin responds:
curl -s -o /dev/null -w "%{http_code}\n" "http://20.88.188.200/contact/?comment=admin%27%20OR%20%271%27%3D%271%27%20--%20"
admin' OR '1'='1' -- , the textbook tautology used to subvert a naive SQL query. With no Web Application Firewall inspecting Layer 7 traffic, nothing recognizes or blocks the pattern: the request sails straight to the origin and is answered normally. This is precisely the attack you virtually patch with the OWASP and Cloudflare managed rulesets in Lab 7.200. The malicious string was accepted and served without objection. Note this result: in Lab 7 the identical request through Cloudflare returns 403 and a block page.1.5 Probe the Locked Admin Panel
Finally, knock on the administrative panel and see how it responds:
curl -s -o /dev/null -w "%{http_code}\n" http://20.88.188.200/admin
401 Unauthorized. That is the origin's own control, not an edge control: the panel is still directly reachable on the public IP, and it is up to you to build a real perimeter around it (which you do with an IP list in Lab 8).401. The leaked credentials from 1.2 do not unlock it (it is a separate, locked page), and there is no login form to brute-force (/login returns 404). What matters for the baseline is that /admin is reachable directly on the raw IP at all.1.6 Record the "Before" Scorecard
Write down what you just observed. This is the baseline you replay in Lab 15, after the full perimeter is in place, to prove each gap is closed.
| Attack | Target | Result against the raw origin |
|---|---|---|
| Direct hit and fingerprint | http://20.88.188.200/ | 200, Server: nginx/1.29.1, self-signed cert |
| Leak the secrets file | /.git/secrets.txt | 200, prints acmeadmin / #Super.Secret-5+ |
| Scrape the catalog | /services/luggages/ (50x) | 50 200, unlimited |
| SQL injection probe | /contact/?comment=... | 200, accepted |
| Probe the admin panel | /admin | 401, reachable on the public IP |
Lab 1 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Origin is directly reachable and fingerprinted | curl -sI returned 200 and a Server header |
| Origin serves an invalid (self-signed) certificate | openssl showed issuer equal to subject |
| A sensitive file is publicly served | /.git/secrets.txt returned 200 with credentials |
| No rate control on the catalog | 50 of 50 scrape requests returned 200 |
| No Layer 7 filtering | the SQL injection probe returned 200 |
| Admin panel is locked at the origin but exposed on the IP | /admin returned 401 |
Lab 1 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
curl hangs or times out | You are testing from your own machine, not the lab workstation | Run every command in the in-browser Ubuntu workstation Terminal, which sits on the lab network |
openssl prints nothing | The pipe swallowed the certificate or port 443 was slow to answer | Re-run the command; the self-signed certificate is served on 20.88.188.200:443 |
The secrets file returns 404 | Typo in the path | The path is /.git/secrets.txt exactly; /secrets.txt and /.git/config both return 404 |
The admin probe returns 401, not 200 | That is the expected result | The panel is locked by Basic auth at the origin; the leaked credentials do not open it and there is no /login form (it returns 404). The point is only that /admin is reachable on the raw IP |
Lab 2 - DNS Onboarding & Proxy Control
2.1 Cloudflare as Authoritative DNS
From the workstation Terminal, confirm the nameservers are Cloudflare's:
dig +short NS <student-domain>
howard.ns.cloudflare.com.
mia.ns.cloudflare.com.The onboarded zone starts with no DNS records. In the dashboard, open DNS > Records and create an A record for the apex (name @, which represents <student-domain>) pointing to the shared origin 20.88.188.200, with Proxy Status set to Proxied (orange cloud).
Still in DNS > Records, create the other two web hostnames the later labs rely on, each as an A record with Proxy Status Proxied (orange cloud):
pspointing to the shared web origin20.88.188.200: the Page Shield demo host, used in Lab 14.public-apipointing to the orders API origin4.157.169.241: the public orders API, used in Labs 10.4 and 13.
ps share the web origin, while public-api is served by a separate orders API origin: that is why they point to different IP addresses.2.2 Controlling Traffic with Proxy Status
In Cloudflare DNS > Records, create a new A record named ftp pointing to 20.88.188.200, and set its Proxy Status to DNS only (grey cloud).
From the workstation, query that record and compare it to a proxied record:
dig +short ftp.<student-domain>
dig +short <student-domain>
ftp record returns the raw origin IP 20.88.188.200, while the proxied apex returns Cloudflare edge IPs (for example addresses in 104.x / 172.67.x ranges).ftp record (or leave it grey) and make sure the apex stays Proxied so the web application remains protected.2.3 Experience: A Grey Cloud Re-Exposes the Origin
Proxy status is not a cosmetic toggle. To feel what a mistake costs, temporarily break one of your web records and watch the origin IP come back into the open. In DNS > Records, edit the ps record and switch its Proxy Status from Proxied to DNS only (grey cloud), then Save.
From the workstation, resolve the hostname (query a public resolver so no local cache hides the change):
dig +short @1.1.1.1 ps.<student-domain>
20.88.188.200. By grey-clouding the record you told the world exactly where the server lives, and every edge protection you build later could be bypassed by hitting that address directly. This is the single most common way teams accidentally undo their own perimeter.Diagnose and fix. A grey cloud means Cloudflare only answers DNS for the name and does not proxy its traffic, so the record must publish the true origin address. The fix is to put the record back behind the proxy: edit ps again and set Proxy Status to Proxied (orange cloud), then Save.
dig +short @1.1.1.1 ps.<student-domain>
104.18.x), and the origin IP is hidden. Lesson: orange cloud is what puts Cloudflare in the request path; any web hostname you leave grey is a hole straight to the origin. Confirm the apex, ps, and public-api are all Proxied before you move on.Lab 2 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Cloudflare is authoritative for the zone | dig NS returned Cloudflare nameservers |
Apex, ps, and public-api are proxied | dig +short returned Cloudflare edge IPs, not the origin |
| The origin IP is hidden behind the proxy | none of the proxied names resolve to 20.88.188.200 |
| A grey-cloud record exposes the origin | the ftp record (and the temporarily grey ps) resolved to 20.88.188.200 |
Lab 2 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
dig still returns the origin IP for a proxied name | Local resolver cached the old (or negative) answer | Query a public resolver: dig +short @1.1.1.1 <name>, or run sudo resolvectl flush-caches |
| A proxied name returns no answer at all | The record was just created and the resolver cached an earlier NXDOMAIN | Wait a few seconds and query @1.1.1.1; the authoritative record is live immediately |
| A grey-clouded web record still leaks the origin | Expected: a grey cloud publishes the true origin IP | Set the record back to Proxied (orange cloud) so Cloudflare hides the origin |
Lab 3 - SSL/TLS & Encryption Standards
3.1 Universal SSL, Flexible Mode & Edge Encryption
Go to SSL/TLS > Edge Certificates and confirm the Universal SSL certificate is Active.
<student-domain> and *.<student-domain>). It is what removes the browser certificate warning for visitors.For immediate mitigation, go to SSL/TLS > Overview, open Configure encryption mode, select Flexible under Encryption mode, then click Save. (The manual modes appear as radio options beneath "Automatic SSL/TLS (recommended)".)
On the same Edge Certificates page, turn Always Use HTTPS On.
http:// request to https:// at the edge, so visitors are never served over an unencrypted connection.Still on Edge Certificates, set Minimum TLS Version to TLS 1.2.
Verify the redirect and the negotiated protocol from the workstation:
curl -sI http://<student-domain>/ | head -n 4
curl -sI https://<student-domain>/ | head -n 1
301/308 redirect to the https:// URL (Always Use HTTPS), and the HTTPS request returns 200. The site now presents a valid, Cloudflare-issued certificate to browsers.Flexible restored the browser padlock, but it quietly broke something. From the workstation, compare the website against the orders API, both proxied through Cloudflare since Lab 2:
curl -s -o /dev/null -w "website: %{http_code}\n" https://<student-domain>/
curl -s -o /dev/null -w "orders API: %{http_code}\n" https://public-api.<student-domain>/v1/orders
200, but the orders API returns 522. Flexible connects to the origin over plain HTTP on port 80. The website origin answers on port 80, so it works, but the orders API origin listens only on HTTPS port 443, so Cloudflare's connection on port 80 times out and the edge returns 522. Flexible cannot reach a TLS-only origin. You fix this in 3.2 by encrypting the Cloudflare-to-origin hop.3.2 Full (Strict) + Origin Certificate
Go to SSL/TLS > Origin Server and click Create Certificate.
Leave the defaults selected: Generate private key and CSR with Cloudflare, RSA (2048) key type, the pre-filled hostnames (*.<student-domain> and <student-domain>), and 15 years validity. Click Create.
Copy the generated Origin Certificate and Private Key, save each to a file (for example cert.pem and key.pem), then click OK.
*.<student-domain>, <student-domain>) with an expiry roughly 15 years out.Now encrypt the Cloudflare-to-origin hop. Go to SSL/TLS > Overview, open Configure encryption mode, select Full, and click Save.
443 and accepts the certificate the origin presents without validating it. Both origins already serve a self-signed certificate on port 443, so Full works immediately with no origin-side change, and it repairs the orders API that Flexible broke.Re-run the comparison from the workstation:
curl -s -o /dev/null -w "website: %{http_code}\n" https://<student-domain>/
curl -s -o /dev/null -w "orders API: %{http_code}\n" https://public-api.<student-domain>/v1/orders
200. The orders API went from 522 to 200 because Cloudflare now reaches it on port 443. From here on the website, the orders API, and every API Shield lab ride an encrypted Cloudflare-to-origin connection.3.3 Advanced Certificates for Subdomains
First, in Cloudflare DNS > Records, create an A record named payments.platform pointing to 20.88.188.200 with Proxy Status set to Proxied (orange cloud).
payments.platform.<student-domain>) that Universal SSL's single-level wildcard (*.<student-domain>) does not cover, which is exactly why an Advanced Certificate is required.Go to SSL/TLS > Edge Certificates and click Order an advanced certificate. In Certificate Hostnames, type payments.platform and select the autocompleted full hostname payments.platform.<student-domain>. The form pre-fills the apex (<student-domain>) and wildcard (*.<student-domain>); remove both with their Remove buttons so the certificate covers only the payments hostname.
Set Certificate Authority to Google Trust Services, leave Certificate validation method on TXT Validation, keep the default Certificate Validity Period, then click Save.
payments.platform.<student-domain> appears in the Edge Certificates list and moves from Initializing to Pending Validation (TXT) to Active (usually a few minutes), and the plan line shows 1 of 100 advanced certificates used.Wait for the certificate to activate, then confirm from the workstation that it has taken precedence over Universal SSL for that specific subdomain by inspecting the presented certificate:
echo | openssl s_client -connect payments.platform.<student-domain>:443 -servername payments.platform.<student-domain> 2>/dev/null | openssl x509 -noout -issuer -ext subjectAltName
O = Google Trust Services) and the Subject Alternative Name lists DNS:payments.platform.<student-domain>. That proves the dedicated advanced certificate, not the Universal SSL wildcard, is being served for the deep subdomain.Lab 3 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Edge encryption enforced | plaintext http:// returned a 301/308 to https:// |
| Flexible mode broke the TLS-only API | the orders API returned 522 under Flexible |
| Full mode repaired it end to end | website and orders API both returned 200 under Full |
| Advanced certificate serves the deep subdomain | openssl showed the chosen CA issuer and a SAN for payments.platform.<student-domain> |
Lab 3 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Whole zone returns 526 | You selected Full (Strict) but the origin only has a self-signed certificate | Switch back to Full; Full (Strict) needs a trusted origin certificate, which this shell-less shared origin cannot install |
Orders API returns 522 | SSL mode is Flexible, which connects to the origin on port 80; the API is HTTPS-only | Set the encryption mode to Full |
| Advanced certificate stuck on Pending Validation | DCV still in progress | Wait a few minutes; on this full-setup zone Cloudflare completes TXT validation automatically |
openssl shows the wrong (wildcard) certificate for payments | The advanced certificate is not active yet | Re-run once it reads Active; Cloudflare then serves the most specific certificate |
Lab 4 - Caching Foundations
4.1 Establishing Caching Baselines
From the workstation, inspect the cache status of a product category page:
curl -sSI https://<student-domain>/services/luggages/ | grep -i cf-cache-status
cf-cache-status: DYNAMIC, and there is no age header. Every request is going all the way to the struggling origin.4.2 Control caching with a Cache Rule
Go to Caching > Cache Rules and click Create rule. Name it "Cache luggages category". Under When incoming requests match, set Field = URI Path, Operator = equals, Value = /services/luggages/.
Under Then, set Cache eligibility to Eligible for cache. Then set Edge TTL to Ignore cache-control header and use this TTL, and enter 30 seconds. Click Deploy.
From the workstation, request the path twice and watch the status change:
curl -sSI https://<student-domain>/services/luggages/ | grep -i cf-cache-status
curl -sSI https://<student-domain>/services/luggages/ | grep -i cf-cache-status
MISS (or DYNAMIC on the very first hit), and the second returns HIT (or REVALIDATED), with an age header present. The Cache Rule successfully offloaded the path from the origin.4.3 Leverage cache-control directives
Go to Caching > Configuration and set Browser Cache TTL to Respect Existing Headers. On a newly onboarded zone this dropdown often defaults to a fixed time such as 4 hours, so change it if needed.
Cache-Control header, rather than from dashboard rules. "Respect Existing Headers" tells Cloudflare to defer to the origin's instructions.Disable the Cache Rule you created in 4.2 (toggle it off on Caching > Cache Rules).
Cache-Control directives take effect so you can observe origin-driven caching.Purge the path, then reload it a couple of times and inspect the headers:
curl -sSI https://<student-domain>/services/luggages/ | grep -iE "cf-cache-status|age|cache-control"
Cache-Control header for the category page, so there is no cache-control line and cf-cache-status falls back to DYNAMIC (Cloudflare will not cache HTML on its own). That confirms the edge is now deferring to the origin rather than a dashboard rule.Cache-Control the origin sends. If the application added a directive such as Cache-Control: max-age=300, the edge would cache the page for that duration and you would see HIT with an age; because this origin sends none, the page stays DYNAMIC. Either way the cache behavior is driven by the application, exactly as the developers intended.4.4 Troubleshooting a Cached 404
Diagnose a reported problem: a page that was briefly missing is now returning a stale 404 even after the origin was fixed. The fix is a targeted purge.
404) when configured to, so a transient origin error can "stick" at the edge. A Custom Purge for the specific URL clears the stale response immediately, and you can control error caching with a Status Code TTL in a Cache Rule (for example cache 404 for only 10 seconds).Go to Caching > Configuration, use Custom Purge, choose Purge by URL, and enter the affected URL (for example https://<student-domain>/services/luggages/). Then reload and confirm the fresh response is served.
Now make the negative-caching behavior explicit and controlled with a Status Code TTL. By default Cloudflare does not cache 404 responses, so a burst of requests to a missing URL (a broken link, or an attacker fuzzing paths) reaches the origin every time. Create a Cache Rule (Caching > Cache Rules > Create rule) that matches a path known to 404, for example the custom filter expression starts_with(http.request.uri.path, "/missing/"):
- Set Cache eligibility to Eligible for cache.
- Under Edge TTL, select a mode: choose Use cache-control header if present, bypass cache if not. The Edge TTL card requires a mode before it will deploy; this option leaves normal edge caching to the origin's headers while the Status Code TTL below governs the
404. - Still under Edge TTL, in the Status code TTL subsection click Add status code setting.
- Set Scope to Single code, the Status code to
404, and the Duration to10seconds, then Deploy.
curl -sSI https://<student-domain>/missing/nope | grep -iE 'HTTP|cf-cache-status'
curl -sSI https://<student-domain>/missing/nope | grep -iE 'HTTP|cf-cache-status'
Both return 404, but the first shows cf-cache-status: MISS and the second cf-cache-status: HIT, proving the negative response is now served from cache. Wait more than 10 seconds and the next request shows cf-cache-status: EXPIRED: Cloudflare still had the 404 cached but the TTL lapsed, so it revalidates with the origin. The request right after that returns to HIT as the freshly revalidated 404 is served from the edge again.Lab 4 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Established the cache baseline | cf-cache-status: DYNAMIC on the category page |
| Offloaded a path with a cache rule | cf-cache-status went MISS then HIT with an age |
| Handed cache control back to the origin | with the rule off the page returned to DYNAMIC |
| Purged a stale object surgically | Custom Purge by URL served a fresh response |
| Controlled a cached 404 | the 404 went MISS -> HIT -> EXPIRED under a 10s status-code TTL |
Lab 4 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Page stays DYNAMIC after adding the cache rule | The rule did not match, or Edge TTL mode was left unset | Confirm the URI Path matches exactly and set an Edge TTL mode before Deploy |
| Browser Cache TTL will not "respect headers" | A fresh zone often defaults this dropdown to a fixed time (for example 4 hours) | Set Browser Cache TTL to Respect Existing Headers explicitly in Caching > Configuration |
Cached 404 shows EXPIRED, not MISS | Expected after the TTL lapses; Cloudflare revalidates a still-held 404 | Nothing to fix; the next request returns to HIT |
| Purge did not refresh the object | Purged a different URL than the one served | Purge the exact absolute URL, including trailing slash |
Lab 5 - Advanced Caching
5.1 Cache an entire HTML page (Cache Everything)
The /services/safes/ category page is plain HTML that changes rarely, yet every hit currently goes to the origin (cf-cache-status: DYNAMIC). During a promotion, thousands of shoppers loading that page can overwhelm AcmeCorp. You will cache the HTML itself at the edge.
In the dashboard, go to Caching > Cache Rules and click Create rule, then choose Cache Rules from the dropdown. Configure:
- Rule name:
Lab 5 - Cache Everything safes - When incoming requests match: set the field to URI Path, operator equals, value
/services/safes/. - Then / Cache eligibility: select Eligible for cache (this is what caches the HTML, not just static file types).
- Leave Place at on its default (Last) and click Deploy.
From the Workstation Terminal, prime and confirm the cache. The first request is a MISS (fetched from origin and stored), the second is a HIT (served from the edge):
curl -sSI https://<student-domain>/services/safes/ | grep -i cf-cache-status
curl -sSI https://<student-domain>/services/safes/ | grep -i cf-cache-status
cf-cache-status: MISS and the second cf-cache-status: HIT. The full HTML page is now served from Cloudflare's edge.5.2 Make it safe for logins, then fix the ordering trap
Caching HTML is dangerous the moment users can log in: a cached personalized page could be handed to the wrong visitor. The fix is a second rule that bypasses cache whenever a session cookie is present, so anonymous shoppers get the fast cached page while logged-in users always reach the origin.
Create a second cache rule (Caching > Cache Rules > Create rule > Cache Rules):
- Rule name:
Lab 5 - Bypass on session cookie - When incoming requests match: click Edit expression and enter
(http.cookie contains "session_id"). - Then / Cache eligibility: select Bypass cache.
- Here is the trap. A common instinct is "the bypass should be checked first, so put it on top." Set Place at to First (or, if you left it Last, use the rule's kebab menu > Move up so the Bypass rule sits above Cache Everything). Deploy.
Break it and watch. Send an anonymous request and a request that carries a session cookie:
curl -sSI https://<student-domain>/services/safes/ | grep -i cf-cache-status
curl -sSI -H "Cookie: session_id=abc123" https://<student-domain>/services/safes/ | grep -i cf-cache-status
cf-cache-status: HIT. The cookied request was supposed to bypass, but it is being served from cache anyway. On a real site this is exactly how one user's logged-in page leaks to another.Diagnose. Cache Rules are last matching rule wins: rules run top to bottom, and the last rule that sets a given behavior overrides earlier ones. Your cookied request matches both rules, but because Cache Everything sits below the Bypass rule, its "Eligible for cache" wins and cancels the bypass. Putting the bypass "first" is backwards.
Fix. In Caching > Cache Rules, use the Bypass rule's kebab menu > Move down (or edit it and set Place at > Last) so the order is Cache Everything, then Bypass. Deploy, then re-run the same two commands:
curl -sSI https://<student-domain>/services/safes/ | grep -i cf-cache-status
curl -sSI -H "Cookie: session_id=abc123" https://<student-domain>/services/safes/ | grep -i cf-cache-status
HIT, but the cookied request now reports cf-cache-status: DYNAMIC (bypassed to origin). Lesson: with Cache Rules, order is a security control, not a cosmetic one. The bypass must come after (below) the rule it is meant to override.5.3 Collapse marketing URLs with a custom cache key
Marketing appends tracking tags (?v=aaa, ?utm_source=email) to links. By default each unique query string is a separate cache object, so every tagged variant misses the cache and hits the origin, wrecking the Cache Hit Ratio. You will cache the /services/luggages/ page with a custom cache key that ignores the query string, collapsing every variant into one object.
Create a cache rule (Caching > Cache Rules > Create rule > Cache Rules):
- Rule name:
Lab 5 - Custom cache key luggages - When incoming requests match: URI Path equals
/services/luggages/. - Then / Cache eligibility: Eligible for cache.
- Expand Cache key and enable Ignore query string.
- Place this rule above the Bypass rule (so Bypass stays last). Deploy.
curl -sSI "https://<student-domain>/services/luggages/?v=aaa" | grep -i cf-cache-status
curl -sSI "https://<student-domain>/services/luggages/?v=bbb" | grep -i cf-cache-status
?v=aaa reports MISS (first fetch) and ?v=bbb reports HIT, even though the query strings differ. Both variants resolved to the same cached object, so tracking tags no longer bypass the cache.5.4 Shield the origin with Tiered Cache
Even with HTML cached, every one of Cloudflare's global data centers can independently miss and reach back to the single AcmeCorp origin on a cold cache. Tiered Cache arranges data centers into lower and upper tiers so lower-tier PoPs ask an upper-tier PoP before ever touching the origin.
Go to Caching > Tiered Cache, select Smart Tiered Cache (Recommended), and click Apply.
Lab 5 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Cached a full HTML page | /services/safes/ went MISS then HIT under Cache Everything |
| Reproduced the ordering leak | with Bypass placed first, a cookied request wrongly returned HIT |
| Fixed it with rule order | with Bypass placed last, the cookied request returned DYNAMIC while anonymous stayed HIT |
| Collapsed query-string variants | ?v=aaa and ?v=bbb shared one cached object (MISS then HIT) |
| Enabled Tiered Cache | Smart Tiered Cache applied in Caching > Tiered Cache |
Lab 5 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Cookied request still HIT after you "fixed" the order | Bypass rule is not actually last, or the change was not deployed | Confirm the Cache Rules list shows Cache Everything above Bypass and that you clicked Deploy |
Page never becomes HIT | Cache eligibility left as Bypass, or the URI Path did not match | Edit the rule, set Eligible for cache, and match the exact path including the trailing slash |
Different query strings still MISS separately | Ignore query string not enabled in the cache key | Edit the luggages rule, expand Cache key, enable Ignore query string, Deploy |
Cookied request returns DYNAMIC but so does anonymous | Cache Everything rule disabled or ordered after Bypass | Ensure Cache Everything is enabled and sits above Bypass |
Lab 6 - Traffic Management & Rules Engine
6.1 Geo-Redirects (Portugal Localization)
Create a Redirect Rule so visitors from Portugal are sent to the localized landing page. Go to Rules > Overview, and in the Redirect Rules section click Create rule.
- Rule name:
Lab 6.1 - Portugal localization - When incoming requests match: set Field = Country, Operator = equals, Value = Portugal (the expression reads
ip.src.country eq "PT"). - Then: under URL redirect choose Dynamic, set the Expression to
concat("https://", http.host, "/pt"), Status code302, and enable Preserve query string. Click Deploy.
/pt path.curl -s -o /dev/null -w "%{http_code}\n" https://<student-domain>/
This returns 200 (not a 3xx), confirming the rule is scoped to Portuguese source IPs only. A visitor geolocated to Portugal receives a 302 to the localized page.6.2 Campaign Redirects
Marketing is running a campaign whose short link (/promo) must bounce visitors to AcmeCorp's LinkedIn page. Create a second Redirect Rule that matches the campaign path and sends it to an external URL.
- Rule name:
Lab 6.2 - Campaign redirect - When incoming requests match: Field = URI Path, Operator = equals, Value =
/promo. - Then: URL redirect > Static, URL = your LinkedIn profile URL (for example
https://www.linkedin.com/company/cloudflare), Status code301. Click Deploy.
curl -sSI https://<student-domain>/promo | grep -iE 'HTTP|location'
You should see a 301 and a location: header pointing at the LinkedIn URL. When you are done, disable this rule so it does not interfere with later labs.6.3 Response Header Transform
First, observe a security win Cloudflare gives you for free. The origin advertises its exact software in the Server response header, which attackers use to fingerprint the backend. Inspect the header on your proxied site:
curl -sSI https://<student-domain>/ | grep -i "^server:"
Server header with its own value (cloudflare). The origin's real software and version never reach the visitor, with zero configuration.Now go to Rules > Overview, click Create rule and choose Response Header Transform Rule. Name it "Add X-Frame-Options". Apply it to All incoming requests, choose Set static, header name X-Frame-Options, value SAMEORIGIN, then Deploy.
X-Frame-Options: SAMEORIGIN tells browsers to refuse to render the site inside a frame on another origin, which defends against clickjacking. Injecting it at the edge means AcmeCorp gets the protection without changing origin code.curl -sSI https://<student-domain>/ | grep -iE "^server:|^x-frame-options:"
You should see server: cloudflare and x-frame-options: SAMEORIGIN.6.4 Managed Transforms (Security Headers)
You just added one header by hand. Cloudflare can also apply a curated set of best-practice header changes with a single click, using Managed Transforms. Go to Rules > Settings > Managed Transforms (or, from Rules > Overview, click Go to Managed Transforms). Under HTTP response headers, enable Remove "X-Powered-By" headers and Add security headers.
x-content-type-options: nosniff, x-xss-protection: 1; mode=block, x-frame-options: SAMEORIGIN, referrer-policy: same-origin, and expect-ct: max-age=86400, enforce. Remove "X-Powered-By" headers strips another backend fingerprint, the same way Cloudflare already masks Server. (Note: HSTS / Strict-Transport-Security is not part of this set; you enable HSTS separately under SSL/TLS.)x-powered-by is gone:
curl -sSI https://<student-domain>/ | grep -iE "x-content-type-options|x-frame-options|x-xss-protection|referrer-policy|x-powered-by"
You should see x-content-type-options: nosniff and x-frame-options: SAMEORIGIN, and no x-powered-by line.6.5 URL Rewrites
AcmeCorp discontinued the "arrows" product line and wants requests for it served the "luggages" content instead, invisibly, without a public redirect. Go to Rules > Overview, and in the URL Rewrite Rules section click Create rule. Name it "Retire arrows path". Choose Custom filter expression and match URI Path equals /services/arrows/. Under Then rewrite the path and/or query, in the Path group choose Rewrite to with type Static. The value field already shows a leading /, so type services/luggages/ without a leading slash (the final path becomes /services/luggages/). Typing /services/luggages/ here would produce a broken //services/luggages/. Leave the Query untouched, then Deploy.
200 (not a redirect):
curl -s -o /dev/null -w "%{http_code}\n" https://<student-domain>/services/arrows/
You should get 200, and fetching the page body shows the luggages content served under the arrows URL.Lab 6 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Geo redirect scoped to Portugal | a non-PT request stayed 200 (control), proving the rule is country-scoped |
| Campaign short link redirects | /promo returned 301 with a location: to the campaign URL |
| Added a security response header | x-frame-options: SAMEORIGIN appeared and server: read cloudflare |
| Enabled managed security headers | x-content-type-options: nosniff present, x-powered-by gone |
| Retired a product URL transparently | /services/arrows/ returned 200 with luggages content, no visible redirect |
Lab 6 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Rewrite returns 404 or a broken path | Leading slash typed into the Rewrite value produced //services/luggages/ | Type services/luggages/ without the leading slash (the field already shows the /) |
| Campaign redirect interferes with later labs | The /promo redirect rule is still enabled | Disable the campaign redirect rule after verifying it |
| New security header does not appear | Rule not deployed, or scoped to a path instead of All incoming requests | Confirm the Response Header Transform rule is enabled and applies to all requests |
| Geo redirect never fires | Expected from the workstation, which is not in Portugal | Nothing to fix; the country-scoped rule only redirects PT source IPs |
Lab 7 - WAF Managed Rulesets
/.git/secrets.txt file and the SQL injection on the contact form. In this chapter you deploy Cloudflare's managed rulesets the way a real rollout is done, in Log first and then Block, virtually patch both attacks at the edge, discover that your fix also hard-blocks a legitimate customer, repair that with a surgical exception, and finish by triaging the whole incident in Security Analytics.7.1 Prove the Lab 1 attacks still land through Cloudflare
The origin is proxied now, but proxying alone does not inspect traffic. From the workstation, replay two of the Lab 1 attacks against the proxied hostname (not the raw IP) and confirm they still succeed:
curl -s -o /dev/null -w "secrets.txt: %{http_code}\n" https://<student-domain>/.git/secrets.txt
curl -s -o /dev/null -w "contact SQLi: %{http_code}\n" "https://<student-domain>/contact/?comment=admin%27%20OR%20%271%27%3D%271%27%20--%20"
200. The leaked .git secrets file (the same acmeadmin / #Super.Secret-5+ credentials from Lab 1) is served, and the SQL injection tautology on the comment field sails straight to the origin. With no WAF rules deployed, Cloudflare forwards both. These are the two attacks you close in this lab.7.2 Deploy the Managed Ruleset the safe way: Log, observe, then Block
A managed WAF can block legitimate traffic the instant you enable it, so professionals roll it out in Log first, watch what it would do, and only then switch to Block. Go to Security > Security rules. In the Managed rules section, click Deploy managed ruleset and deploy the Cloudflare Managed Ruleset. On its configuration, set the ruleset action override to Log and deploy.
.git and other sensitive paths. Staging it in Log lets you see every request it would act on without breaking a single customer.Re-run the secrets-file attack, then look at what the WAF recorded:
curl -s -o /dev/null -w "secrets.txt: %{http_code}\n" https://<student-domain>/.git/secrets.txt
200 because Log does not block. Go to Security > Analytics > Events: a new event for /.git/secrets.txt appears under Managed rules with Action = Log, matched rule Version Control - Information Disclosure. You can now see the attack the WAF caught without having risked blocking anyone.Now enforce. Edit the Cloudflare Managed Ruleset and change the action override from Log to Block, then deploy. Wait 15 to 30 seconds for the change to reach the edge and re-run the attack:
curl -s -o /dev/null -w "secrets.txt: %{http_code}\n" https://<student-domain>/.git/secrets.txt
403 and the Cloudflare block page. The managed ruleset blocks the request for the exposed source-control path at the edge, even though the origin would still happily serve it. In Security > Analytics > Events the same path now shows Action = Block. You just virtually patched a file leak you cannot fix at the origin.7.3 Add OWASP Core as defense in depth (Log)
Back in Security > Security rules > Managed rules, click Deploy managed ruleset again and deploy the Cloudflare OWASP Core Ruleset. On its configuration page, leave Anomaly Score Threshold at Medium (40) and Paranoia Level at PL1, set the action to Log, and deploy.
7.4 The fix breaks a customer: reproduce, then repair with a surgical exception
The contact form at /contact/ accepts free text that can look like an injection payload, so a real customer message can trip the WAF. Reproduce the false positive from the workstation:
curl -s -o /dev/null -w "contact: %{http_code}\n" "https://<student-domain>/contact/?comment=admin%27%20OR%20%271%27%3D%271%27%20--%20"
403 and the Cloudflare block page. A legitimate contact-form submission is hard-blocked, which for AcmeCorp is lost customer contact and lost revenue. In Security > Analytics > Events a Block event appears for /contact/ under Managed rules; expand it to see the matched rule SQLi - Comment in the Cloudflare Managed Ruleset.Now the experience: break it, watch it fail, then fix it. The instinct is to add a skip exception. Go to Security > Security rules, click Create rule > Managed rules (the New managed rules exception page). Name it "Contact form exclusion". Under When incoming requests match set Field = URI Path, Operator = wildcard, Value = /contact/*. Keep Skip all remaining rules. Leave Place at on its default of Last and Deploy. Wait 15 to 30 seconds and re-test with a fresh marker:
curl -s -o /dev/null -w "contact: %{http_code}\n" "https://<student-domain>/contact/?comment=admin%27%20OR%20%271%27%3D%271%27%20--%20&n=1"
403. The exception did nothing. A skip exception only skips rules that run after it, and Place at = Last put your exception below the rulesets, so they run and block first. Placing the skip last is backwards.Fix. Edit the "Contact form exclusion" exception and change Place at from Last to First, then Deploy. Wait 15 to 30 seconds, then send the contact request a few times with unique markers and one control request to the still-protected secrets path:
for n in 1 2 3; do curl -s -o /dev/null -w "contact n=$n: %{http_code}\n" "https://<student-domain>/contact/?comment=admin%27%20OR%20%271%27%3D%271%27%20--%20&n=$n"; done
curl -s -o /dev/null -w "control secrets.txt: %{http_code}\n" https://<student-domain>/.git/secrets.txt
/contact/ requests now return 200 (the exception runs first and skips the managed rules), while the control /.git/secrets.txt stays 403. In Security > Analytics > Events the /contact/ requests show Action = Skip and the secrets path still shows Block. Lesson: with managed-rules exceptions, order is a security control, not a cosmetic setting. A skip must be placed before the rules it is meant to bypass, and it must be scoped tightly (here /contact/* only) so it never bleeds onto other paths, such as the API host you protect in Labs 12 and 13.7.5 Triage the incident in Security Analytics
You have now generated blocks, a skip, and log events. Use Security > Analytics the way an analyst does during an incident. The Events tab groups firewall events by Action and by Service (for example, Managed rules) and lists the top source IPs, paths, user agents, and countries. The Traffic tab shows sampled request logs with Cloudflare's threat classifications.
With Group view by: Action, read the summary cards, then drill in to answer three questions: which service and which rules fired most, which paths were targeted, and whether a single source is responsible. Expand one Block event to view its Ray ID, User Agent, ASN, and matched rule.
.git virtual patch and the blocked contact payload), Skip (your exception), and Log (the staged rulesets). Under Events by service > Managed rules the matched rules are listed by name, including Version Control - Information Disclosure, SQLi - Comment, and the Contact form exclusion skip. Because every test came from the workstation, a single entry under Source IP Addresses accounts for every event, Paths shows /.git/secrets.txt and /contact/, and User Agents shows curl. That "one IP, one user agent, two paths" fingerprint is exactly how you distinguish a single automated actor from a distributed attack.Lab 7 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Confirmed the Lab 1 attacks still landed | /.git/secrets.txt and the /contact/ SQLi both returned 200 through Cloudflare |
| Rolled out the Managed Ruleset Log then Block | /.git/secrets.txt stayed 200 and logged in Log, then returned 403 in Block |
| Staged OWASP Core as defense in depth | ruleset shows Log at threshold Medium (40), PL1 |
| Reproduced a false positive | a legitimate /contact/ submission returned 403 (SQLi - Comment) |
| Diagnosed and fixed the exception order | Placed Last it stayed 403; Placed First it showed Action = Skip and 200, while .git stayed 403 |
| Triaged the incident | Events grouped by Action and Service, single source IP and user agent across two paths |
Lab 7 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
/.git/secrets.txt returns 403 immediately, before you switch to Block | The Managed Ruleset deployed with its default Block action instead of Log | Edit the ruleset, set the action override to Log to observe first, then back to Block to enforce |
/.git/secrets.txt still returns 200 after switching to Block | Managed Ruleset action still Log, not deployed, or change not propagated | Confirm the ruleset action is Block; wait 15-30s and retry |
| Exception does not stop the block | Place at left on Last, so the rulesets run before it | Set the exception to Place at > First and Deploy |
| Repeated test requests do not appear as new events | Cloudflare de-duplicates identical requests | Add a unique marker (&n=1, &n=2) to each request |
| Contact form still blocked after the exception | Reordering not yet applied at the edge | Wait 15-30 seconds, then re-send with a fresh marker |
Lab 8 - WAF Custom Rules
8.1 The threat brief and campaign baseline
Security hands you a brief: a single actor is probing AcmeCorp. They scrape the catalog, push SQL injection at the contact form, knock on /admin, and abuse the partner API. Managed rules from Lab 7 catch known-bad signatures, but they do not enforce AcmeCorp policy: geography, least-privilege admin access, and an API contract. You author that policy in this lab.
First, replay the Lab 1 campaign through Cloudflare and record what your current posture does. Run all four and note each code:
curl -s -o /dev/null -w "scrape: %{http_code}\n" https://<student-domain>/services/luggages/
curl -s -o /dev/null -w "contact SQLi: %{http_code}\n" "https://<student-domain>/contact/?comment=admin%27%20OR%20%271%27%3D%271%27%20--%20"
curl -s -o /dev/null -w "admin probe: %{http_code}\n" https://<student-domain>/admin
curl -s -o /dev/null -w "secrets file: %{http_code}\n" https://<student-domain>/.git/secrets.txt
200 (nothing throttles it yet, that is Lab 11), the admin probe is 401 (origin Basic auth, still reachable), and the secrets file is 403 (your Lab 7 managed ruleset). The uncomfortable one: the contact SQLi is 200, not 403. Your own Lab 7 exception (the /contact/* skip placed first) reopened the injection path. Note it; you re-close it in 8.3. This is the before picture, and each objective below closes one gap.8.2 Geofence sanctioned countries, quietly watch a competitor
Goal: refuse traffic from a set of sanctioned countries (regulatory compliance), and separately log, not block, a competitor's region for intelligence.
Go to Security > Security rules. In the Custom rules card click Create rule, name it "Geo Block - Sanctioned", and click Edit expression (top-right of the "When incoming requests match" box). Type this expression, which matches when the source country is in a set of sanctioned ISO codes. Set the action to Block and Deploy:
ip.src.country in {"KP" "IR" "SY" "CU"}
in {..} keeps one rule readable as the sanctions list grows, instead of a chain of or clauses. The ip.src.country field is the two-letter ISO code of the visitor's IP.Your turn (build it yourself): you just built one set-based geo rule step by step. Now author a similar one on your own. Create a rule named "Competitor Monitoring" that matches AcmeCorp's two competitor regions, China and Russia, but sets the action to Log instead of Block, so the match is recorded for intelligence without mitigating. Use the same ip.src.country in {..} pattern you just learned: click Edit expression, type your expression, set the action to Log, and Deploy.
ip.src.country in {"CN" "RU"} with the action set to Log. If you reached for eq joined by or instead of a set, it still works, but the set form is what keeps the rule readable as the list grows.Prove the block from one location. Your workstation has a single source country, so you cannot originate traffic from a sanctioned country. Find your country, then confirm normal traffic is allowed:
curl -s https://<student-domain>/cdn-cgi/trace | grep '^loc='
curl -s -o /dev/null -w "control /: %{http_code}\n" https://<student-domain>/
loc= shows your two-letter country (call it <your-cc>), which is not in the sanctioned set, and / returns 200.To prove the Block action fires, edit "Geo Block - Sanctioned", temporarily add your own country to the set (for example ip.src.country in {"KP" "IR" "SY" "CU" "<your-cc>"}), Save, wait about a minute, and re-run the control:
curl -s -o /dev/null -w "control /: %{http_code}\n" https://<student-domain>/
403 and the Cloudflare block page. Remove your country from the set and Save; it returns 200 again. You have proven the geofence works and kept your own access. Edge propagation can take up to a minute, so wait and re-run if you still see the old result.Finally, review the results at Security > Analytics > Events.
8.3 Re-close the contact form your own exception reopened
In 8.1 you saw the uncomfortable result: the contact-form SQLi returned 200, not 403. Your Lab 7 exception (the /contact/* skip, placed first) told the managed ruleset to leave that path alone, and the injection walked straight through. You will not remove that exception, the contact form still needs it for legitimate submissions, so instead you add a custom rule in a different phase that closes the gap.
Go to Security > Security rules. In the Custom rules card click Create rule. Name it "Challenge Contact Form", click Edit expression, and type this expression, which matches the contact path exactly. Under Then take action choose Managed Challenge, and Deploy:
http.request.uri.path eq "/contact/"
cf_clearance cookie; an automated client cannot, so scripted injection and form-spam are filtered without blocking customers.200 in 8.1 now returns 403 with a challenge:
curl -s -o /dev/null -w "contact SQLi: %{http_code}\n" "https://<student-domain>/contact/?comment=admin%27%20OR%20%271%27%3D%271%27%20--%20"
curl -s -D - -o /dev/null "https://<student-domain>/contact/" | grep -i "cf-mitigated"
You get 403 and cf-mitigated: challenge. You closed the gap your own exception opened, from a different phase, without touching the Lab 7 rule.8.4 Lock down the admin panel, the wrong way then the right way
The attacker reached /admin in Lab 1. It is protected by origin Basic auth (that is the 401 you keep seeing), but AcmeCorp policy says only corporate egress IPs should even reach it. You will build that lockdown, and on the way you will fall into the single most common custom-rule mistake and climb back out.
First, find the workstation's public IP by running this on the workstation:
curl -s https://api.ipify.org; echo
Lists are defined at the account level. Go to Manage account > Configurations > Lists, click Create IP list, set the Identifier to corporate_ips (lowercase letters, numbers, and underscores only; it cannot be changed later), and Create. On the next screen add the workstation's public IP and click Add to list.
$corporate_ips, without rewriting the rule when membership changes.Build it the wrong way first, on purpose. The intuitive design is "Allow the corporate list into /admin". Return to Security > Security rules, create a custom rule named "Admin Allow Corp (broken)", click Edit expression, type the expression below, set the action to Allow, and Deploy:
(http.request.uri.path eq "/admin") and (ip.src in $corporate_ips)
Observe the trap. Simulate an outsider by removing your IP from corporate_ips (Manage account > Configurations > Lists > corporate_ips, delete your entry), wait about 15 seconds, then probe /admin from the workstation:
curl -s -o /dev/null -w "admin (off-list): %{http_code}\n" https://<student-domain>/admin
401, not 403. The outsider was NOT blocked. The custom-rule Allow action only skips the remaining custom rules; it never blocks anyone by itself. With just an Allow rule, every off-list request sails past to the origin. Your "lockdown" protected nothing.Fix it. Edit the rule (rename it "Allow Corp Admin Only" if you like), click Edit expression, and invert the logic to block /admin for anyone not on the list. Change the action to Block and Deploy:
(http.request.uri.path eq "/admin") and not (ip.src in $corporate_ips)
Confirm both sides. With your IP still off the list:
curl -s -o /dev/null -w "admin (off-list): %{http_code}\n" https://<student-domain>/admin
403 and the Cloudflare block page. Re-add your workstation IP to corporate_ips, wait about 15 seconds, and re-run: it returns 401 again (reached the origin's Basic-auth challenge). Off-list 403, on-list 401: the lockdown works and you kept your own access.8.5 A least-privilege perimeter on the partner API
The partner API accepts orders, but the Lab 1 recon showed the origin answering more HTTP methods than the contract needs. AcmeCorp policy: the public API surface should accept only the methods it actually uses, and a trusted partner should be exempt from the managed ruleset but never from the method contract. You will build two rules and then prove how they interact.
Rule 1, the method allowlist (build this one step by step). Go to Security > Security rules, create a custom rule named "API Method Allowlist", click Edit expression, and type the expression below. It matches partner-API requests whose method is not one of the two the contract allows. Set the action to Block and Deploy:
(http.host eq "public-api.<student-domain>") and not (http.request.method in {"GET" "POST"})
GET) and creates (POST) orders. Blocking everything else (DELETE, PUT, PATCH, TRACE) shrinks the attack surface to exactly the contract: a positive security model at the method layer.Prove it from the workstation:
D=<student-domain>
curl -s -o /dev/null -w "GET orders: %{http_code}\n" "https://public-api.$D/v1/orders"
curl -s -o /dev/null -w "DELETE orders: %{http_code}\n" -X DELETE "https://public-api.$D/v1/orders"
GET returns 200 (an allowed method, reaches the origin) and DELETE returns 403 (blocked by your method allowlist at the edge, it never reaches the origin).Rule 2, the trusted-partner bypass (your turn, build it yourself). You just built one scoped custom rule end to end. Now author a similar one on your own to a new objective. A trusted B2B partner's automated calls trip the managed ruleset and generate false positives, so you want to Skip the managed rules for them, but only when you are confident it really is the partner. Require two factors: the request must carry the header x-partner-api: true and originate from an IP on the corporate_ips list you built in 8.4. Create a rule named "Skip Managed for Partner API", scope it to the partner host, action Skip with All managed rules ticked, and place it above the method allowlist. Remember: header names are lowercased in expressions, and http.request.headers[...] returns an array, so wrap the header test in any(...[*] eq "true").
(http.host eq "public-api.<student-domain>") and any(http.request.headers["x-partner-api"][*] eq "true") and (ip.src in $corporate_ips)
with action Skip -> All managed rules. The two-factor test (header AND corporate IP) means a leaked header alone cannot buy a bypass from an untrusted network.Now prove how Rule 1 and Rule 2 interact. This is the teaching point of the lab. Send the partner's credentials three ways from the workstation (your workstation IP is on corporate_ips, so it satisfies the second factor):
D=<student-domain>
P="q=admin%27%20OR%20%271%27%3D%271%27%20--%20"
curl -s -o /dev/null -w "partner GET (SQLi): %{http_code}\n" -H "x-partner-api: true" "https://public-api.$D/v1/orders?$P"
curl -s -o /dev/null -w "partner DELETE: %{http_code}\n" -H "x-partner-api: true" -X DELETE "https://public-api.$D/v1/orders"
curl -s -o /dev/null -w "no-header GET (SQLi): %{http_code}\n" "https://public-api.$D/v1/orders?$P"
- partner GET with the SQLi payload ->
200: the Skip bypassed the managed ruleset for the trusted partner, andGETis an allowed method, so it reaches the origin. - partner DELETE ->
403: this is the lesson. Skip only bypassed the managed rules; it did NOT skip your custom method allowlist. The partner is exempt from signatures but still bound by the method contract. - no-header GET with the SQLi payload ->
403: no bypass, so the managed ruleset blocks the injection as in Lab 7.
8.6 Re-run the campaign and triage in Analytics
Your custom policy is live. Replay the Lab 1 and 8.1 campaign one more time to see how the posture changed, then read the story in Analytics.
D=<student-domain>
curl -s -o /dev/null -w "scrape: %{http_code}\n" "https://$D/services/luggages/"
curl -s -o /dev/null -w "contact SQLi: %{http_code}\n" "https://$D/contact/?comment=admin%27%20OR%20%271%27%3D%271%27%20--%20"
curl -s -o /dev/null -w "admin probe: %{http_code}\n" "https://$D/admin"
curl -s -o /dev/null -w "secrets file: %{http_code}\n" "https://$D/.git/secrets.txt"
curl -s -o /dev/null -w "API DELETE: %{http_code}\n" -X DELETE "https://public-api.$D/v1/orders"
- scrape ->
200(still open; volumetric abuse is Lab 11's job). - contact SQLi ->
403(challenge), where 8.1 showed200: closed by your 8.3 rule. - admin probe ->
401on-list, or403off-list: governed by your 8.4 lockdown. - secrets file ->
403: still your Lab 7 managed ruleset. - API DELETE ->
403: blocked by your 8.5 method allowlist.
Read the whole policy at once in Security > Analytics > Events. Set Group view by: Action to see Block, Managed Challenge, and Skip side by side, then switch Group view by to Rule to attribute each mitigation to the custom rule that produced it.
Lab 8 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Recorded the campaign baseline | the four 8.1 probes showed scrape 200, contact SQLi 200, admin 401, secrets 403 |
| Geofenced sanctioned countries, logged a competitor | a temporary self-country expression flipped / to 403, then back to 200; the competitor set logs without blocking |
| Re-closed the contact form your exception reopened | the 8.1 contact SQLi went from 200 to 403 with cf-mitigated: challenge |
Learned the Allow trap, then locked /admin correctly | the Allow rule left an off-list request at 401; "Block when not in list" made it 403, on-list stayed 401 |
| Built a least-privilege API method contract | GET returned 200, DELETE returned 403 at the edge |
| Proved Skip scope with a two-factor partner bypass | partner GET passed 200, but partner DELETE was still 403 under the method contract |
Lab 8 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Geo block returns 200 during your test | The expression matches other countries, not yours | Temporarily set ip.src.country eq "<your-cc>", test, then restore the original set |
Contact SQLi still returns 200 after 8.3 | The challenge rule path does not match, or the edge has not propagated | Confirm the expression is http.request.uri.path eq "/contact/" and wait about a minute |
Off-list /admin still returns 401 | You are still on the Allow-only design, which blocks no one | Switch the action to Block with not (ip.src in $corporate_ips) |
/admin returns 403 from your own workstation | Your public IP is not on corporate_ips | Add your api.ipify.org IP to the list, wait about 15 seconds, retry |
Partner GET still hits managed rules | Skip rule sits below the block, or a factor is missing | Place the Skip rule above the allowlist; confirm the header and that your IP is in corporate_ips |
Partner DELETE unexpectedly returns 200 | Skip is set to skip custom rules too, not just managed | In the Skip action tick only All managed rules, not remaining custom rules |
Lab 9 - Bot Management
9.1 Bot analytics and the baseline challenge rule
First confirm Bot Management is on. Go to Security > Settings and find the Bot traffic section: Bot management should read Always active on this Enterprise zone.
cf.bot_management.score. That field only carries a meaningful value when Bot Management is active.Review live bot traffic in Security > Analytics: click Add filter, choose Bot score, and inspect the low-score band plus the Source user agents and Source ASNs lists.
lt 30 catches the clearly-automated band while leaving the ambiguous middle (roughly 30 to 60) alone.Create the rule: Security > Security rules > Custom rules > Create rule. Name it "Bot Management - Baseline", click Edit expression, and paste the expression below. It fires on low-scoring, non-verified traffic to the bot-demo path only. Start the action on Log, then Deploy. (A ready-made Templates option, "Mitigate definite bot traffic", is also available under Security rules if you prefer a guided starting point.)
(cf.bot_management.score lt 30) and not cf.bot_management.verified_bot and http.request.uri.path contains "/hellobots"
not cf.bot_management.verified_bot excludes verified crawlers such as Googlebot, so SEO is never harmed. Scoping to /hellobots keeps the test focused on the lab's bot-demo path.Predict before you flip it. Once the Log events look right, edit the rule, change the action to Managed Challenge, and Deploy. Before running anything, predict: a scripted curl from a single egress IP scores low and is not a verified bot, so when it requests /hellobots, what status code and what mitigation header should it get? Note your answer, then test:
curl -s -o /dev/null -w "%{http_code}\n" "https://<student-domain>/hellobots"
curl -s -D - -o /dev/null "https://<student-domain>/hellobots" | grep -i "cf-mitigated"
403 and the headers include cf-mitigated: challenge. That header is the definitive proof the challenge fired (a real browser would solve it silently and get through). If you predicted 403 plus a challenge mitigation, your mental model of score plus action is right. You will also see the rule firing in Security > Analytics > Events.9.2 Tune out trusted automation (your turn)
The baseline rule now challenges everything low-scoring on /hellobots, and that includes traffic you trust. Two legitimate low-scoring clients must keep working: a nightly internal BI tool that identifies itself with the user agent B1-Bot/1.11, and a trusted catalog-sync job that hits /services/luggages/. Both currently score low and would be challenged.
Your turn (build it yourself): you saw the baseline expression built step by step. Now extend it on your own. Edit "Bot Management - Baseline", click Edit expression, and add two and not (...) clauses so the rule still catches low-scoring bots but carves out the two trusted clients: exclude the exact path /services/luggages/ and the exact user agent B1-Bot/1.11. Keep the score and verified-bot logic. Leave the action on Managed Challenge and Deploy.
and not (...) clauses carve trusted automation out of the challenge while every other low-score request is still caught. This is the normal Bot Management tuning loop: start broad, then add precise exclusions for known-good clients.(cf.bot_management.score lt 30) and not cf.bot_management.verified_bot and not (http.request.uri.path eq "/services/luggages/") and not (http.user_agent eq "B1-Bot/1.11")
Field names are lowercase, and each exclusion is its own parenthesized and not (...) clause.With the action on Managed Challenge, test all three cases from the workstation:
D=<student-domain>
curl -s -L -o /dev/null -w "hellobots: %{http_code}\n" "https://$D/hellobots"
curl -s -L -o /dev/null -w "luggages: %{http_code}\n" "https://$D/services/luggages/"
curl -s -L -o /dev/null -w "B1-Bot UA: %{http_code}\n" -A "B1-Bot/1.11" "https://$D/hellobots"
/hellobots request is still challenged (403), while /services/luggages/ and the B1-Bot/1.11 request pass through (200). The exclusions work without weakening the main defense. (The -L flag follows the origin's trailing-slash redirect on /hellobots so a passed-through request reports 200; a challenged request has no redirect to follow and stays 403.)9.3 The over-broad rule: predict the blast radius, then fix it
Look closely at the expression you just wrote. In carving out the two trusted clients, the path scope http.request.uri.path contains "/hellobots" is gone. The rule no longer targets the bot-demo path; it now evaluates every request on the zone.
Predict first. Your workstation curl scores as automated and is not a verified bot, and the homepage / is neither /services/luggages/ nor the B1-Bot agent. Predict what the homepage returns now, then check it:
curl -s -D - -o /dev/null "https://$D/" | grep -iE "HTTP/|cf-mitigated"
HTTP/2 403 with cf-mitigated: challenge. You never targeted the homepage, but your single-egress curl scores as automated and the un-scoped rule challenges it anyway. Every low-score client is now challenged across the whole zone: your own uptime monitors, partner integrations, and the curl tests in the labs that follow. This is the classic over-broad security rule. It looks perfect in the demo and quietly breaks legitimate automation everywhere else.Fix it. Edit the "Bot Management - Baseline" rule, change the action from Managed Challenge to Log, and Deploy. Re-run the homepage check:
curl -s -D - -o /dev/null "https://$D/" | grep -iE "HTTP/|cf-mitigated"
HTTP/2 200 and the cf-mitigated header is gone. In Log the rule still records every low-score request under Security > Analytics > Events for tuning, but it no longer challenges anyone, so the rest of the labs are testable from the workstation.Lab 9 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Confirmed Bot Management is active | Bot management: Always active in Security > Settings |
| Baseline challenge on the bot path | predicted, then confirmed /hellobots -> 403 with cf-mitigated: challenge |
| Authored your own exclusion pass | /services/luggages/ and the B1-Bot/1.11 agent passed with 200 while /hellobots stayed 403 |
| Predicted and saw the over-broad blast radius | the homepage was wrongly challenged (403), then returned to 200 after switching to Log |
Lab 9 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
cf.bot_management.score has no value | Bot Management not active on the zone | Confirm Always active under Security > Settings > Bot traffic |
| Your tuned rule still challenges luggages or B1-Bot | An exclusion clause is mistyped or uses the wrong operator | Match exactly not (http.request.uri.path eq "/services/luggages/") and not (http.user_agent eq "B1-Bot/1.11") |
A passed-through /hellobots shows 403, not 200 | Missing -L, so the trailing-slash redirect is not followed | Add -L to the curl so it follows the redirect |
| Every request across the zone is challenged | The rule lost its path scope and now matches all traffic | Re-scope it, or set the action to Log as the lab directs |
| Later labs' curl tests all fail | The bot rule was left on Managed Challenge zone-wide | Set the baseline rule to Log before continuing |
Lab 10 - Threat Intel with Managed Lists
10.1 Block open proxies and anonymizers
Go to Security > Security rules > Custom rules and click Create rule. Name it "Block Malicious IPs by Managed List". Build the match with the visual expression builder:
- Field = IP Source Address, Operator = is in list, Value = Open Proxies (a Cloudflare-managed list).
- Click Or to add a second condition: Field = IP Source Address, Operator = is in list, Value = Anonymizers.
The Expression Preview should read exactly. Set the action to Block, set Status = Active, and click Deploy:
(ip.src in $cf.open_proxies) or (ip.src in $cf.anonymizer)
Confirm the rule is live and that it does not touch your own traffic. From the workstation:
curl -s -o /dev/null -w "control /: %{http_code}\n" https://<student-domain>/
200. Because the list contents are real-world malicious IPs, you will not match from the lab workstation; the deployed rule is what enforces the block for any request that originates from an open proxy or anonymizer.10.2 Add a high-confidence block tier (your turn)
Two more managed lists describe networks that are outright malicious rather than merely anonymizing: Botnets / Command and Control Servers and Malware. Traffic from these should be blocked with even higher confidence than a proxy.
Your turn (build it yourself): you just built one managed-list Block rule with the visual builder. Now author a second one on your own, named "Block C2 and Malware Networks". Use the same builder steps: Field = IP Source Address, Operator = is in list, choose the Botnets / Command and Control Servers list, click Or, then add a second is in list condition for the Malware list. Set the action to Block, Status = Active, and Deploy.
ip.src in $cf.<list> clauses joined by or, one referencing the Botnets / Command and Control list and one referencing Malware, with action Block and status Active. The exact list tokens are filled in by the builder when you pick each Value, so do not type them by hand.10.3 Predict how this interacts with your partner bypass
In Lab 8 you built a partner rule with the Skip -> All managed rules action for trusted B2B traffic. Suppose one of that partner's calls arrives from an IP that happens to be on the Open Proxies list.
Predict: does the partner Skip let that request through, or does your 10.1 managed-list rule still block it? Decide before reading on.
DELETE proved: a bypass is only as wide as the components you tick. Threat-intel blocking of proxies and command-and-control applies even to otherwise-trusted partners, which is exactly what you want.Lab 10 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Blocked anonymizing networks by threat intel | the rule is Active with (ip.src in $cf.open_proxies) or (ip.src in $cf.anonymizer), control / stayed 200 |
| Authored a high-confidence block tier | your Botnets/C2 + Malware rule is Active with two is in list clauses |
| Reasoned about action tiers and bypass scope | blocked proxies, C2, and malware, left VPNs alone, and confirmed the partner Skip does not exempt threat-list blocks |
Lab 10 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| The managed list does not appear in Value | The list is not enabled for the zone's plan | Confirm the zone is Enterprise; managed lists require the entitlement |
| Nothing is blocked from the workstation | Expected: the workstation IP is not on a threat list | Nothing to fix; the rule enforces for real anonymizer/proxy source IPs |
| Expression preview differs from the lab | Wrong list chosen or missing the Or condition | Rebuild so it reads exactly (ip.src in $cf.open_proxies) or (ip.src in $cf.anonymizer) |
| Your second rule would block legitimate VPN users | You added the VPNs list to a Block rule | Remove VPNs from the Block rule; for visibility use a separate Log rule instead |
Lab 11 - Rate Limiting
11.1 Rate Limit By IP (Brute Force Protection)
Go to Security > Security rules, scroll to Advanced rate limiting rules, and click Create rule. Name it "Safes Page Rate Limit", click Edit expression, and match the target path:
http.request.uri.path contains "/services/safes/"
Under With the same characteristics leave IP selected. Under When rate exceeds set Requests = 5 and Period = 1 minute. Under Then take action choose Block, and set the duration to 1 minute. Click Deploy.
Predict, then burst. The threshold is 5 requests per minute per IP, so predict: in a rapid loop of 8 requests from one IP, roughly which request number should first return 429? Note your answer, then run the burst from the workstation:
D=<student-domain>
for i in $(seq 1 8); do curl -s -o /dev/null -w "req $i -> %{http_code}\n" "https://$D/services/safes/"; done
200, then once the per-IP threshold is crossed (around request 6) the remaining requests return 429 (Too Many Requests). The counter is eventually consistent, so the exact request where 429 first appears can vary by one; if you predicted "the sixth, give or take one", your model is right. Wait a minute for the 1-minute block to clear before the next test.11.2 Rate limit by origin response code (your turn)
Attackers fuzz search endpoints with junk queries to find hidden parameters. Those probes overwhelmingly generate 404 responses. A plain per-IP limit is crude here: you only want to throttle clients that keep generating errors, not real users who search successfully. Cloudflare can count the origin's response code instead of raw requests.
In the same Advanced rate limiting rules section, click Create rule and name it "Search Fuzzing Protection". Click Edit expression and match the search path:
http.request.uri.path eq "/services/search"
Your turn (configure it yourself): you built a per-IP limit in 11.1. This one is similar but adds one twist you now configure on your own. Tick Use custom counting expression, and in the Increment counter when box author the condition so the counter advances only when the origin responds with a not-found. Set the threshold to 10 requests per 1 minute, action Block with a 1 minute duration, and Deploy.
404. Normal successful lookups (200) never fill the counter, so real users are never limited. Only clients that keep generating "not found" errors trip the rule. This proves Cloudflare can rate-limit on the origin's response, not just the incoming request.http.response.code eq 404, with Requests = 10, Period = 1 minute, action Block, duration 1 minute. Sustained 404-generating traffic is almost always malicious, so a hard Block at the threshold is appropriate.Test from the workstation. The nonexistent path returns 404, so every request feeds the counter:
D=<student-domain>
for i in $(seq 1 25); do curl -s -o /dev/null -w "req $i -> %{http_code}\n" "https://$D/services/search?q=$i"; done
404 (passed to the origin and counted), then the remaining requests (11 onward) return 429. The switch from 404 to 429 proves the counter advanced only on the origin's 404 responses and the Block engaged at the threshold.Lab 11 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Capped a hot page per IP | predicted the flip point, then a burst on /services/safes/ went from 200 to 429 around request 6 |
| Authored a response-code rate limit | you configured http.response.code eq 404; the first 10 /services/search hits returned 404, then 429 |
| Understood eventual consistency | counters may overshoot by one, so the flip point varies slightly |
Lab 11 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Deploy fails with a duration error | Mitigation duration is shorter than the counting period | Set the duration to at least the period (1 minute for a 1-minute window) |
Burst never returns 429 | Bot rule still challenging curl, or counter not yet consistent | Set the Lab 9 bot rule to Log; re-run the loop to let the counter catch up |
| Response-code rule never trips | Custom counting expression not set to http.response.code eq 404 | Enable Use custom counting expression and set it in the Increment counter when box |
429 persists too long between tests | Mitigation duration longer than you want for re-testing | Use a 1-minute duration so the limit resets quickly |
Lab 12 - API Discovery & Endpoint Management
12.1 Session Identifiers, Discovery & Endpoint Management
Add the session identifier. Go to Security > Settings, open the API abuse category, and click into Session identifiers. This zone starts with no identifier configured, so click Add identifiers, set Type = Header and Name = session-id, and Save. The page then shows 1 / 10 identifiers added with the status "Identifier not seen in requests" until the traffic you generate below starts carrying the header.
Generate API traffic from the workstation. Send a distinct session-id per simulated user across the API's real endpoints (list orders, read one order, create an order):
H=public-api.<student-domain>
for u in $(seq 1 20); do
S="session-id: user-$u"
curl -s -o /dev/null -H "$S" "https://$H/v1/orders"
curl -s -o /dev/null -H "$S" "https://$H/v1/orders/0"
curl -s -o /dev/null -H "$S" -X POST -H "Content-Type: application/json" -d "{\"createdBy\":\"user$u\",\"items\":[\"sku-$u\"]}" "https://$H/v1/orders"
done
Discovered endpoints appear under Security > Web assets > Operations (Endpoint Management) via Add operations > Select from Discovery. Until Discovery populates, add operations manually: on Add operations use Add custom operations and enter Method, Hostname (public-api.<student-domain>), and Path (for example POST /v1/orders), then Save operations.
/v1/orders operations also show up under Add operations > Select from Discovery, and (optionally) accept a rate-limiting recommendation to govern them.Lab 12 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Configured a session identifier | Session identifiers shows 1 / 10 with the session-id header |
| Generated attributed API traffic | the loop sent distinct session-id values across all three endpoints |
| Brought operations under management | the /v1/orders operations appear in Endpoint Management |
Lab 12 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Identifier stays "not seen in requests" | Generated traffic did not carry the session-id header | Re-run the loop; each request must send -H "session-id: user-N" |
| Nothing under Select from Discovery | Discovery can take up to 24 hours to populate | Add the operations manually now via Add custom operations |
| API requests fail before Discovery | public-api not proxied, or zone not on Full | Confirm the Lab 2 record is proxied and SSL is at least Full |
Lab 13 - API Schema Validation
13.1 Schema Validation
First, onboard the orders API. In Cloudflare DNS > Records, create an A record named api (the name must be exactly api) pointing to 4.157.169.241, with Proxy Status set to Proxied (orange cloud).
api record puts it behind Cloudflare so API Shield can inspect and validate its traffic at api.<student-domain>.Obtain the OpenAPI schema for the orders API. Use the lab schema generator at schema-generator.labs.cfdata.org to produce an OpenAPI document whose servers URL points at https://api.<student-domain>.
servers block. If it points at a placeholder host, the uploaded operations will not match your live traffic and validation never fires.Go to Security > Web assets > Schema validation, click Upload schema, select the file, review that the endpoints map to api.<student-domain>, and click Add schema and endpoints. Then tick the POST /v1/orders row, click Change action, choose Block, and Set action.
Test from the workstation, one compliant request and two violations. The schema requires each entry in items to be a UUID, so a ready-to-use UUID is provided below (no need to find your own):
H=api.<student-domain>
CT="Content-Type: application/json"
UUID=5bd195af-f22a-4cf7-ae9b-116d47104fbc
curl -s -o /dev/null -w "compliant: %{http_code}\n" -X POST -H "$CT" -d "{\"createdBy\":\"alice\",\"items\":[\"$UUID\"]}" "https://$H/v1/orders"
curl -s -o /dev/null -w "bad item type: %{http_code}\n" -X POST -H "$CT" -d "{\"createdBy\":\"alice\",\"items\":[\"sku1\"]}" "https://$H/v1/orders"
curl -s -o /dev/null -w "missing items: %{http_code}\n" -X POST -H "$CT" -d "{\"createdBy\":\"alice\"}" "https://$H/v1/orders"
items) returns 200 (it reaches the origin and creates the order); both violations return 403. The 403 is Cloudflare's schema-validation block at the edge, distinct from the origin's own 400 for bad input, so seeing 403 (not 400) proves the request never reached the backend. The bad item type request is blocked because the schema types each items entry as a UUID (format: uuid) and Cloudflare enforces it; the missing items request is blocked because items is a required field.200 depends on the zone running Full (set in Lab 3.2). Full lets Cloudflare accept the orders origin's self-signed certificate and forward the valid request to it. Had you selected Full (Strict) on this shared origin, Cloudflare would reject the self-signed certificate and return 526 before the request reached the API, so the compliant call would show 526 instead of 200. In the student-provisioned (self-provisioned) edition the origin has an installed Cloudflare Origin CA certificate, so the zone runs Full (Strict) and the compliant request still returns 200.Finally, add a fallthrough block. In Schema validation, use the Fallthrough template / setting to Block requests to the API host that do not match any known operation.
Lab 13 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Onboarded and enforced the schema | a compliant POST /v1/orders returned 200 |
| Blocked contract violations at the edge | bad item type and missing items both returned 403 (not the origin's 400) |
| Closed the undefined-operation gap | a fallthrough block rejects requests to any operation not in the schema |
Lab 13 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Violations return 400, not 403 | Schema action not yet propagated to the edge | Wait about a minute after Set action and re-run |
Compliant request returns 526 | Zone on Full (Strict) against a self-signed origin cert | Confirm SSL is Full (Lab 3.2), not Full (Strict), on this shared origin |
| Uploaded operations never match traffic | Schema servers URL points at a placeholder host | Generate the schema with servers = https://api.<student-domain> |
| No validation fires at all | api record not proxied | Set the api A record to Proxied (orange cloud) |
Lab 14 - Page Shield: Client-Side Protection
14.1 Client-side Resource Monitoring
Enable monitoring. Go to Security > Settings, open the Client side abuse category, and turn on Continuous script monitoring.
Generate traffic by loading the demo page in the workstation browser: https://ps.<student-domain>/contact/received/. This page deliberately loads several third-party scripts and opens several outbound connections, including a malicious sample. Then review the inventory under Security > Web assets > Client-side resources, across its Scripts, Connections, and Cookies tabs.
useinsider.com, assets.api.useinsider.com, www.rtb123.com, jsdelivr.at) alongside the first-party /js/scripts.min.<hash>.js, and a malicious sample cryptomining.testcategory.com/1.js is surfaced and flagged. The Connections tab lists outbound endpoints the page contacts (for example a hookb.in beacon and a cf-malicious-test.domain.example.com endpoint).14.2 Content Security Rules (CSP)
Monitoring tells you what is running; a Content security rule enforces what is allowed to run. Go to Security > Security rules, find Content security rules, and click Create rule. Name it "Page Shield - Script Allowlist".
Scope the rule to your site host, and build the allowlist from the legitimate resources you saw in 14.1 (leaving the malicious ones off, so they become violations):
- Script sources:
self,useinsider.com,*.useinsider.com,www.rtb123.com, andjsdelivr.at. - Connection sources:
self.
Set Action = Log to start in report-only mode, then Deploy. Once you confirm nothing legitimate is caught, switch the action to Allow to enforce the allowlist.
cryptomining.testcategory.com script and the hookb.in / cf-malicious-test.domain.example.com connections fall outside and are reported as violations. Log emits the report-only CSP header so browsers report violations without blocking; Allow emits the enforcing header.Confirm the policy is live by checking the response header from the workstation:
curl -s -D - -o /dev/null https://ps.<student-domain>/contact/received/ | grep -i content-security
content-security-policy-report-only header listing your allowed script-src and connect-src sources; in Allow mode it is the enforcing content-security-policy header. A resource outside the allowlist is reported (or blocked) accordingly.Lab 14 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Enabled client-side monitoring | the Scripts and Connections tabs inventory the demo page's resources |
| Surfaced a malicious script | cryptomining.testcategory.com/1.js is listed and flagged |
| Enforced a CSP allowlist | a content-security-policy (or report-only) header appears with your allowed sources |
Lab 14 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Inventory is empty | The demo page has not been loaded enough, or data is still accumulating | Load ps.<student-domain>/contact/received/ a few times and revisit later |
| No CSP header in the response | Rule not deployed, or scoped to the wrong host | Confirm the content security rule is deployed and scoped to the ps host |
| Legitimate script reported as a violation | Its source is missing from the allowlist | Add the source to Script sources, keep the rule on Log until clean |
Final lab: seal the perimeter, then replay the opening attacks to prove it holds.
Lab 15 - Seal & Validate the Perimeter
15.1 Seal the Origin (Simulated)
Proxying through Cloudflare hides the origin IP, but it does not by itself stop someone who already knows the IP (as you did in Lab 1) from connecting directly and skipping every edge control. Closing that gap requires the origin to reject any request that did not come from Cloudflare. On a real origin there are two standard ways:
- Authenticated Origin Pulls (mTLS): Cloudflare presents a client certificate on every origin request, and the origin's web server is configured to require and verify it (for example nginx
ssl_client_certificate+ssl_verify_client on). Direct-to-IP requests, which lack the certificate, are refused at the TLS layer. You enable this in the zone's SSL/TLS settings and configure the web server to enforce it. - Network IP allowlist: restrict the origin's firewall or cloud security group so only Cloudflare's published IP ranges may reach ports
80and443. Everything else is dropped before it reaches the web server.
You cannot run either method against the shared lab origin, so simulate the seal from the workstation by dropping traffic to the origin IPs at your local firewall. This makes the origin unreachable from where you launch attacks, exactly as a real seal would:
sudo iptables -A OUTPUT -d 20.88.188.200 -j REJECT
sudo iptables -A OUTPUT -d 4.157.169.241 -j REJECT
200 in Lab 1 now fails with a connection error (curl exit code 7):
curl -s -o /dev/null -w "%{http_code}\n" --max-time 6 http://20.88.188.200/ || echo "blocked (curl exit $?)"
The output is 000 followed by blocked (curl exit 7): the connection was refused, so the origin is now unreachable from your workstation.15.2 Replay the Lab 1 Attacks
With the origin sealed (simulated), rerun the opening attacks. The direct-to-IP path is gone, and the Cloudflare path is now defended by everything you built in Labs 7 to 14. Confirm the Cloudflare path still works and the sensitive file is virtually patched:
curl -s -o /dev/null -w "website via CF: %{http_code}\n" https://<student-domain>/
curl -s -o /dev/null -w "/.git via CF: %{http_code}\n" https://<student-domain>/.git/secrets.txt
200 (blocking the origin IP does not affect the hostname, which resolves to Cloudflare's edge), while /.git/secrets.txt now returns 403: the Lab 7 managed ruleset virtually patches, through Cloudflare, the exact file that leaked its secrets in Lab 1.Compared to your Lab 1 baseline:
- Sensitive file, now virtually patched.
/.git/secrets.txtthrough Cloudflare returns403(Lab 7), where Lab 1 returned the secrets. - Volumetric abuse, now throttled. The Lab 11 rate limits cap repeated hits with
429, where Lab 1 let every request through. - Direct-to-IP, now refused.
http://20.88.188.200/fails from your workstation, where Lab 1 returned200.
Remove the simulated seal to restore the workstation to its normal state:
sudo iptables -D OUTPUT -d 20.88.188.200 -j REJECT
sudo iptables -D OUTPUT -d 4.157.169.241 -j REJECT
200 again, confirming the block was local and is now cleared:
curl -s -o /dev/null -w "%{http_code}\n" --max-time 6 http://20.88.188.200/15.3 Architecture Recap
You took AcmeCorp from wide open to a defended edge. Here is the perimeter you built, layer by layer, and the Lab 1 problem each layer answers.
| Layer | Chapters | What it defends |
|---|---|---|
| DNS onboarding & proxy | 2 | Hides the origin IP so attackers cannot target it directly. |
| SSL/TLS encryption | 3 | Ends plaintext and certificate warnings; Full (Strict) end-to-end encryption on a self-hosted origin. |
| Caching & performance | 4, 5 | Absorbs traffic spikes at the edge so the origin stays up. |
| Traffic & rules engine | 6 | Runs business and hardening logic at the edge, off the origin. |
| WAF (managed + custom) | 7, 8 | Virtually patches the exposed file and enforces access policy. |
| Bot management | 9 | Challenges scrapers while allowing trusted automation. |
| Threat intelligence | 10 | Preemptively blocks anonymizers and open proxies with Cloudflare-managed lists. |
| Rate limiting | 11 | Caps volumetric abuse and API fuzzing. |
| API Shield | 12, 13 | Discovers the API and enforces its OpenAPI schema at the edge. |
| Page Shield | 14 | Watches the browser for supply-chain and skimming attacks. |
| Sealed origin | 15 | Forces all traffic through Cloudflare so nothing above can be bypassed (simulated at the workstation in this lab). |
Lab 15 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Direct-to-IP curl still returns 200 | The iptables REJECT rule was not added, or targets the wrong IP | Re-run the iptables -A OUTPUT -d <origin-ip> -j REJECT commands exactly |
| The website through Cloudflare also fails | You blocked the hostname or a Cloudflare edge IP, not the origin IP | Only block the origin IPs; the hostname must keep resolving to Cloudflare |
| Workstation still blocked after the lab | The REJECT rules were not removed | Run the two iptables -D OUTPUT ... delete commands to restore normal traffic |
/.git/secrets.txt not 403 on replay | The Lab 7 managed ruleset is not deployed | Confirm the Cloudflare Managed Ruleset from Lab 7 is active |
End of Unified Lab Guide (Legacy Edition).