Cloudflare Application Services - Lab Guide
Unified Lab Guide & Technical Reference — Courseware Version 4.0 (AcmeCorp Edition). Prepared for AcmeCorp IT Security & Infrastructure. Scenario: Edge Security Modernization.
The lab's AWS security groups allow inbound SSH (port 22) to all three VMs only from the public IP address you deployed from. The moment you move to a different network (home to classroom, classroom to hotel, or your ISP rotates your address), SSH to the workstation and origins stops working and you get connection timeouts.
Almost every command in this guide runs from the workstation over SSH (ssh cloudflare@<workstation-ip>), so refresh your IP before you start Lab 1.
There is one command for everything. Run the same lab installer for your operating system any time you need it: to deploy the lab the first time, to update your IP, or to tear the lab down at the end of class. You never need to know where the lab files live or type any other command.
Windows (PowerShell):
irm https://appsec-lab-deploy.cloudflareacademy.com/win | iex
macOS / Linux:
curl -fsSL https://appsec-lab-deploy.cloudflareacademy.com/mac | bash
The installer sets up your toolchain, finds your lab in AWS, then shows a short menu:
- If your lab is already deployed:
[1] Update my IP[2] Destroy lab[3] Quit. Choose 1 to unlock SSH from wherever you are now: it detects your current public IP, confirms the change, and rewrites the SSH rule on all three security groups. - If no lab exists yet:
[1] Deploy new lab[2] Quit.
Works from any computer. Because your lab's state lives in your own AWS account, running the same command on a second machine finds your deployed lab automatically and offers the same menu, so you can update your IP or destroy the lab without getting back to the machine you deployed from. The first time you run it on a new computer it will ask for your AWS access key, secret, and region.
Only SSH is IP-locked. The origins' HTTP :80 and HTTPS :443 stay open to the public internet, so the AcmeCorp website, the public API, and Lab 1's direct-to-origin attacks keep working across network changes.
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 code 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.
AcmeCorp's environment runs on three Ubuntu virtual machines in your AWS account. Each of the two origin servers serves both the AcmeCorp website and the AcmeCorp public API, reachable through Cloudflare as ps.<student-domain> and public-api.<student-domain>. Origin A is your primary; Origin B is an identical backup used in the Load Balancing lab.
| Component | Public IP | Role |
|---|---|---|
| Client Workstation | <workstation-ip> | Ubuntu machine you drive the labs from: run curl, dig, and openssl, and simulate attacks. Preloaded with the lab tooling. |
| Origin A (primary) | <origin-a-ip> | Serves the website (ps.<student-domain>) and the public API (public-api.<student-domain>). Identifies as origin-1; the website /status reports "origin":"primary". |
| Origin B (backup) | <origin-b-ip> | Identical to Origin A. Identifies as origin-2; the website /status reports "origin":"backup". The second pool member in the Load Balancing lab. |
All three VMs use the cloudflare user with the lab password #Savetheinternet (for example, ssh cloudflare@<origin-a-ip>).
Your instructor assigns you a student domain; the three public IPs are printed by the lab installer when you deploy, as workstation_public_ip, origin_a_public_ip, and origin_b_public_ip. Type them below and every matching placeholder across this entire guide — including inside the copy blocks — is replaced with your real values. Values save 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:
ssh cloudflare@<workstation-ip>
curl -sI http://<origin-a-ip>/ | 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 <origin-a-ip>:443 -servername ps.<student-domain> 2>/dev/null | openssl x509 -noout -issuer -subject
HTTP/1.1 200 OK and a Server: header naming the origin's web server. The second shows the certificate's issuer and subject are the same self-signed value, which is why a real browser would throw a certificate warning. 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://<origin-a-ip>/.git/secrets.txt
.git directory. This is exactly the kind of exposure you will virtually patch with the managed WAF in Lab 9.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://<origin-a-ip>/services/luggages/; done | sort | uniq -c
200 (the output reads 50 200). Every scrape succeeded; nothing slowed the client down.1.4 Probe the Locked Admin Panel
Knock on the administrative panel and see how it responds:
curl -s -o /dev/null -w "%{http_code}\n" http://<origin-a-ip>/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 10).401. What matters for the baseline is that /admin is reachable directly on the raw IP at all.1.5 Break the Login with SQL Injection
These origins also run a login application. Send a classic SQL injection payload straight at the login endpoint. The quotes and spaces are URL-encoded so the full payload survives:
curl -s -o /dev/null -w "%{http_code}\n" "http://<origin-a-ip>/login?username=admin%27%20OR%20%271%27%3D%271%27%20--%20&password=x"
200. Nothing inspected or blocked the payload; it went straight to the application.1.6 Brute-force the Login
Now cycle a short password list against the login form, exactly as a credential-stuffing bot would:
for pw in 123456 password admin letmein qwerty hunter2 acmecorp; do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://<origin-a-ip>/login -d "username=admin&password=$pw"
done
200 or a redirect); none are throttled or challenged. The login is wide open.Lab 2 - DNS Onboarding & Proxy Control
2.1 Cloudflare as Authoritative DNS
Log in to the Cloudflare dashboard and click Add a site.
Enter your assigned domain name <student-domain> and select the Enterprise plan.
Because this is a fresh zone, the quick scan finds nothing to import. Create two A records by hand: ps and public-api, both pointing to Origin A (<origin-a-ip>), each with Proxy Status set to Proxied (orange cloud).
:80) and the API (:443) on the same box, so both hostnames resolve to the same IP. Proxying the A records ensures all traffic hits Cloudflare's security edge first, hiding the true IP of the AcmeCorp servers.Log into your domain registrar (simulated via your instructor tool).
Change the nameservers to the ones provided by Cloudflare (e.g., howard.ns.cloudflare.com).
Wait for DNS propagation.
From the Terminal on your workstation, confirm the nameservers are pointing to Cloudflare:
dig +short NS <student-domain>
howard.ns.cloudflare.com.
mia.ns.cloudflare.com.2.2 Partial (CNAME) Setup for a Stubborn Partner (Concept)
Prerequisites you would confirm first. The domain must be on a Business or Enterprise plan, it must keep its current authoritative DNS provider (partial setup means Cloudflare is not authoritative), and it must not be a Cloudflare Registrar domain. Only proxyable record types (A, AAAA, CNAME) can be onboarded this way, and the root/apex (example.com) cannot be proxied in a partial setup, only subdomains.
Convert the zone to CNAME setup. On the domain's Overview page, in the DNS card on the right, select Convert to CNAME DNS Setup, then select Convert to confirm.
Prove ownership with the verification TXT record. After converting, Cloudflare generates a Verification TXT Record (you can always find it again on the DNS > Records page under Verification TXT Record). It looks like this:
Type: TXT
Name: cloudflare-verify.example.com
Content: 966215192-518620144
Add that exact record at the partner's existing authoritative DNS provider. Cloudflare polls for it (this can take up to a few hours), marks the zone active, and emails a confirmation.
Onboard the hostname. In Cloudflare, create a proxied (orange-cloud) record for the hostname you want to protect, for example portal. Then, at the partner's authoritative DNS provider, create a CNAME for that same hostname pointing to Cloudflare's target:
portal.example.com CNAME portal.example.com.cdn.cloudflare.net
Finally, remove any old A, AAAA, or CNAME record for that hostname at the partner's provider, leaving only the .cdn.cloudflare.net CNAME.
portal.example.com into Cloudflare's edge, where the WAF and CDN inspect it, while every other record on the domain keeps resolving straight from the partner's DNS untouched.Verify. You would confirm with an external DNS lookup tool that portal.example.com now resolves through portal.example.com.cdn.cloudflare.net and shows Cloudflare in front, while the rest of the partner's domain is unaffected.
2.3 Controlling Traffic with Proxy Status
In Cloudflare DNS, create a new A record named ftp pointing to <origin-a-ip>.
Set the proxy status to DNS only (grey cloud).
Use dig to query that record:
dig +short ftp.<student-domain>
<origin-a-ip>Set the proxy status back to Proxied for your main web records (ps and public-api).
Lab 3 - SSL/TLS & Encryption Standards
3.1 Universal SSL, Flexible Mode & Edge Encryption
Go to SSL/TLS > Overview and open Configure encryption mode.
Under Encryption mode, select Flexible, then click Save. (The manual modes appear as radio options beneath "Automatic SSL/TLS (recommended)"; there is no separate "Custom" toggle.)
Verify that your Universal SSL certificate has been issued and is active under Edge Certificates.
Now harden the edge. On SSL/TLS > Edge Certificates, 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://ps.<student-domain>/ | head -n 4
curl -sI https://ps.<student-domain>/ | head -n 1
301/308 redirect to the https:// URL (Always Use HTTPS), and the HTTPS request returns 200.Visit your site using https://ps.<student-domain> and inspect the certificate in the browser.
(Optional) Make a request using curl to Origin A directly (<origin-a-ip>) on port 80, bypassing Cloudflare.
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.
*.<student-domain>, <student-domain>) with an expiry roughly 15 years out.In the dashboard, copy the generated Origin Certificate and Private Key. You install the same pair on both origin servers: the whole zone runs under one SSL/TLS mode, the certificate is a wildcard (*.<student-domain>) that works on either machine, and the Load Balancing lab (Lab 7) fails traffic over to Origin B, which must also present a valid certificate or failover returns 526 Invalid SSL certificate.
From your workstation, SSH into Origin A:
ssh cloudflare@<origin-a-ip>
Open the certificate file in nano, paste the Origin Certificate you copied from the dashboard, then save and exit (Ctrl+O, Enter, Ctrl+X):
sudo nano /etc/ssl/acmecorp/cert.pem
Open the key file the same way and paste the Private Key, then save and exit:
sudo nano /etc/ssl/acmecorp/key.pem
Reload nginx so it serves the new certificate (it already listens on port 443 for both ps and public-api):
sudo systemctl reload nginx
Repeat the exact same install on Origin B: SSH in, recreate cert.pem and key.pem with the same certificate and key, then reload nginx.
ssh cloudflare@<origin-b-ip>
526 Invalid SSL certificate.Go to SSL/TLS > Overview > Configure encryption mode, select Full (Strict) under Encryption mode, then click Save.
Use curl to send a request to the origin on port 443, expecting a secure connection:
curl -I https://ps.<student-domain>
3.3 Advanced Certificates for Subdomains
First, in Cloudflare DNS > Records, create an A record named payments.platform pointing to Origin A (<origin-a-ip>) 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.
payments.platform.<student-domain>. Universal SSL only covers one level of subdomains (e.g., *.domain.com), so it will not cover this deep subdomain.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 this is a single-hostname certificate.
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 the 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.Go to the DNS tab and disable the proxy for the subdomain, then visit that subdomain and note what happens.
Lab 4 - Caching Foundations
4.1 Establishing Caching Baselines
From the workstation, inspect the cache status of a product category page:
curl -sSI https://ps.<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://ps.<student-domain>/services/luggages/ | grep -i cf-cache-status
curl -sSI https://ps.<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://ps.<student-domain>/services/luggages/ | grep -iE "cf-cache-status|age|cache-control"
Cache-Control header (for example max-age=300), you will see that cache-control line and, after the first request, cf-cache-status: HIT with an age that grows up to the max-age. If the origin sends no Cache-Control for this HTML page, there is no cache-control line and cf-cache-status stays DYNAMIC, because Cloudflare does not cache HTML on its own. Either outcome proves the edge is now deferring to the origin, not to a dashboard rule.Cache-Control the origin sends, so the cache behavior is driven by the application, exactly as the developers intended.4.4 Troubleshooting an Aggressive Cache
A pricing page is stuck: the origin updated its prices during a live campaign, but visitors still see the old numbers because an aggressive Edge TTL is serving a cached copy. First reproduce the stale HIT. Re-enable the Cache Rule from 4.2 so the category page is cached again, then request it a few times until the edge is serving a cached copy:
curl -sSI https://ps.<student-domain>/services/luggages/ | grep -iE 'cf-cache-status|age'
cf-cache-status: HIT with a non-zero, growing age. The edge is serving the cached copy, so a price change at the origin is not visible yet.Fix it with a targeted purge. Go to Caching > Configuration, use Custom Purge, choose Purge by URL, enter https://ps.<student-domain>/services/luggages/, and confirm. Then re-request the page:
curl -sSI https://ps.<student-domain>/services/luggages/ | grep -iE 'cf-cache-status|age'
cf-cache-status: MISS (fetched fresh from the origin), and the next request is a HIT again. The stale price is gone.Lab 5 - Advanced Caching
5.1 Advanced Strategies: Cache Everything & Cookie Bypass
Create a Cache Everything rule for a specific path (e.g., the marketing landing page).
Run the following and confirm the HTML is being cached (HIT):
curl -svo /dev/null https://ps.<student-domain>/static/test.html 2>&1 | grep cf-cache-status
Create a second Cache Rule that bypasses the cache when a session cookie is present. Under When incoming requests match, set Field = Cookie, Operator = contains, Value = session_id (the expression reads http.cookie contains "session_id"). Under Then, set Cache eligibility to Bypass cache, and Deploy. Leave it below the Cache Everything rule so it takes precedence for logged-in requests.
Run the following two commands to verify:
curl -svo /dev/null https://ps.<student-domain>/static/test.html 2>&1 | grep "cf-cache-status"
curl -svo /dev/null -H "Cookie: session_id=12345" https://ps.<student-domain>/static/test.html 2>&1 | grep "cf-cache-status"
HIT (served from cache), while the second (containing the cookie) should return BYPASS or DYNAMIC (not served from cache), proving the security logic works. A "Bypass cache" cache rule commonly reports cf-cache-status: DYNAMIC for the matching request.A broad Cache Everything rule also matches dynamic and health endpoints that must never be served from cache. Add one more Cache Rule to bypass the cache for the health endpoint /status: under When incoming requests match use the expression http.request.uri.path eq "/status", under Then set Cache eligibility to Bypass cache, and Deploy. Keep it below the Cache Everything rule so it takes precedence.
/status reports live origin health and is the endpoint the Load Balancing lab (Lab 7) polls to watch failover. If Cache Everything caches it, the edge replays a stale response and never re-queries the origin or load balancer, so a real outage still looks healthy and failover appears broken. Health and status endpoints should always bypass cache.curl -sSI https://ps.<student-domain>/status | grep -i cf-cache-status
The response should read BYPASS (or DYNAMIC), confirming /status is fetched fresh from the origin on every request rather than served from cache.5.2 Custom Cache Key
Create a custom cache key to Ignore query strings (e.g., ?utm_source=twitter, ?utm_source=email).
Verify that two different query strings resolve to the same cached object:
curl -sSI "https://ps.<student-domain>/image.jpg?utm_source=twitter" | grep -i cf-cache-status
curl -sSI "https://ps.<student-domain>/image.jpg?utm_source=twitter" | grep -i cf-cache-status
curl -sSI "https://ps.<student-domain>/image.jpg?utm_source=email" | grep -i cf-cache-status
MISS, the second returns HIT. The third uses a different query string (utm_source=email) yet still returns HIT, proving Cloudflare ignored the query string and served the same cached object.MISS, wait 15 to 30 seconds and run it again: once the new cache key is live everywhere, every query-string variant collapses onto the same cached object and returns HIT.5.3 Enable Tiered Caching
Enable Tiered Caching on your zone. Under Tiered Cache Topology, select Smart Tiered Cache (Recommended) and click Apply. If the topology already shows Active, confirm the topology is set to Smart Tiered Cache.
5.4 Cache Negative Responses with a Status Code TTL
By default Cloudflare does not cache 404 responses, so a burst of requests to missing URLs (a broken campaign link, or an attacker fuzzing paths) reaches the origin every single time. You can absorb that at the edge by caching the 404 itself for a few seconds using a Cache Rule Status Code TTL.
Create a Cache Rule (Caching > Cache Rules > Create rule) that matches a path known to return 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. - Deploy.
curl -sSI https://ps.<student-domain>/missing/nope | grep -iE 'HTTP|cf-cache-status'
curl -sSI https://ps.<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 6 - Traffic Management & Rules Engine
6.1 Rules Engine: 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://ps.<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://ps.<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 Header Modification
First, observe a security win that Cloudflare gives you for free. The origin server advertises its exact software in the Server response header (for example Server: nginx/1.24.0 (Ubuntu)), which attackers use to fingerprint the backend and target version-specific vulnerabilities. Inspect the Server header on your proxied site and confirm it reads cloudflare, not nginx.
Server header with its own value (cloudflare). The origin's real software and version never reach the visitor, so the backend fingerprint is hidden with zero configuration.Now create a Response Header Transform Rule that applies to All incoming requests and adds a custom response header: set PSBC-Labs to the value Are Great.
X-Frame-Options or Strict-Transport-Security) without touching origin code.- Open
https://ps.<student-domain>/in Chrome. - Press
F12(or right-click and choose Inspect) to open DevTools, then click the Network tab. - Tick Disable cache in the Network toolbar and reload with
Ctrl+R(Windows/Linux) orCmd+R(macOS). - In the request list, click the top document row (the request to your hostname).
- Select the Headers tab and scroll to Response Headers.
- Confirm
server: cloudflare(origin's nginx version is hidden) andpsbc-labs: Are Great(your Transform Rule fired).
server: cloudflare and psbc-labs: Are Great appear in the output:
curl -sSI https://ps.<student-domain>/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. Turn on two of them now:
- 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. This strips another backend fingerprint, the same way Cloudflare already masks
Server. - Directly below it, enable 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. This is the fast path to the XSS and clickjacking protections you would otherwise hand-build with Transform Rules, and it never touches origin code.x-powered-by is gone:
curl -sSI https://ps.<student-domain>/ | grep -Ei '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 in the output, and no x-powered-by line.6.4 Host Header Overrides for API Gateways
AcmeCorp's public API is served by a separate virtual host on the origin: public-api.<student-domain>. The origin routes by the Host header, so an API request that arrives with the public website's Host (ps.<student-domain>) lands on the website, not the API. First confirm the problem: request https://ps.<student-domain>/api/products and note it returns the website's 404 HTML page.
Host, but public traffic arrives with the front-door hostname, so the origin cannot route it to the right service.Create an Origin Rule with a Host Header Override. Use a custom filter expression that matches the API path, and rewrite the Host the origin receives:
- Rule name:
Lab 6.4 - Host override /api to public-api vhost - When incoming requests match (Edit expression):
starts_with(http.request.uri.path, "/api/") - Host Header: select Rewrite to… and enter
public-api.<student-domain> - Leave SNI, DNS Record, and Destination Port set to Preserve, then Deploy.
Host header at the edge makes the origin route /api/* traffic to the API virtual host, without changing the public URL or touching origin code.- Open
https://ps.<student-domain>/api/productsin Chrome. - Press
F12to open DevTools, click the Network tab, tick Disable cache, and reload. - Click the
api/productsrequest row, then the Response tab: you should now see a JSON product catalog instead of the 404 HTML page. - On the Headers tab, under Response Headers, confirm
content-type: application/jsonandx-origin: origin-1(proof the request reached the API service on the public-api vhost). - As a control, open
https://ps.<student-domain>/and confirm it still returns the website HTML with nox-originheader, since the override only applies to/api/paths.
curl -s -o /dev/null -D - https://ps.<student-domain>/api/products | grep -iE 'HTTP|content-type|x-origin'
Before the rule it returns 404 text/html; after the rule it returns 200 application/json with x-origin: origin-1. Use a GET here, not a HEAD (curl -I): this orders API only implements GET for /api/products and answers a HEAD with 404, so curl -sSI would show a misleading 404 even though the override is working.6.5 URL Rewrites: Serve a Different Resource Without Redirecting
Marketing retired the Arrows product but still runs live links and ads pointing at /services/arrows/. Rather than send those visitors a redirect (which changes the address bar and can break campaign tracking), you will quietly serve the Luggages page from that URL using a URL Rewrite Rule. The visitor keeps seeing /services/arrows/; the origin receives a request for /services/luggages/.
First confirm both pages exist and differ: https://ps.<student-domain>/services/arrows/ and https://ps.<student-domain>/services/luggages/ each return 200 with their own product title.
Create the rule:
- Go to Rules > Overview, and in the URL Rewrite Rules section click Create rule.
- Rule name:
Lab 6.5 - Serve luggages on the arrows URL - If incoming requests match: choose Custom filter expression and enter
http.request.uri.path eq "/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 typeservices/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. - Deploy.
curl -sS https://ps.<student-domain>/services/arrows/ | grep -i '<title>'
curl -sSI https://ps.<student-domain>/services/arrows/ | head -n 1
Before the rule the title reads Arrows; after the rule the same /services/arrows/ URL returns the Luggages page title, and the status line stays HTTP/2 200 (not a 301/302).Lab 7 - Load Balancing & Failover
7.1 Configure Origin Pools & Basic Health Checks
Go to Traffic > Load Balancing, click Manage Monitors, then Create new monitor. Create a simple monitor named lab5-http-root-monitor: Type HTTP, Path /, Port 80, expecting the default 200 response code.
Back on Traffic > Load Balancing, create two separate Origin Pools (use the pool Create button or the endpoints list). For each pool, add one endpoint and attach the monitor you just made:
primary-pool- endpoint nameorigin-a, address<origin-a-ip>, port80, weight1; expand the endpoint's Host header and set the Header value tops.<student-domain>; Monitor:lab5-http-root-monitor.secondary-pool- endpoint nameorigin-b, address<origin-b-ip>, port80, weight1; expand the endpoint's Host header and set the Header value tops.<student-domain>; Monitor:lab5-http-root-monitor.
<student-domain> and *.<student-domain>, not the IP, so the handshake fails and the load balancer returns error 525. Setting the endpoint Host header to a covered hostname (ps.<student-domain>) makes the SNI match the certificate, and Full (strict) validation succeeds. The weight field is required (0.00-1.00); leave it at 1 for now (you will change it in Lab 8).- On Traffic > Load Balancing, look at the endpoints (pools) list.
- Confirm both
primary-poolandsecondary-poolshow a green Healthy status. (Health can take up to a minute to populate after you attach the monitor.)
522. A 522 is an edge-to-origin connection timeout: Cloudflare opened the client TLS session but could not connect to the endpoint. It is confusing because the pool can stay green at the same time. The reason is that health and live traffic do not use the same port:
- The health monitor probes each endpoint on the monitor's own Port (here
80), which is independent of the endpoint's Port. A green pool only proves the monitor's port is reachable, not that the endpoint Port is correct. - If an endpoint's Port does not match the port the origin actually serves (for example a typo of
79instead of80), the monitor keeps succeeding on80and reports Healthy, while live requests are sent to the wrong port and time out with522. With Off (Failover) steering this pins every request to that broken primary, so you get a constant522under a green dashboard. - Fix: edit the pool endpoint and set Port to the port the origin serves (
80in this lab), matching the other endpoint. When you see a522while both pools show Healthy, check the endpoint Port first.
7.2 Implementing Failover
On Traffic > Load Balancing, click Create load balancer. Name it lb.<student-domain>. In the Pools step add primary-pool first and secondary-pool second (order is the failover order); set the fallback pool to secondary-pool. In the Traffic Steering step choose Off (Failover). Finish the wizard and save.
{"status":"healthy","origin":"primary"}:
curl -s https://lb.<student-domain>/statusNow simulate a failure of Origin A. SSH to Origin A and stop nginx:
ssh cloudflare@<origin-a-ip>
sudo systemctl stop nginx
- From your workstation, poll the load balancer for a few cycles:
for i in $(seq 1 6); do curl -s https://lb.<student-domain>/status; echo; sleep 6; done - Within a health-check cycle the response flips to
{"status":"healthy","origin":"backup"}: traffic is now served entirely by Origin B. - Optionally, in the dashboard endpoints list confirm
primary-poolnow shows Unhealthy (Critical) whilesecondary-poolstays Healthy.
"origin":"primary" and never flips to "backup". If Origin A is stopped and primary-pool shows Critical in the dashboard, but /status keeps returning "primary", the request is being answered from the edge cache and never reaches the load balancer, so failover is invisible. Confirm it is a cache hit:
curl -sSI https://lb.<student-domain>/status | grep -i cf-cache-status
A cf-cache-status: HIT (with a climbing age) is the giveaway. The cause is the Lab 5 Cache Everything rule matching /status. Add the Bypass cache rule for http.request.uri.path eq "/status" from Lab 5 (kept below Cache Everything) and purge the cached /status object once, then re-run the failover poll: the response now flips to "origin":"backup" within a health-check cycle.Restore the primary: SSH back to Origin A and start nginx again:
ssh cloudflare@<origin-a-ip>
sudo systemctl start nginx
Poll /status again and confirm the response returns to "origin":"primary" once the primary pool is healthy:
curl -s https://lb.<student-domain>/status
Lab 8 - Advanced Health Checks & Steering
8.1 Troubleshooting with Advanced Health Checks
A basic HTTP check only proves the web server answered with a 200. A "zombie" origin can return 200 OK while the application behind it is broken. To catch that, create a monitor that also inspects the response body. On Traffic > Load Balancing > Manage Monitors, click Create new monitor, name it lab5-status-body-monitor, set Type HTTP, Path /status, Port 80, then expand Advanced health check settings and set Response Body to the substring "status":"healthy".
Edit primary-pool and change its Monitor from lab5-http-root-monitor to lab5-status-body-monitor, then save. Confirm primary-pool stays Healthy (Origin A's /status still contains "status":"healthy").
Now simulate a zombie: keep nginx running (so a basic 200 check would still pass) but make /status return the wrong body. SSH to Origin A and flip the status body to degraded, then reload nginx:
ssh cloudflare@<origin-a-ip>
sudo cp /etc/nginx/sites-available/acmecorp /etc/nginx/sites-available/acmecorp.bak
sudo sed -i 's/"status":"healthy","origin":"primary"/"status":"degraded","origin":"primary"/' /etc/nginx/sites-available/acmecorp
sudo nginx -t && sudo systemctl reload nginx
"status":"healthy" is missing and marks it down.- Confirm the origin is a zombie (200 but wrong body). SSH on Origin A, or from the workstation check the status through the primary path.
- Poll the load balancer:
for i in $(seq 1 8); do curl -s https://lb.<student-domain>/status; echo; sleep 8; done - The body-content check fails,
primary-poolgoes Unhealthy, and the response flips to{"status":"healthy","origin":"backup"}even though Origin A never stopped serving 200s.
Revert the change so the primary recovers: SSH to Origin A, restore the backup, and reload nginx:
sudo mv /etc/nginx/sites-available/acmecorp.bak /etc/nginx/sites-available/acmecorp
sudo nginx -t && sudo systemctl reload nginx
8.2 Weighted steering and origin weights
Edit the load balancer and change the Traffic Steering policy from Off (Failover) to Random. Then set the pool weights so the primary receives roughly 60% and the secondary roughly 40% of traffic: weight primary-pool at 0.6 and secondary-pool at 0.4. Save.
- From your workstation, send a batch of requests and count how many each origin served:
for i in $(seq 1 250); do curl -s https://lb.<student-domain>/status; echo; done | grep -o 'primary\|backup' | sort | uniq -c - You should see both
primaryandbackupcounted, roughly in a 60/40 ratio. Use a large sample: random steering is per-request, so small batches swing widely (for example 40 requests, or even 120, can land near 50/50), but over ~250 requests the split converges on the configured weights.
8.3 Session Affinity (Sticky Sessions)
Weighted Random steering picks a pool per request, so the same visitor can bounce between Origin A and Origin B on consecutive clicks. For a stateful flow (a login session or a shopping cart held on one origin) that bouncing breaks the experience. Session Affinity fixes it by pinning each client to one endpoint for the life of a cookie.
With the load balancer still on Random steering, edit it and turn on affinity:
- Open the load balancer for editing (Traffic > Load Balancing > your LB > Edit).
- On the Hostname step (the first step), below Load balancer description, tick the Session Affinity checkbox.
- Leave the defaults (Cloudflare issues an affinity cookie that encodes which endpoint subsequent requests should return to while that endpoint stays healthy), then save through the wizard.
curl -s -c /tmp/aff.txt https://lb.<student-domain>/status; echo
for i in $(seq 1 10); do curl -s -b /tmp/aff.txt https://lb.<student-domain>/status; echo; done | grep -o 'primary\|backup' | sort | uniq -c
All 10 requests report a single origin (for example 10 primary), proving the client stuck to one endpoint. A fresh client with no cookie would again be split by weight.Lab 9 - WAF Managed Rulesets
9.1 Deploy the WAF and understand OWASP
First, from the workstation, confirm an exposure from Chapter 1 is still live through Cloudflare: the origin still serves its leaked source-control file.
ssh cloudflare@<workstation-ip>
curl -s -o /dev/null -w "%{http_code}\n" https://ps.<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.Retest the exposed file through Cloudflare:
curl -s -o /dev/null -w "%{http_code}\n" https://ps.<student-domain>/.git/secrets.txt
403 and the Cloudflare block page, even though the origin would still serve it. Confirm a matching Block event under Managed rules in Security > Analytics > Events.Now add the OWASP ruleset to cover the login SQL injection. In the Managed rules section, click Deploy managed ruleset again and select the Cloudflare OWASP Core Ruleset.
On the OWASP ruleset configuration page, leave Anomaly Score Threshold at Medium (40) and Paranoia Level at PL1, set the Action to Log, then click Deploy.
From the workstation, SSH in and simulate a SQL injection against the login page. The quotes and spaces are URL-encoded so the full payload is preserved intact:
ssh cloudflare@<workstation-ip>
curl -s -o /dev/null -w "%{http_code}\n" "https://ps.<student-domain>/login?username=admin%27%20OR%20%271%27%3D%271%27%20--%20"
403 and the Cloudflare block page. Go to Security > Analytics, open the Events tab, and within about 20 seconds a new event appears for path /login with Action = Block and Service = Managed rules. Click it to expand the full detail (Ray ID, matched rule, anomaly score): the match is in the Cloudflare Managed Ruleset, which blocks this SQL-injection pattern on its own default action before the OWASP ruleset (running in Log) scores it. The Lab 1 login injection now gets no further than the edge.9.2 Dealing with False Positives
Part A - trust the scanner with an IP Access Rule. AcmeCorp's internal vulnerability scanner runs from the workstation and keeps tripping the WAF. Go to Security > Security rules, click Create rule > IP access rules. Set the Value to your workstation IP (<workstation-ip>), Action to Allow, scope to This website, add the note "Vulnerability Scanner", and deploy.
Re-run the same SQLi from the workstation (the same request from 9.1):
curl -s -o /dev/null -w "%{http_code}\n" "https://ps.<student-domain>/login?username=admin%27%20OR%20%271%27%3D%271%27%20--%20"
200 instead of 403: the Allow rule skipped WAF inspection so the request reached the origin. In Security > Analytics > Events there is no new Block for /login. You may instead see an Allow event attributed to IP access rules, which is the allowlist matching, not a managed-rule block.Important - remove this rule before continuing. Delete the "Vulnerability Scanner" IP Access Rule now. If you leave it in place, all workstation traffic bypasses the WAF, and the rest of Lab 9 (and the WAF labs that follow) will record no events. Go to Security > Security rules, set the Type filter to IP access rules, open the rule's Menu, and choose Delete.
Part B - a surgical path exception. The contact form at /contact/ accepts free-text that can look like an injection payload, so legitimate submissions trip the WAF's SQL-injection rules. Instead of allowlisting an IP, you will skip managed rules for just that one path. First reproduce the false positive from the workstation:
curl -s -o /dev/null -w "%{http_code}\n" "https://ps.<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.Now create the exception. Go to Security > Security rules, click Create rule > Managed rules (this opens the New managed rules exception page). Name it "Contact form exclusion". Under When incoming requests match… set Field = URI Path, Operator = wildcard, Value = /contact/*. Under Then… keep Skip all remaining rules. Change Place at from its default Last to First, then click Deploy.
- From the workstation, re-send the contact-form attack several times (unique markers avoid edge de-duplication):
for i in $(seq 1 10); do curl -s -o /dev/null "https://ps.<student-domain>/contact/?comment=admin%27%20OR%20%271%27%3D%271%27%20--%20&n=$i"; done - Send a control attack against the login page:
curl -s -o /dev/null "https://ps.<student-domain>/login?username=admin%27%20OR%20%271%27%3D%271%27%20--%20" - In Security > Analytics > Events, the
/contact/requests now show Action = Skip (rule Contact form exclusion, no new Block), while the/logincontrol still shows Action = Block (SQLi - Comment). The exception surgically disabled managed rules on the contact path only, leaving the rest of the site protected.
9.3 Reading Analytics & Identifying Real 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 (for example, Exploit or Automated).
Use the filters to answer: which rules or services triggered most, and is a single IP responsible? Drill into an event to view the Ray ID, User Agent, ASN, and the matched rule.
.git virtual patch and the /login and /contact/ SQL-injection attempts, and Skip events from your contact-form exception). You will not see a separate OWASP Log card: the Cloudflare Managed Ruleset blocked these payloads on its own default action before the OWASP ruleset (running in Log) could score them. Events by service shows Managed rules as the top source. Scroll to Top events by source: because all of your test traffic came from the workstation, a single entry under Source IP Addresses (your workstation IP) accounts for every event, and Paths shows /.git/secrets.txt, /login, and /contact/. User Agents shows curl/8.5.0 and Source ASNs shows the workstation's cloud provider. This "one IP, one user agent" pattern is exactly how you distinguish a single automated actor from a distributed attack.Lab 10 - WAF Custom Rules
10.1 Geo Blocking + Setting 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".
AcmeCorp must refuse traffic from a sanctioned country. Click Edit expression (top-right of the "When incoming requests match" box) to switch from the visual builder to the raw expression editor, then paste an expression that matches a single sanctioned country, for example North Korea. Under "Then take action" choose Block, leave the response as the default Cloudflare WAF block page (403), and click Deploy:
ip.src.country eq "KP"
ip.src.country field is a two-letter ISO country code derived from the visitor's IP. Deliberately blocking a country your workstation is not in keeps your own control requests working, so you can verify the rule from a single location.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, find the country the edge geolocates you to:
ssh cloudflare@<workstation-ip> curl -s https://ps.<student-domain>/cdn-cgi/trace | grep '^loc='
loc=US. That two-letter code is your country as the edge sees it; call it <your-country>. It will not be KP, which is why the sanctioned-country rule does not block you.Now confirm that your normal traffic is allowed by the Geo Block rule:
curl -s -o /dev/null -w "%{http_code}\n" https://ps.<student-domain>/
200. Your workstation is not in the sanctioned country, so the Geo Block expression does not match it and the request passes.Now open Security > Security rules > Custom rules, edit Geo Block, temporarily change its expression to match your own country from the trace above (for example ip.src.country eq "US"), click Save, wait about a minute for the edge to update, then re-run the same curl:
curl -s -o /dev/null -w "%{http_code}\n" https://ps.<student-domain>/
403 and the Cloudflare block page. This proves the geo match and the Block action are working. Change the expression back to ip.src.country eq "KP" and Save; the curl returns 200 again. Edge propagation can take up to a minute, so if you still see the old result, wait and re-run.Finally, review the results at Security > Analytics > Events.
US the same way to test it). Expand an event to confirm the matched rule name, the country, and the action.10.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://ps.<student-domain>/contact/
curl -s -D - -o /dev/null https://ps.<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. When you are done, disable this rule so it does not interfere with the contact-form tests in later labs.10.3 Use IP Lists to Manage Access
Lists are defined at the account level. Go to Manage account > Configurations and open the Lists tab, then click Create IP list. For the Identifier enter corporate_ips (only lowercase letters, numbers, and underscores are allowed, and it cannot be changed later), and click Create. On the next screen add your workstation's public IP address and click Add to list.
$corporate_ips, without rewriting the rule when the membership changes.Now return to Security > Security rules, create a custom rule named "Allow Corp Access", click Edit expression, and enter an expression that blocks the /admin path 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. Using "Block when NOT in the list" is the correct pattern here, 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:
ssh cloudflare@<workstation-ip> curl -s -o /dev/null -w "%{http_code}\n" https://ps.<student-domain>/admin
401. Your workstation IP is in corporate_ips, so the rule does not match and the request reaches the origin's Basic-auth challenge.To prove the rule blocks everyone else, go back to Manage account > Configurations > Lists > corporate_ips, select your IP, click Remove, and confirm. Wait about 15 seconds, then re-run the curl:
403, because your IP is no longer on the list so not (ip.src in $corporate_ips) is true and the Block action fires. Re-add your IP to the list to restore access; the request returns 401 again.10.4 Skip Security Features 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 the 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 and All rate limiting rules, leave Log matching requests enabled so the skips still appear in analytics, and click Deploy.
From the workstation, send the same malicious payload twice: once with the partner header and once without it. The with-header request is allowed straight through to the origin (200), because the Skip action turns the managed rules off for it. The without-header request is caught and blocked by the Cloudflare Managed Ruleset (403). The difference shows up both in the status code and in the security events.
ssh cloudflare@<workstation-ip> A=public-api.<student-domain> P="q=admin%27%20OR%20%271%27%3D%271%27%20--%20" curl -s -o /dev/null -w "with header: %{http_code}\n" -H "x-partner-api: true" "https://$A/v1/partners?$P" curl -s -o /dev/null -w "without header: %{http_code}\n" "https://$A/v1/partners?$P"
Lab 11 - Bot Management
11.1 Evaluate Bot Traffic & Create 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 on the zone.Now 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 top-N lists.
Create the rule: Security > Security rules > Custom rules > Create rule. Name it "Bot Management - Baseline", click Edit expression, and paste the raw expression below. Set the action to Log first and Deploy.
(cf.bot_management.score lt 30) and not cf.bot_management.verified_bot
not cf.bot_management.verified_bot excludes verified crawlers such as Googlebot, so your SEO ranking is never harmed.Once the Log events look correct, edit the rule and change the action to Managed Challenge, then Deploy. Test from the workstation. A single-egress workstation using curl scores as automated, so it is your stand-in scraper:
ssh cloudflare@<workstation-ip> D=ps.<student-domain> curl -s -o /dev/null -w "%{http_code}\n" "https://$D/" curl -s -D - -o /dev/null "https://$D/" | 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 200). In Security > Analytics > Events you will also see the Bot Management - Baseline rule firing.11.2 Refine the Rule to Exclude Trusted Automation
Legitimate automation also scores low: the AcmeCorp mobile app calls /api/products, and a nightly internal BI tool identifies itself with the user agent B1-Bot/1.11. Both are being challenged by the baseline rule. Edit the "Bot Management - Baseline" rule, 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 "/api/products") 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 tuning loop for Bot Management: start broad, then add precise exclusions for known-good automated clients.With the action on Managed Challenge, test all three cases from the workstation:
ssh cloudflare@<workstation-ip> D=ps.<student-domain> curl -s -L -o /dev/null -w "normal: %{http_code}\n" "https://$D/" curl -s -L -o /dev/null -w "/api/products: %{http_code}\n" "https://$D/api/products" curl -s -L -o /dev/null -w "B1-Bot UA: %{http_code}\n" -A "B1-Bot/1.11" "https://$D/"
403), while /api/products and the B1-Bot/1.11 request are now allowed through to the origin (200). The exclusions work without weakening the main bot defense. (The -L flag follows any trailing-slash redirect the origin issues on a passed-through request so it reports 200; a challenged request has no redirect to follow and stays 403.)Look closely at that normal: 403 line, because it is the lesson of this lab. Your refined expression is no longer scoped to a single path, so it now evaluates every request on the zone. You never targeted the homepage, yet your single-egress curl scores as automated and the rule challenges it. Confirm it directly:
curl -s -D - -o /dev/null "https://$D/" | grep -iE "HTTP/|cf-mitigated"
HTTP/2 403 with cf-mitigated: challenge. 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 12 - Rate Limiting
12.1 Rate Limit By IP (Brute Force Protection)
Go to Security > Security rules, scroll to the Advanced rate limiting rules section, and click Create rule. Name it "Login Page Rate Limit", click Edit expression, and match the login path:
http.request.uri.path eq "/login"
Under With the same characteristics leave IP selected (count per source IP). Under When rate exceeds set Requests = 5 and Period = 1 minute. Under Then take action choose Managed Challenge, keep Action for the selected duration, and set the duration to 1 minute. Click Deploy.
Test from the workstation by bursting past the threshold:
ssh cloudflare@<workstation-ip> D=ps.<student-domain> for i in $(seq 1 8); do curl -s -o /dev/null -w "req $i -> %{http_code}\n" "https://$D/login"; done curl -s -D - -o /dev/null "https://$D/login" | grep -i "cf-mitigated"
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. The final header check prints cf-mitigated: challenge, confirming the action is a Managed Challenge (an automated client cannot solve it, so it stays blocked for the mitigation window; a real browser would be offered the challenge instead).12.2 Rate Limit By Origin Response Code
In the same Advanced rate limiting rules section, click Create rule and name it "API Search Abuse Protection". Click Edit expression and match the search endpoint:
http.request.uri.path eq "/api/v1/search"
404 responses, which is the signal we will 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 (fuzzing or scanning) 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 origin returns 404 for /api/v1/search, so every request feeds the counter:
ssh cloudflare@<workstation-ip> D=ps.<student-domain> for i in $(seq 1 25); do curl -s -o /dev/null -w "req $i -> %{http_code}\n" "https://$D/api/v1/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 13 - API Discovery & Endpoint Management
13.1 Session Identifiers, Discovery & Endpoint Management
First set a session identifier. Go to Security > Settings, open the API abuse category, click into Session identifiers (/security/settings/api-abuse/session-identifiers), click Add identifiers, set Type = Header and Name = Authorization, and Save.
Generate API traffic from the workstation. Send a distinct Authorization value per simulated user across several endpoints, including the undocumented shadow endpoint:
ssh cloudflare@<workstation-ip> H=public-api.<student-domain> for u in $(seq 1 20); do A="Authorization: Bearer user-$u-tok" curl -s -o /dev/null -H "$A" "https://$H/status" curl -s -o /dev/null -H "$A" "https://$H/v1/orders" curl -s -o /dev/null -H "$A" -X POST -H "Content-Type: application/json" -d "{\"createdBy\":\"user$u\",\"items\":[\"sku-$u\"]}" "https://$H/v1/orders" curl -s -o /dev/null -H "$A" "https://$H/v1/trying/hidden/endpoint" done
Discovered endpoints appear under Security > Web assets > Operations (Endpoint Management) via Add operations > Select from Discovery. Until Discovery populates, add the endpoints you want to govern 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/trying/hidden/endpoint shows up under Add operations > Select from Discovery.)Lab 14 - API Schema Validation & Sequence Analytics
14.1 Schema Validation & Endpoint Rate Limiting
The API publishes its own OpenAPI schema at /openapi.json. Fetch it and point its servers URL at your zone's API hostname (this is required so Cloudflare maps the operations to your host, not the schema's placeholder host):
ssh cloudflare@<workstation-ip> H=public-api.<student-domain> curl -s "https://$H/openapi.json" \ | sed -E "s#\"url\": *\"https://[^\"]*\"#\"url\": \"https://$H\"#" \ > acmecorp-openapi.json head -n 500 acmecorp-openapi.json
servers block. If it still points at the sample host, the uploaded operations will not match your live traffic and validation never fires.Copy that file to the machine running your browser, then go to Security > Web assets > Schema validation, click Upload schema, select the file, review that the endpoints map to public-api.<student-domain>, and click Add schema and endpoints.
Now enforce it. In the Schema validation list, tick the POST /v1/orders row, click Change action…, choose Block, and Set action.
CreateOrderRequest schema (missing createdBy/items, wrong type, or an extra field since the schema sets additionalProperties: false) is dropped at the edge before it reaches the origin.Test from the workstation, one compliant request and three violations:
ssh cloudflare@<workstation-ip> H=public-api.<student-domain> CT="Content-Type: application/json" curl -s -o /dev/null -w "valid: %{http_code}\n" -X POST -H "$CT" -d "{\"createdBy\":\"alice\",\"items\":[\"sku1\"]}" "https://$H/v1/orders" curl -s -o /dev/null -w "extra field: %{http_code}\n" -X POST -H "$CT" -d "{\"createdBy\":\"alice\",\"items\":[\"sku1\"],\"evil\":\"x\"}" "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" curl -s -o /dev/null -w "wrong type: %{http_code}\n" -X POST -H "$CT" -d "{\"createdBy\":123,\"items\":[\"sku1\"]}" "https://$H/v1/orders"
201 (it reaches the origin and creates the order); all three violations return 403. The 403 is Cloudflare's schema-validation block at the edge, and it is distinct from the origin's own 400 for bad input, so seeing 403 (not 400) proves the request never reached the backend.Endpoint rate limiting. Open the operation from Endpoint Management (click the /v1/orders row) to reach Operation Details. Once session identifiers (13.1) and enough traffic exist, a Rate limiting recommendation is generated here that you can accept to create an endpoint-scoped rate-limiting rule. You can also build one manually with the Lab 12 flow, scoping the match to the operation, for example http.request.uri.path eq "/v1/orders" and http.request.method eq "POST".
14.2 Sequence Analytics & Mitigation
With session identifiers configured (13.1), generate traffic that follows the intended order, plus some out-of-order calls, from the workstation:
ssh cloudflare@<workstation-ip> H=public-api.<student-domain> CT="Content-Type: application/json" # intended flow per user: check status, list orders, then create an order for u in $(seq 1 10); do A="Authorization: Bearer seq-$u" curl -s -o /dev/null -H "$A" "https://$H/status" curl -s -o /dev/null -H "$A" "https://$H/v1/orders" curl -s -o /dev/null -H "$A" -X POST -H "$CT" -d "{\"createdBy\":\"seq$u\",\"items\":[\"a\"]}" "https://$H/v1/orders" done # abnormal: create an order with no preceding GETs for u in $(seq 11 15); do curl -s -o /dev/null -H "Authorization: Bearer bad-$u" -X POST -H "$CT" -d "{\"createdBy\":\"bad$u\",\"items\":[\"a\"]}" "https://$H/v1/orders" done
Review the results under Security > Web assets > Sequences. When a risky sequence is identified, mitigate it with a custom rule (Security > Security rules > Custom rules) that references the API Shield sequence, or as a simpler stand-in, gate the sensitive operation behind a required precondition (such as a valid session token or a preceding step) so out-of-order requests are challenged or blocked.
Lab 15 - Threat Intel with Managed Lists
15.1 Using Managed Lists
Go to Security > Security rules, click Create rule > Custom rules (/security/security-rules/custom-rules/create), and name it "Block Malicious IPs by Managed List".
Build the match with the expression editor. Set Field = IP Source Address and Operator = is in list, then in Value choose the managed list Open Proxies. Click Or and add a second condition for the Anonymizers managed list. The Expression Preview should read:
(ip.src in $cf.open_proxies) or (ip.src in $cf.anonymizer)
Set Action to Block and Deploy.
200 (the workstation is not on any managed list), so there is no false positive:
ssh cloudflare@<workstation-ip> curl -s -o /dev/null -w "%{http_code}\n" https://ps.<student-domain>/
Lab 16 - Spectrum for TCP/UDP (Concept)
16.1 Protecting TCP/UDP Applications
Spectrum lives at (domain) > Spectrum (/spectrum). The page lists any existing applications and offers Create an Application.
The Create an Application dialog collects, in order:
ssh.<student-domain>) that becomes the public entry point; Cloudflare creates the matching DNS record.22-23; ranges must match between edge and origin).A representative configuration for AcmeCorp would front their SSH jump host: Application Type TCP, Domain ssh.<student-domain>, Edge Port 22, Origin the jump host's IP on port 22. Users would then connect to ssh.<student-domain> and reach the origin through Cloudflare, with the origin IP never exposed.
Lab 17 - Page Shield: Client-Side Protection
17.1 Client-side Resource Monitoring
Enable monitoring. Go to Security > Settings, open the Client side abuse category, and turn on Continuous script monitoring (/security/settings?tabs=client-side-abuse).
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 (/security/web-assets/monitor), 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).17.2 Content Security Rules (CSP)
Monitoring tells you what is running; a Content security rule lets you enforce what is allowed to run. Go to Security > Security rules, find Content security rules, and click Create rule (/security/security-rules/client-side-rules/create). Name it "Page Shield - Script Allowlist".
Scope the rule to your site host, and build the allowlist from the legitimate resources you saw in 17.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, change 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 the policy and are reported as violations.Confirm the policy is live by checking the response header from the workstation:
ssh cloudflare@<workstation-ip> 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; switching the action to Allow sends the enforcing content-security-policy header instead. A resource outside the allowlist (the cryptomining script, the hookb.in beacon) is reported or blocked accordingly.End of Unified Lab Guide.
Lab 18 - Seal & Validate the Perimeter
18.1 Seal the Origin
Proxying through Cloudflare hides the origin IPs, but it does not by itself stop someone who already knows an origin 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. There are two standard ways to do it.
18.2 Replay the Lab 1 Attacks
Now rerun the Lab 1 attacks, but this time through the Cloudflare hostname (ps.<student-domain>) instead of the raw IP, and compare the results to the baseline you recorded.
Brute force, now rate limited. Repeat the login brute force through Cloudflare:
ssh cloudflare@<workstation-ip>
for i in $(seq 1 8); do curl -s -o /dev/null -w "%{http_code}\n" https://ps.<student-domain>/login; done
200, then the rate limit from Lab 12 kicks in and further requests return 429. In Lab 1 all attempts succeeded; now the brute force is capped.SQL injection, now seen by the WAF. Replay the injection through Cloudflare:
curl -s -o /dev/null -w "%{http_code}\n" "https://ps.<student-domain>/login?username=admin%27%20OR%20%271%27%3D%271%27%20--%20"
403 instead of reaching the origin. In Lab 1 the identical payload sailed through to the application unseen.Direct-to-IP, now refused. Finally, try the Lab 1 direct-IP request again:
curl -sI --max-time 5 http://<origin-a-ip>/ | head -n 3
200 you saw in Lab 1. The only way in is through Cloudflare. Note: if origin sealing has not been applied in your lab environment, this request may still return 200; that is exactly the gap 18.1 closes.18.3 Architecture Recap
You took AcmeCorp from wide open to sealed and validated. 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 IPs so attackers cannot target them directly. |
| SSL/TLS encryption | 3 | Ends plaintext and certificate warnings with Full (Strict) end-to-end encryption. |
| Caching & performance | 4, 5 | Absorbs traffic spikes at the edge so the origin stays up. |
| Load balancing & health | 7, 8 | Removes the single point of failure and routes around broken origins. |
| Traffic & rules engine | 6 | Runs business and hardening logic at the edge, off the origin. |
| WAF (managed + custom) | 9, 10 | Virtually patches the SQL injection and enforces access policy. |
| Bot management | 11 | Challenges the scrapers while allowing trusted automation. |
| Rate limiting | 12 | Caps the login brute force and API fuzzing. |
| API Shield | 13, 14 | Discovers the API, enforces its schema, and flags abnormal sequences. |
| Threat intelligence | 15 | Preemptively blocks anonymizers and open proxies. |
| Page Shield | 17 | Watches the browser for supply-chain and skimming attacks. |
| Sealed origin | 18 | Forces all traffic through Cloudflare so nothing above can be bypassed. |