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
7.1 Deploy the WAF and understand OWASP
First, from the workstation, confirm the exposure is still live through Cloudflare (not just on the raw IP):
curl -s -o /dev/null -w "%{http_code}\n" https://<student-domain>/.git/secrets.txt
200. With no WAF rules deployed, Cloudflare forwards the request and the origin serves the secrets file.Go to Security > Security rules. In the Managed rules section, click Deploy managed ruleset and deploy the Cloudflare Managed Ruleset.
.git and other sensitive paths.Deploy the Cloudflare OWASP Core Ruleset as well. On its configuration page, leave Anomaly Score Threshold at Medium (40) and Paranoia Level at PL1, and deploy it in Log action to start.
Now retest the exposed file through Cloudflare:
curl -s -o /dev/null -w "%{http_code}\n" https://<student-domain>/.git/secrets.txt
403 and the Cloudflare block page. The managed ruleset recognized the request for the exposed source-control path and blocked it at the edge, even though the origin would still happily serve it. Go to Security > Analytics > Events and confirm a matching Block event under Managed rules.7.2 Dealing with False Positives
The contact form at /contact/ accepts free-text that can look like an injection payload, so legitimate submissions can trip the WAF's SQL-injection rules. First reproduce the false positive from the workstation:
curl -s -o /dev/null -w "%{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. In Security > Analytics > Events a Block event appears for path /contact/ under Managed rules; expand it to see the matched rule (SQLi - Comment) in the Cloudflare Managed Ruleset. The Managed Ruleset blocks this pattern on its own default action, before the OWASP ruleset (running in Log) ever scores it.Create a surgical 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. Change Place at from Last to First, then Deploy.
&n=1, &n=2 to avoid de-duplication), and send a control request to a different path. In Security > Analytics > Events the /contact/ requests now show Action = Skip while other paths still show managed-rule events. The exception surgically disabled managed rules on the contact path only.7.3 Reading Analytics & Identifying Threats
Go to Security > Analytics. The Events tab groups firewall events by Action and by Service (for example, Managed rules) and lists the top source IPs, paths, and countries. The Traffic tab shows sampled request logs with Cloudflare's threat classifications.
Use the filters to answer: which rules or services triggered most, and is a single source responsible? Drill into an event to view the Ray ID, User Agent, ASN, and matched rule.
.git virtual patch plus the blocked contact payload) and Skip (your exception rule). Under Events by service > Managed rules the matched rules are listed by name (for example Version Control - Information Disclosure, SQLi - Comment, and Contact form exclusion). Because all of your test traffic 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. This "one IP, one user agent" pattern is exactly how you distinguish a single automated actor from a distributed attack.Lab 7 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Virtually patched the Lab 1 file leak | /.git/secrets.txt flipped from 200 to 403 through Cloudflare |
| Deployed the OWASP Core Ruleset | ruleset shows in Log at threshold Medium (40), PL1 |
| Reproduced a false positive | a legitimate /contact/ submission returned 403 (SQLi - Comment) |
| Fixed it surgically | a managed-rules exception placed First shows Action = Skip on /contact/ only |
| Read Security Analytics | Events grouped by Action/Service, single source IP and user agent identified |
Lab 7 Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
/.git/secrets.txt still returns 200 | Managed Ruleset not deployed, or change not propagated | Confirm the Cloudflare Managed Ruleset is deployed; 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 Geo Blocking + Different Actions
Go to Security > Security rules. In the Custom rules card click Create rule (or use the Create rule menu at the top right and choose Custom rules). Name it "Geo Block".
Click Edit expression (top-right of the "When incoming requests match" box) to switch to the raw expression editor, then paste an expression that matches everything outside the United States and Canada. Under "Then take action" choose Block, leave the response as the default Cloudflare WAF block page (403), and click Deploy:
not (ip.src.country in {"US" "CA"})
ip.src.country field is a two-letter ISO country code derived from the visitor's IP.Create a second, separate custom rule named "Competitor Monitoring". Set the action to Log and use an expression that matches a single country you want to watch, such as China:
ip.src.country eq "CN"
First, from the workstation, confirm that normal traffic is allowed by your Geo Block rule:
curl -s -o /dev/null -w "%{http_code}\n" https://<student-domain>/
200. Because the workstation is not outside US/CA, the Geo Block expression does not match it, so the request passes.Now open Security > Security rules > Custom rules, edit Geo Block, temporarily change its expression to match your own country (for example ip.src.country eq "US"), click Save, wait about 15 seconds for the edge to update, then re-run the same curl:
403 and the Cloudflare block page. This proves the geo match and the Block action are working. Change the expression back to not (ip.src.country in {"US" "CA"}) and Save; the curl should return 200 again.Finally, review the results at Security > Analytics > Events.
8.2 Challenge the Contact Form
Go to Security > Security rules. In the Custom rules card click Create rule. Name it "Challenge Contact Form", click Edit expression, and match the contact path:
http.request.uri.path eq "/contact/"
Under Then take action choose Managed Challenge, and Deploy.
cf_clearance cookie to a client that passes, so subsequent requests in that session are not re-challenged.curl client cannot solve the challenge:
curl -s -o /dev/null -w "%{http_code}\n" https://<student-domain>/contact/
curl -s -D - -o /dev/null https://<student-domain>/contact/ | grep -i "cf-mitigated"
You should get 403 and cf-mitigated: challenge. A real browser would be offered the challenge, solve it, and receive a cf_clearance cookie.8.3 Lock Down the Admin Panel with an IP List
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.Return to Security > Security rules, create a custom rule named "Allow Corp Admin Only", click Edit expression, and block /admin for anyone not on the corporate list. Set the action to Block and Deploy:
(http.request.uri.path eq "/admin") and not (ip.src in $corporate_ips)
/admin; everyone else is blocked at the edge. "Block when NOT in the list" is the correct pattern, because the custom-rule Allow action only skips remaining custom rules; it does not block anyone by itself.From the workstation (whose IP is on the list), confirm you are allowed through:
curl -s -o /dev/null -w "%{http_code}\n" https://<student-domain>/admin
401. Your IP is in corporate_ips, so the rule does not match and the request reaches the origin's Basic-auth challenge. To prove the block, remove your IP from the list, wait ~15 seconds, and re-run the curl: it now returns 403. Re-add your IP to restore access.8.4 Skip Security for a Partner API
Go to Security > Security rules, create a custom rule named "Skip Partner API Security", and click Edit expression. Match requests to the partner API host that carry a trusted-partner header:
(http.host eq "public-api.<student-domain>") and any(http.request.headers["x-partner-api"][*] eq "true")
x-partner-api: true identifies that trusted traffic. HTTP header names are lowercased in expressions, and http.request.headers[...] returns an array, so it is wrapped in any(...[*] eq "true").Under Then take action choose Skip. In "WAF components to skip" tick All managed rules, leave Log matching requests enabled, and Deploy.
P="q=admin%27%20OR%20%271%27%3D%271%27%20--%20"
curl -s -o /dev/null -H "x-partner-api: true" "https://public-api.<student-domain>/v1/orders?$P"
curl -s -o /dev/null "https://public-api.<student-domain>/v1/orders?$P"
The request with the header appears as a Skip event and produces no Managed rules event; the request without the header still triggers the managed rules. This proves the bypass works only for trusted partner traffic.Lab 8 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Geo block with a Log companion rule | a temporary self-country expression flipped your request to 403, then back to 200 |
| Challenged the contact form | /contact/ returned 403 with cf-mitigated: challenge |
Locked /admin to an IP List | on-list IP got 401 (reached origin auth); off-list got 403 |
| Skipped security for the partner API | the request with x-partner-api: true logged a Skip, without it hit managed rules |
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 |
| IP List cannot be referenced in a rule | Identifier typo, or the list is still empty | Reference it as $corporate_ips and confirm your IP was added |
/admin returns 403 from your workstation | Your public IP is not on corporate_ips | Add your api.ipify.org IP to the list, wait ~15s, retry |
| Partner Skip rule never matches | Header casing or array syntax | Use any(http.request.headers["x-partner-api"][*] eq "true") (lowercased name) |
Lab 9 - Bot Management
9.1 Bot Analytics & Baseline 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.
Create the rule: Security > Security rules > Custom rules > Create rule. Name it "Bot Management - Baseline", click Edit expression, and paste the expression below. Scope it to the bot-demo path and 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.Once the Log events look right, edit the rule, change the action to Managed Challenge, and Deploy. Test from the workstation (a single-egress curl client scores as automated, so it is your stand-in scraper):
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). You will also see the rule firing in Security > Analytics > Events.9.2 Refine BM Rule to Exclude APIs
Legitimate automation also scores low: a nightly internal BI tool identifies itself with the user agent B1-Bot/1.11, and a trusted catalog-sync job hits /services/luggages/. Both would be challenged by the baseline rule. Edit "Bot Management - Baseline", click Edit expression, and replace the expression with:
(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")
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.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.)Before you move on, watch the side effect of that broadened expression. The rule is no longer scoped to /hellobots, so it now evaluates every request on the zone. Request an ordinary page you never intended to protect from bots, the site homepage, from the workstation:
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 | /hellobots returned 403 with cf-mitigated: challenge |
| Excluded trusted automation | /services/luggages/ and the B1-Bot/1.11 agent passed with 200 |
| Saw and fixed the over-broad rule | the homepage was wrongly challenged, 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 |
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 Using Managed Lists
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:
(ip.src in $cf.open_proxies) or (ip.src in $cf.anonymizer)
Set the action to Block, set Status = Active, and click Deploy.
(ip.src in $cf.open_proxies) or (ip.src in $cf.anonymizer), action Block, and status Active. 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.Lab 10 Wrap-up
| Milestone | How you confirmed it |
|---|---|
| Blocked known-bad networks by threat intel | the rule is Active with (ip.src in $cf.open_proxies) or (ip.src in $cf.anonymizer) |
| Zero-maintenance enforcement | Cloudflare keeps the list contents current; no manual blocklist to maintain |
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) |
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.
Test from the workstation by bursting past the threshold:
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; what matters is that the burst flips from 200 to 429.11.2 Rate Limit By Origin Response Code
In the same Advanced rate limiting rules section, click Create rule and name it "Search Fuzzing Protection". Click Edit expression and match a search-style path:
http.request.uri.path eq "/services/search"
404 responses, which is the signal we count.Tick Use custom counting expression. In the Increment counter when box click Edit expression and enter the origin-response condition:
http.response.code eq 404
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.Set Requests = 10, Period = 1 minute. Under Then take action choose Block with duration 1 minute, then Deploy.
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 | a burst on /services/safes/ flipped from 200 to 429 around request 6 |
| Throttled fuzzing by response code | 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).