A continuation of Janady13/crawler-signal-reference / framework-http-cors-headers.md. The complete source identity, every recorded occurrence, and linked equations are on its first page.
Source transcript · continued
Download complete source text ↓2,069 lines · 82.1 KB
listen 443 quic;
http2 on;
http3 on;
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_early_data on;
# HTTP/3 advertisement (from framework-http-performance-headers.md)
add_header Alt-Svc 'h3=":443"; ma=86400' always;
# Security baseline (from framework-http-security-headers.md)
include snippets/common-security-headers.conf;
# API location with full CORS handling
location / {
# Preflight (OPTIONS) handler
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-CSRF-Token, X-Request-ID" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age "86400" always;
add_header Vary "Origin" always;
add_header Cache-Control "public, max-age=86400" always;
return 204;
}
# Actual request handling: CORS headers plus pass to upstream
if ($cors_origin) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Expose-Headers "X-Total-Count, X-RateLimit-Remaining, Server-Timing, ETag, Link" always;
add_header Vary "Origin" always;
}
# SEO: API endpoints should not be indexed (from framework-http-seo-headers.md)
add_header X-Robots-Tag "noindex, nofollow" always;
# Cache control (from framework-http-caching-headers.md)
add_header Cache-Control "public, max-age=0, must-revalidate" always;
proxy_pass http://127.0.0.1:9090;
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;
proxy_pass_header Server-Timing;
}
}
```
After deploying:
```bash
nginx -t && systemctl reload nginx
```
Verify the full flow:
```bash
# Preflight
curl -sI -X OPTIONS -H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: authorization" \
https://api.example.com/users/123
# Actual
curl -sI -X PUT -H "Origin: https://app.example.com" \
-H "Authorization: Bearer test" \
https://api.example.com/users/123
```
Both should return the expected CORS headers.
---
## 15. AUDIT CHECKLIST
Run through these 50 items for any production CORS configuration.
### Access-Control-Allow-Origin
1. [ ] Header present on every CORS enabled endpoint (both preflight and actual response).
2. [ ] Header value is specific (not `*`) for any endpoint with credentials.
3. [ ] Multi origin support uses map based validation, not raw `$http_origin` reflection.
4. [ ] No origin reflection without validation against an allow list.
5. [ ] Allow list regex matches exactly (anchored with `^` and `$`).
6. [ ] Vary: Origin header set whenever Allow-Origin is dynamic.
7. [ ] Origins listed include exact scheme (https://, not just example.com).
8. [ ] Origins listed include port when non standard.
### Access-Control-Allow-Methods
9. [ ] Preflight response includes the methods the API actually accepts.
10. [ ] OPTIONS is always in the list (some browsers require it).
11. [ ] No methods listed that the API does not accept (avoid misleading the browser).
12. [ ] PUT, DELETE, PATCH explicitly listed if the API uses them.
### Access-Control-Allow-Headers
13. [ ] All custom request headers used by the frontend are listed.
14. [ ] Content-Type is listed (required for JSON APIs).
15. [ ] Authorization is listed (required for token based auth).
16. [ ] X-CSRF-Token (or equivalent) is listed if used.
17. [ ] Header names match the case used by the frontend (case insensitive but spelled correctly).
### Access-Control-Expose-Headers
18. [ ] Custom response headers used by the frontend are explicitly exposed.
19. [ ] X-Total-Count or pagination headers exposed if pagination is used.
20. [ ] Rate limit headers (X-RateLimit-*) exposed if rate limiting is announced.
21. [ ] Server-Timing exposed if RUM relies on it for cross origin endpoints.
22. [ ] ETag exposed if client side validation uses it.
### Access-Control-Max-Age
23. [ ] Max-Age header present on every preflight response.
24. [ ] Value is at least 7200 (Chrome cap) to maximize cross browser benefit.
25. [ ] Value 86400 sent to take full advantage of Firefox cap (Chrome still caps at 7200).
26. [ ] No accidentally setting Max-Age to 0 or -1 in production.
### Access-Control-Allow-Credentials
27. [ ] Set to `true` only on endpoints that require authenticated cross origin access.
28. [ ] When set, Allow-Origin is specific (not wildcard).
29. [ ] When set, all other Allow-* and Expose-Headers use specific values, not wildcards.
30. [ ] Cookies used for auth have SameSite=None; Secure attributes.
31. [ ] Frontend uses `credentials: "include"` in fetch when needed.
### Preflight handling
32. [ ] OPTIONS requests return 204 No Content (or 200).
33. [ ] Preflight response includes all required CORS headers.
34. [ ] The `if ($request_method = OPTIONS)` pattern is used safely (with `return`, not `proxy_pass`).
35. [ ] Preflight is handled at the nginx layer (faster) when possible, not always forwarded to upstream.
### Configuration hygiene
36. [ ] No duplicate Allow-Origin headers (would indicate nginx and upstream both adding).
37. [ ] `add_header` inheritance trap not triggered (use snippet pattern from framework-http-security-headers.md).
38. [ ] All add_header lines use `always` keyword.
39. [ ] No CORS headers leaking on non CORS endpoints (e.g. main HTML pages).
40. [ ] No origins in the allow list that no longer exist or have been deprecated.
### Security
41. [ ] No `Access-Control-Allow-Origin: *` with credentials anywhere.
42. [ ] No `Access-Control-Allow-Origin: null` unless specifically required.
43. [ ] Origin allow list does not include unintended subdomains via overly broad regex.
44. [ ] Pen test does not report origin reflection vulnerability.
45. [ ] StackHawk or similar scanner reports no CORS misconfigurations.
### Cross cutting
46. [ ] `nginx -t` passes without warnings.
47. [ ] `nginx -T` shows expected CORS directives.
48. [ ] curl preflight test returns expected headers.
49. [ ] curl actual request test returns expected headers.
50. [ ] Browser console shows no CORS errors on production traffic.
A site that passes all 50 has correctly configured CORS for security and performance.
---
## 16. COMMON PITFALLS
Fifteen patterns to recognize and avoid.
**Pitfall 1: Wildcard plus credentials.**
Symptom: browser console "Access-Control-Allow-Origin cannot be wildcard when credentials are included".
Why it breaks: browser explicitly refuses the combination.
Fix: replace `*` with specific origin via map based validation.
**Pitfall 2: Origin reflection without validation.**
Symptom: scanner reports CORS misconfiguration; potential credential theft from any origin.
Why it breaks: `add_header Access-Control-Allow-Origin $http_origin always;` says "whoever you are, you may read with credentials". Attacker hosts JavaScript on evil.com, makes authenticated request to victim's browser, reads response.
Fix: use map based allow list (Section 5.3 and 5.5). Never reflect without validation.
**Pitfall 3: Missing Vary: Origin with dynamic Allow-Origin.**
Symptom: cache (CDN, proxy, browser) serves response approved for origin A to a request from origin B.
Why it breaks: cache key does not include Origin; one stored response serves all requesters.
Fix: always add `Vary: Origin` when Allow-Origin is dynamic.
**Pitfall 4: Missing preflight handler returning 405.**
Symptom: OPTIONS request returns 405 Method Not Allowed.
Why it breaks: location does not handle OPTIONS, falls through to default handler.
Fix: add `if ($request_method = OPTIONS) { return 204; }` block with CORS headers.
**Pitfall 5: Preflight returns 200 with body instead of 204.**
Symptom: works but logs are noisy, some scanners flag the unnecessary body.
Why it breaks: 200 is allowed by spec; 204 is preferred and cleaner.
Fix: use `return 204;` instead of letting nginx fall through to a default 200 response.
**Pitfall 6: Custom request header missing from Allow-Headers.**
Symptom: "Request header field X-Custom is not allowed".
Why it breaks: every custom header must be in the allow list.
Fix: add the header to Access-Control-Allow-Headers.
**Pitfall 7: Custom response header invisible to JavaScript.**
Symptom: `response.headers.get("X-Total-Count")` returns null despite the header being on the response.
Why it breaks: only safelisted response headers are visible by default; everything else requires opt in.
Fix: add the header to Access-Control-Expose-Headers.
**Pitfall 8: Missing Max-Age, every request preflights.**
Symptom: SPA feels slow on cross origin API calls.
Why it breaks: default cache is 5 seconds; every API call doubles latency from preflight.
Fix: add `Access-Control-Max-Age 86400`.
**Pitfall 9: nginx and upstream both adding CORS headers.**
Symptom: duplicate Allow-Origin header; browser rejects.
Why it breaks: nginx adds one, FastAPI middleware adds another. Browser sees two values, treats as invalid.
Fix: pick one layer to handle CORS. Either nginx or upstream, not both.
**Pitfall 10: `if ($request_method = OPTIONS)` combined with proxy_pass.**
Symptom: unexpected behavior, preflight succeeds but actual request fails.
Why it breaks: `if` plus `proxy_pass` is one of the nginx if antipatterns. The combination produces non obvious behavior.
Fix: keep `if` with `return` only. Use separate handling for the actual request.
**Pitfall 11: Forgotten OPTIONS in Allow-Methods.**
Symptom: preflight succeeds but some browsers (Safari) refuse the actual request.
Why it breaks: OPTIONS should be listed even though it is the preflight method itself.
Fix: include OPTIONS in the methods list.
**Pitfall 12: Cookie SameSite=None missing Secure.**
Symptom: cross origin auth works on https but fails on http; mobile users with mixed networks see intermittent auth failures.
Why it breaks: SameSite=None requires Secure attribute, which requires HTTPS.
Fix: ensure cookies are set with `Secure` attribute and site is HTTPS only (which Bubbles already is via HSTS).
**Pitfall 13: Overly broad regex allows unintended subdomains.**
Symptom: scanner reports `https://attacker.example.com.evil.com` is allowed.
Why it breaks: regex `~^https://.*\.example\.com$` matches `attacker.example.com.evil.com` because `.` matches anything.
Fix: anchor the end properly and escape dots: `~^https://[a-z0-9-]+\.example\.com$`.
**Pitfall 14: Allow list missing development origins.**
Symptom: works in production, breaks in local development.
Why it breaks: localhost or 127.0.0.1 not in the allow list.
Fix: add development origins conditionally:
```nginx
map $http_origin $cors_origin {
default "";
"~^https://app\.example\.com$" "$http_origin";
# Development: comment out in production if security concern
"~^https?://localhost(:[0-9]+)?$" "$http_origin";
}
```
**Pitfall 15: Set-Cookie expected to be readable from JavaScript.**
Symptom: JavaScript cannot read the auth cookie.
Why it breaks: Set-Cookie is intentionally never exposed to script regardless of CORS. This is a fundamental browser security boundary.
Fix: do not attempt to read Set-Cookie from script. Use HttpOnly cookies for sessions and let the browser handle them automatically with `credentials: "include"`.
---
## 17. DIAGNOSTIC COMMANDS
Reference of every command useful for CORS investigation.
### Simulate preflight
```bash
# Simulate full preflight
curl -sI -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: authorization, content-type" \
https://api.example.com/users/123
# Look for the response headers
curl -sI -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
https://api.example.com/data | grep -iE "^(access-control|vary):"
# Test from different origins
for origin in "https://app.example.com" "https://evil.com" "https://localhost:5173"; do
echo "=== Origin: $origin ==="
curl -sI -X OPTIONS -H "Origin: $origin" \
-H "Access-Control-Request-Method: POST" \
https://api.example.com/data | grep -i access-control-allow-origin
done
```
### Simulate actual request
```bash
# Actual GET with origin
curl -sI -H "Origin: https://app.example.com" \
https://api.example.com/data | grep -iE "^(access-control|vary):"
# Actual POST with auth
curl -sI -X POST -H "Origin: https://app.example.com" \
-H "Authorization: Bearer test" \
-H "Content-Type: application/json" \
https://api.example.com/users \
-d '{"name":"test"}'
# Confirm Expose-Headers and the actual headers being exposed
curl -sI -H "Origin: https://app.example.com" \
https://api.example.com/users | grep -iE "^(access-control-expose|x-total|server-timing|etag):"
```
### Test the full flow
```bash
# Bash function: test a full CORS flow
test_cors() {
local origin=$1
local method=$2
local url=$3
echo "=== Preflight: $method $url from $origin ==="
curl -sI -X OPTIONS -H "Origin: $origin" \
-H "Access-Control-Request-Method: $method" \
-H "Access-Control-Request-Headers: authorization, content-type" \
"$url" | grep -iE "^(HTTP|access-control|vary):"
echo ""
echo "=== Actual: $method $url from $origin ==="
curl -sI -X "$method" -H "Origin: $origin" \
-H "Authorization: Bearer test" \
"$url" | grep -iE "^(HTTP|access-control|vary):"
}
test_cors "https://app.example.com" "PUT" "https://api.example.com/users/123"
test_cors "https://evil.com" "PUT" "https://api.example.com/users/123"
```
### Browser DevTools quick reference
In Chrome DevTools:
1. **Network tab**: shows preflight (OPTIONS) and actual requests as separate entries.
2. Click the OPTIONS request: Headers tab shows the CORS response.
3. Click the actual request: Headers tab shows CORS response and any errors.
4. **Console tab**: CORS errors appear in red with the specific reason.
Useful console commands:
```javascript
// Test a CORS request and read response
fetch("https://api.example.com/data", {
method: "GET",
credentials: "include",
headers: {"Authorization": "Bearer test"}
})
.then(r => {
console.log("Status:", r.status);
console.log("X-Total-Count:", r.headers.get("X-Total-Count"));
console.log("Server-Timing:", r.headers.get("Server-Timing"));
return r.json();
})
.then(data => console.log("Data:", data))
.catch(e => console.error("Error:", e));
// List what headers are visible
fetch("https://api.example.com/data")
.then(r => {
for (const [k, v] of r.headers.entries()) {
console.log(k, ":", v);
}
});
```
### Server side investigation
```bash
# Find CORS directives in active config
nginx -T 2>/dev/null | grep -iE "access-control|http_origin|cors_origin"
# Check map definitions
nginx -T 2>/dev/null | grep -A 10 "map \$http_origin"
# Find any wildcard plus credentials combinations (security check)
nginx -T 2>/dev/null | awk '/Access-Control-Allow-Origin.*\*/{f=1} /Access-Control-Allow-Credentials.*true/{if(f)print "DANGER: wildcard + credentials in same scope"} /server {/{f=0}'
# Audit for any raw $http_origin usage (unvalidated reflection)
nginx -T 2>/dev/null | grep "Access-Control-Allow-Origin.*\$http_origin"
# Any output here is potentially a vulnerability
# Apply changes
nginx -t && systemctl reload nginx
```
### External CORS testers
```bash
# Online testers
echo "Visit: https://cors-test.codehappy.dev/"
echo "Visit: https://cors-anywhere.herokuapp.com/"
# Self hosted with a test page
cat > /tmp/cors-test.html << 'EOF'
<!DOCTYPE html>
<html><body>
<h1>CORS Test</h1>
<button onclick="testCors()">Test</button>
<pre id="out"></pre>
<script>
async function testCors() {
const out = document.getElementById("out");
out.textContent = "Testing...";
try {
const r = await fetch("https://api.example.com/data", {
method: "GET",
credentials: "include",
headers: {"Authorization": "Bearer test"}
});
out.textContent = `Status: ${r.status}\n`;
for (const [k, v] of r.headers.entries()) {
out.textContent += `${k}: ${v}\n`;
}
} catch (e) {
out.textContent = `Error: ${e.message}`;
}
}
</script>
</body></html>
EOF
# Serve from a different origin to test cross origin behavior
python3 -m http.server -d /tmp 8080
# Visit http://localhost:8080/cors-test.html in browser
```
---
## 18. CROSS-REFERENCES
* [framework-http-caching-headers.md](framework-http-caching-headers.md): Cache-Control, ETag, Last-Modified, Expires, Vary, Age. Vary: Origin is part of the CORS pattern and shares mechanics with Vary: Accept-Encoding from the caching framework.
* [framework-http-content-headers.md](framework-http-content-headers.md): Content-Type, Content-Language, Content-Encoding, Content-Length, Content-Disposition. Content-Type values determine whether a request preflights (application/json preflights; form encoded does not).
* [framework-http-seo-headers.md](framework-http-seo-headers.md): X-Robots-Tag, Link, Location. API endpoints should typically be noindex.
* [framework-http-security-headers.md](framework-http-security-headers.md): HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CORP, COEP, COOP. CORS is closely related to but distinct from CORP. CORS is what the origin says about who may read its responses; CORP is what the resource owner says about who may load it at all.
* [framework-http-performance-headers.md](framework-http-performance-headers.md): Server-Timing, Timing-Allow-Origin, Server, Alt-Svc. Timing-Allow-Origin is the CORS equivalent for the Resource Timing API. Server-Timing requires Access-Control-Expose-Headers to be readable from cross origin JavaScript.
* [UNIVERSAL-RANKING-FRAMEWORK.md](UNIVERSAL-RANKING-FRAMEWORK.md): the master ranking reference.
* [SEO-BUILD-REFERENCE.md](SEO-BUILD-REFERENCE.md) v2.4: the build playbook.
* [framework-fastapi-sidecar.md](framework-fastapi-sidecar.md): FastAPI patterns including CORSMiddleware usage.
* WHATWG Fetch (CORS spec): https://fetch.spec.whatwg.org/
* MDN CORS guide: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
* MDN Access-Control-Allow-Origin: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Origin
* MDN Access-Control-Allow-Credentials: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials
* MDN Access-Control-Max-Age: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age
* enable-cors.org: https://enable-cors.org/
* nginx `if` is evil article: https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/
---
## APPENDIX A: ONE PAGE QUICK REFERENCE
For the person who just wants the answer.
### Bubbles default CORS pattern for authenticated API
```nginx
# http context
map $http_origin $cors_origin {
default "";
"~^https://app\.example\.com$" "$http_origin";
}
# server / location context
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-CSRF-Token" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Max-Age "86400" always;
add_header Vary "Origin" always;
return 204;
}
if ($cors_origin) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Expose-Headers "X-Total-Count, Server-Timing, ETag, Link" always;
add_header Vary "Origin" always;
}
proxy_pass http://127.0.0.1:9090;
}
```
### Public read only API
```nginx
location /api/public/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Max-Age "86400" always;
return 204;
}
add_header Access-Control-Allow-Origin "*" always;
proxy_pass http://127.0.0.1:9090;
}
```
### Header decision matrix
| Header | Preflight | Actual response | With credentials? |
|---|---|---|---|
| Access-Control-Allow-Origin | Yes | Yes | Specific value (no `*`) |
| Access-Control-Allow-Methods | Yes | n/a | Specific value (no `*`) |
| Access-Control-Allow-Headers | Yes | n/a | Specific value (no `*`) |
| Access-Control-Allow-Credentials | Yes | Yes | `true` |
| Access-Control-Max-Age | Yes | n/a | Specific seconds value |
| Access-Control-Expose-Headers | n/a | Yes | Specific value (no `*`) |
| Vary: Origin | Yes | Yes | Required when Allow-Origin is dynamic |
### Five rules to memorize
1. Never combine `*` with credentials. Browser refuses.
2. Always validate origin against an allow list. Never reflect blindly.
3. Always set `Vary: Origin` when Allow-Origin is dynamic.
4. Always set `Max-Age` (use 86400) to avoid every API call preflighting.
5. Custom request headers go in Allow-Headers; custom response headers go in Expose-Headers.
### Five commands every operator should know
```bash
# 1. Simulate preflight
curl -sI -X OPTIONS -H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
https://api.example.com/data
# 2. Simulate actual request
curl -sI -H "Origin: https://app.example.com" \
https://api.example.com/data | grep -i access-control
# 3. Test from disallowed origin (should not get Allow-Origin)
curl -sI -H "Origin: https://evil.com" \
https://api.example.com/data | grep -i access-control-allow-origin
# 4. Find any unvalidated origin reflection (security audit)
nginx -T 2>/dev/null | grep "Access-Control-Allow-Origin.*\$http_origin"
# Any output is potentially a vulnerability
# 5. Apply changes
nginx -t && systemctl reload nginx
```
### Three end to end tests
```bash
# 1. Preflight succeeds
curl -sI -X OPTIONS -H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: PUT" \
https://api.example.com/data | head -1
# Expected: HTTP/2 204
# 2. Disallowed origin gets no Allow-Origin
curl -sI -H "Origin: https://evil.com" \
https://api.example.com/data | grep -i access-control-allow-origin | wc -l
# Expected: 0
# 3. Vary: Origin is set on dynamic responses
curl -sI -H "Origin: https://app.example.com" \
https://api.example.com/data | grep -i vary | grep -i origin
# Expected: vary: Origin
```
If all three produce expected output AND browser DevTools shows no CORS errors on production traffic, the CORS stack is correctly wired.
---
End of framework-http-cors-headers.md.