Add CSP, file upload, open redirect, clickjacking patterns

Complete security patterns collection (23 total):
- csp.md: nonces, hashes, strict-dynamic, reporting
- file-upload.md: content validation, path traversal, malware scanning
- open-redirect.md: URL validation, OAuth redirect URI, bypass techniques
- clickjacking.md: X-Frame-Options, frame-ancestors CSP

Comprehensive coverage for web application security review.
This commit is contained in:
Rodin
2026-05-10 23:24:52 -07:00
parent 17c535bc61
commit 1eac5d3bcc
5 changed files with 768 additions and 16 deletions
+35 -16
View File
@@ -29,18 +29,37 @@ Based on OWASP Top 10:2025 and recent security research.
| [jwt-security.md](jwt-security.md) | Algorithm confusion, weak secrets, expiration | A07 |
| [session-management.md](session-management.md) | Session fixation, hijacking, secure cookies | A07 |
### Attack Prevention
### Injection & Request Attacks
| File | Topic | OWASP 2025 |
|------|-------|------------|
| [injection-prevention.md](injection-prevention.md) | SQL, command, template, path traversal | A05 |
| [ssrf.md](ssrf.md) | Server-side request forgery, metadata endpoints | A10 |
| [xxe.md](xxe.md) | XML external entities, DTD attacks | A05 |
| [dos-prevention.md](dos-prevention.md) | Rate limiting, resource bounds, algorithmic complexity | — |
| [prompt-injection.md](prompt-injection.md) | LLM security, data/instruction separation | — |
| [deserialization.md](deserialization.md) | Untrusted data deserialization, pickle, yaml | A08 |
| [race-conditions.md](race-conditions.md) | TOCTOU, atomic check-and-act, database locks | — |
| [open-redirect.md](open-redirect.md) | URL validation, OAuth redirect URI | A01 |
### Client-Side Security
| File | Topic | OWASP 2025 |
|------|-------|------------|
| [csp.md](csp.md) | Content Security Policy, nonces, hashes | A05 |
| [cors.md](cors.md) | Origin validation, credential handling | A01 |
| [clickjacking.md](clickjacking.md) | X-Frame-Options, frame-ancestors | A01 |
### Application Logic
| File | Topic | OWASP 2025 |
|------|-------|------------|
| [race-conditions.md](race-conditions.md) | TOCTOU, atomic check-and-act, database locks | — |
| [dos-prevention.md](dos-prevention.md) | Rate limiting, resource bounds, algorithmic complexity | — |
| [file-upload.md](file-upload.md) | Content validation, safe storage, malware scanning | A04 |
### AI/LLM Security
| File | Topic | OWASP 2025 |
|------|-------|------------|
| [prompt-injection.md](prompt-injection.md) | LLM security, data/instruction separation | — |
### Infrastructure
@@ -51,18 +70,18 @@ Based on OWASP Top 10:2025 and recent security research.
## OWASP Top 10:2025 Coverage
| # | Category | Pattern |
|---|----------|---------|
| A01 | Broken Access Control | authorization.md, cors.md |
| A02 | Security Misconfiguration | secure-defaults.md |
| A03 | Software Supply Chain Failures | supply-chain.md |
| A04 | Cryptographic Failures | cryptography.md |
| A05 | Injection | injection-prevention.md, xxe.md |
| A06 | Insecure Design | secure-defaults.md |
| A07 | Authentication Failures | authentication.md, jwt-security.md, session-management.md |
| A08 | Software or Data Integrity Failures | deserialization.md |
| A09 | Security Logging and Alerting Failures | audit-logging.md |
| A10 | Mishandling of Exceptional Conditions | error-handling.md, ssrf.md |
| # | Category | Patterns |
|---|----------|----------|
| A01 | Broken Access Control | authorization, cors, clickjacking, open-redirect |
| A02 | Security Misconfiguration | secure-defaults |
| A03 | Software Supply Chain Failures | supply-chain |
| A04 | Cryptographic Failures | cryptography, file-upload |
| A05 | Injection | injection-prevention, xxe, csp |
| A06 | Insecure Design | secure-defaults |
| A07 | Authentication Failures | authentication, jwt-security, session-management |
| A08 | Software or Data Integrity Failures | deserialization |
| A09 | Security Logging and Alerting Failures | audit-logging |
| A10 | Mishandling of Exceptional Conditions | error-handling, ssrf |
## Sources
+174
View File
@@ -0,0 +1,174 @@
# Clickjacking
## Rule
Set X-Frame-Options or frame-ancestors CSP. Prevent your site from being embedded in attacker frames.
**Source:** [OWASP Clickjacking Defense Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html)
## How Clickjacking Works
1. Attacker creates page with invisible iframe containing your site
2. Attacker overlays convincing UI elements
3. User thinks they're clicking attacker's button
4. Actually clicking your site's button (delete, transfer, etc.)
```html
<!-- Attacker's page -->
<style>
iframe {
opacity: 0;
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
z-index: 2;
}
.fake-button {
position: absolute;
top: 200px; left: 300px; /* Aligned with real button */
z-index: 1;
}
</style>
<div class="fake-button">Click to win a prize!</div>
<iframe src="https://bank.com/transfer?to=attacker&amount=10000"></iframe>
```
## Correct Pattern
```python
# Option 1: X-Frame-Options header (legacy, still works)
@app.after_request
def add_frame_options(response):
response.headers["X-Frame-Options"] = "DENY"
# Or "SAMEORIGIN" to allow same-origin framing
return response
# Option 2: CSP frame-ancestors (modern, more flexible)
@app.after_request
def add_csp(response):
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
# Or "frame-ancestors 'self'" for same-origin
# Or "frame-ancestors 'self' https://trusted.com" for specific sites
return response
# Option 3: Both (for browser compatibility)
@app.after_request
def add_framing_protection(response):
response.headers["X-Frame-Options"] = "DENY"
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
return response
```
## Incorrect Pattern
```python
# Wrong: no framing protection at all
# (missing headers)
# Wrong: JavaScript frame-busting only
# Can be bypassed with sandbox attribute
"""
<script>
if (top !== self) {
top.location = self.location;
}
</script>
"""
# Bypassed by: <iframe src="bank.com" sandbox="allow-forms"></iframe>
# Wrong: ALLOWALL (defeats the purpose)
response.headers["X-Frame-Options"] = "ALLOWALL"
# Wrong: checking via JavaScript after load
# Attacker can disable JS or race the check
```
## When Framing IS Needed
```python
# If you need to allow specific partners to embed:
ALLOWED_FRAME_ANCESTORS = ["https://partner1.com", "https://partner2.com"]
@app.after_request
def conditional_framing(response):
# Pages that should never be framed
if request.path.startswith("/admin") or request.path.startswith("/settings"):
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
# Embeddable widgets
elif request.path.startswith("/embed/"):
ancestors = " ".join(ALLOWED_FRAME_ANCESTORS)
response.headers["Content-Security-Policy"] = f"frame-ancestors {ancestors}"
# Default: same-origin only
else:
response.headers["Content-Security-Policy"] = "frame-ancestors 'self'"
return response
```
## Double-Framing Defense
```python
# Attacker might try: evil.com -> trusted.com -> your-site.com
# frame-ancestors 'self' https://trusted.com would allow this!
# Defense: Only allow direct framing
@app.after_request
def strict_framing(response):
# Check if request came from an allowed embedder
# Note: Referer can be spoofed, this is defense-in-depth
referer = request.headers.get("Referer", "")
if is_embed_request(request):
if not any(referer.startswith(a) for a in ALLOWED_FRAME_ANCESTORS):
response.headers["Content-Security-Policy"] = "frame-ancestors 'none'"
return response
# Also set on response so browsers enforce
ancestors = " ".join(ALLOWED_FRAME_ANCESTORS)
response.headers["Content-Security-Policy"] = f"frame-ancestors {ancestors}"
return response
```
## Sensitive Actions
```python
# Clickjacking is most dangerous for state-changing actions
# Add extra protection for these:
def require_confirmation(f):
"""Require explicit confirmation for sensitive actions."""
@wraps(f)
def decorated(*args, **kwargs):
# Require POST with CSRF token
if request.method != "POST":
abort(405)
# Verify CSRF
if not validate_csrf_token(request.form.get("csrf_token")):
abort(403)
# Optional: require re-authentication for very sensitive actions
# Optional: add CAPTCHA
return f(*args, **kwargs)
return decorated
@app.route("/account/delete", methods=["POST"])
@require_confirmation
def delete_account():
# Clickjacking can't easily bypass POST + CSRF
pass
```
## Edge Cases
- Mobile apps using WebViews may legitimately embed your site
- PDF embedding (`<embed>`, `<object>`) not covered by frame-ancestors
- Legacy IE doesn't support CSP frame-ancestors, needs X-Frame-Options
- frame-ancestors must be in HTTP header, not `<meta>` tag
- Cursorjacking: manipulating cursor position (similar attack)
- Likejacking: clicking social media Like buttons
+166
View File
@@ -0,0 +1,166 @@
# Content Security Policy (CSP)
## Rule
Define strict CSP to prevent XSS. Start restrictive, loosen only as needed. Never use `unsafe-inline` for scripts.
**Source:** [MDN Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
## CSP Directives
| Directive | Controls |
|-----------|----------|
| `default-src` | Fallback for all resource types |
| `script-src` | JavaScript sources |
| `style-src` | CSS sources |
| `img-src` | Image sources |
| `connect-src` | XHR, fetch, WebSocket |
| `frame-src` | iframe sources |
| `frame-ancestors` | Who can embed this page |
| `form-action` | Form submission targets |
| `base-uri` | `<base>` tag restrictions |
## Correct Pattern
```python
# Strict CSP with nonces (recommended)
import secrets
def generate_csp_nonce() -> str:
return secrets.token_urlsafe(16)
def get_csp_header(nonce: str) -> str:
"""Generate strict CSP header."""
return "; ".join([
"default-src 'self'",
f"script-src 'nonce-{nonce}' 'strict-dynamic'",
"style-src 'self' 'nonce-{nonce}'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self' https://api.example.com",
"frame-ancestors 'none'",
"form-action 'self'",
"base-uri 'self'",
"upgrade-insecure-requests",
])
@app.after_request
def add_security_headers(response):
nonce = generate_csp_nonce()
g.csp_nonce = nonce # Make available to templates
response.headers["Content-Security-Policy"] = get_csp_header(nonce)
return response
# In template:
# <script nonce="{{ g.csp_nonce }}">...</script>
```
## Incorrect Pattern
```python
# Wrong: unsafe-inline allows XSS
csp = "script-src 'self' 'unsafe-inline'"
# Wrong: unsafe-eval allows eval()
csp = "script-src 'self' 'unsafe-eval'"
# Wrong: wildcard allows any source
csp = "script-src *"
# Wrong: no CSP at all
# (missing header)
# Wrong: report-only without enforcement
# Use for testing, but deploy with enforcement
response.headers["Content-Security-Policy-Report-Only"] = csp
# ^ Only reports, doesn't block!
# Wrong: data: in script-src
csp = "script-src 'self' data:"
# Attacker can inject: <script src="data:text/javascript,alert(1)">
```
## Hash-Based CSP (Alternative to Nonces)
```python
import hashlib
import base64
def script_hash(script_content: str) -> str:
"""Generate CSP hash for inline script."""
digest = hashlib.sha256(script_content.encode()).digest()
return f"'sha256-{base64.b64encode(digest).decode()}'"
# For static inline scripts that don't change:
INLINE_SCRIPT = "console.log('hello');"
SCRIPT_HASH = script_hash(INLINE_SCRIPT)
csp = f"script-src 'self' {SCRIPT_HASH}"
```
## CSP for Single Page Apps
```python
# SPAs often need looser CSP for dynamic content
def spa_csp(nonce: str) -> str:
return "; ".join([
"default-src 'self'",
# strict-dynamic allows scripts loaded by nonced scripts
f"script-src 'nonce-{nonce}' 'strict-dynamic'",
# SPAs often need blob: for web workers
"worker-src 'self' blob:",
# For inline styles from JS frameworks
f"style-src 'self' 'nonce-{nonce}'",
# API calls
"connect-src 'self' https://api.example.com wss://ws.example.com",
"frame-ancestors 'none'",
"base-uri 'self'",
])
```
## CSP Reporting
```python
def csp_with_reporting(nonce: str) -> str:
"""CSP with violation reporting."""
policy = get_csp_header(nonce)
# Add reporting endpoint
policy += "; report-uri /csp-report"
# Or use newer report-to directive
policy += "; report-to csp-endpoint"
return policy
@app.route("/csp-report", methods=["POST"])
def csp_report():
"""Receive CSP violation reports."""
report = request.get_json(force=True)
log.warning("CSP violation", extra={
"blocked_uri": report.get("blocked-uri"),
"violated_directive": report.get("violated-directive"),
"document_uri": report.get("document-uri"),
})
return "", 204
```
## Gradual Rollout
```python
# Step 1: Report-only to find issues
response.headers["Content-Security-Policy-Report-Only"] = strict_csp
# Step 2: After fixing violations, enforce
response.headers["Content-Security-Policy"] = strict_csp
# Step 3: Keep report-only for new restrictions
response.headers["Content-Security-Policy"] = current_csp
response.headers["Content-Security-Policy-Report-Only"] = stricter_csp
```
## Edge Cases
- Third-party scripts (analytics, widgets) need explicit sources
- Inline event handlers (`onclick`) blocked by default — use addEventListener
- `style` attribute blocked without `'unsafe-inline'` in `style-src`
- PDF plugins may need `object-src`
- Browser extensions can trigger CSP violations (ignore in reports)
- `frame-ancestors` doesn't work in `<meta>` tag — must be HTTP header
+205
View File
@@ -0,0 +1,205 @@
# File Upload Security
## Rule
Validate content, not just extension. Store outside webroot. Generate new filenames. Set size limits.
**Source:** [OWASP File Upload Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html)
## Attack Vectors
| Attack | Description |
|--------|-------------|
| Web shell | Upload .php/.jsp that executes commands |
| XSS via SVG | SVG with embedded JavaScript |
| XXE via Office | DOCX/XLSX contain XML |
| Path traversal | Filename like `../../../etc/cron.d/shell` |
| DoS | Upload huge files, exhaust disk |
| Malware hosting | Use your server to distribute malware |
## Correct Pattern
```python
import os
import uuid
import magic # python-magic for content detection
from pathlib import Path
UPLOAD_DIR = Path("/var/uploads") # Outside webroot!
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
ALLOWED_TYPES = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"application/pdf": ".pdf",
}
def save_upload(file_storage) -> str:
"""Safely handle file upload."""
# Check size first (before reading into memory)
file_storage.seek(0, 2) # Seek to end
size = file_storage.tell()
file_storage.seek(0) # Reset
if size > MAX_FILE_SIZE:
raise ValueError("File too large")
# Read content for validation
content = file_storage.read()
file_storage.seek(0)
# Detect MIME type from content, not extension
detected_type = magic.from_buffer(content, mime=True)
if detected_type not in ALLOWED_TYPES:
raise ValueError(f"File type not allowed: {detected_type}")
# Generate safe filename (never use user input)
extension = ALLOWED_TYPES[detected_type]
safe_filename = f"{uuid.uuid4()}{extension}"
# Store outside webroot
dest_path = UPLOAD_DIR / safe_filename
# Ensure we're still in upload dir (paranoid check)
if not dest_path.resolve().is_relative_to(UPLOAD_DIR.resolve()):
raise ValueError("Invalid path")
with open(dest_path, "wb") as f:
f.write(content)
return safe_filename
def serve_upload(filename: str):
"""Serve uploaded file safely."""
# Validate filename format
if not filename or ".." in filename or "/" in filename:
raise ValueError("Invalid filename")
path = UPLOAD_DIR / filename
# Verify path is within upload dir
if not path.resolve().is_relative_to(UPLOAD_DIR.resolve()):
raise ValueError("Invalid path")
if not path.exists():
raise FileNotFoundError()
# Serve with safe content-type
return send_file(
path,
mimetype="application/octet-stream", # Force download
as_attachment=True,
download_name=filename
)
```
## Incorrect Pattern
```python
import os
# Wrong: using user-provided filename
def bad_upload(file):
filename = file.filename # User controlled!
file.save(f"/uploads/{filename}")
# Attack: filename = "../../../var/www/shell.php"
# Wrong: checking only extension
def bad_validate(filename):
return filename.endswith((".jpg", ".png"))
# Attack: shell.php.jpg with PHP content
# Wrong: storing in webroot
def bad_upload_2(file):
file.save(f"/var/www/html/uploads/{file.filename}")
# Attacker can access directly, execute scripts
# Wrong: trusting Content-Type header
def bad_validate_2(file):
return file.content_type.startswith("image/")
# Header is attacker-controlled!
# Wrong: no size limit
def bad_upload_3(file):
file.save(f"/uploads/{uuid.uuid4()}")
# DoS: upload 100GB file
```
## Image-Specific Validation
```python
from PIL import Image
import io
MAX_IMAGE_PIXELS = 4096 * 4096 # Prevent decompression bomb
def validate_image(content: bytes) -> bool:
"""Validate image content."""
try:
Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
img = Image.open(io.BytesIO(content))
# Actually load the image (validates structure)
img.verify()
# Reopen for further checks (verify() invalidates)
img = Image.open(io.BytesIO(content))
# Check format
if img.format not in ("JPEG", "PNG", "GIF"):
return False
# Strip EXIF (can contain sensitive data, XSS in some viewers)
# PIL's save() with specific format strips most metadata
return True
except Exception:
return False
def strip_image_metadata(content: bytes) -> bytes:
"""Remove EXIF and other metadata."""
img = Image.open(io.BytesIO(content))
# Create new image without metadata
output = io.BytesIO()
img.save(output, format=img.format)
return output.getvalue()
```
## Antivirus Scanning
```python
import clamd # ClamAV client
def scan_for_malware(filepath: str) -> bool:
"""Scan file with ClamAV."""
try:
cd = clamd.ClamdUnixSocket()
result = cd.scan(filepath)
if result is None:
return True # Clean
# result = {filepath: ('FOUND', 'Malware.Name')}
status, name = result.get(filepath, (None, None))
if status == "FOUND":
log.warning("Malware detected", filepath=filepath, malware=name)
os.remove(filepath)
return False
return True
except Exception as e:
log.error("Antivirus scan failed", error=str(e))
return False # Fail closed
```
## Edge Cases
- Double extensions: `file.php.jpg` may execute as PHP on misconfigured servers
- Null byte: `file.php%00.jpg` truncates to `file.php` in some languages
- Case sensitivity: `.PhP` may execute on Windows
- SVG can contain JavaScript — treat as dangerous
- ZIP files need recursive scanning for zip bombs
- Office files (DOCX) are ZIPs containing XML — check for XXE
- GIF89a header with PHP code can execute on some servers
+188
View File
@@ -0,0 +1,188 @@
# Open Redirect
## Rule
Never redirect to user-controlled URLs. Validate against allowlist of destinations.
**Source:** [CWE-601: URL Redirection to Untrusted Site](https://cwe.mitre.org/data/definitions/601.html)
## Why It's Dangerous
- **Phishing**: Victim trusts your domain, clicks link, lands on attacker site
- **OAuth token theft**: Redirect URI manipulation steals auth codes
- **Credential harvesting**: Fake login page after "session expired" redirect
- **Malware distribution**: Your domain reputation used to bypass filters
## Correct Pattern
```python
from urllib.parse import urlparse, urljoin
ALLOWED_HOSTS = {"example.com", "app.example.com"}
ALLOWED_PATHS = {"/dashboard", "/profile", "/settings"}
def safe_redirect(url: str, default: str = "/") -> str:
"""Validate redirect URL, return safe destination."""
if not url:
return default
# Parse the URL
parsed = urlparse(url)
# Option 1: Only allow relative paths (safest)
if parsed.netloc:
# Has a host component - reject external URLs
return default
# Ensure path doesn't escape (e.g., //evil.com)
if url.startswith("//"):
return default
# Validate path against allowlist (if applicable)
if ALLOWED_PATHS and parsed.path not in ALLOWED_PATHS:
return default
return url
def safe_redirect_with_hosts(url: str, default: str = "/") -> str:
"""Allow specific external hosts."""
if not url:
return default
parsed = urlparse(url)
# Relative URL - safe
if not parsed.netloc:
if url.startswith("//"):
return default
return url
# External URL - check allowlist
if parsed.scheme not in ("http", "https"):
return default
if parsed.netloc not in ALLOWED_HOSTS:
return default
return url
@app.route("/login")
def login():
next_url = request.args.get("next", "/dashboard")
# ... authenticate user ...
return redirect(safe_redirect(next_url))
```
## Incorrect Pattern
```python
# Wrong: direct redirect from parameter
@app.route("/redirect")
def bad_redirect():
url = request.args.get("url")
return redirect(url) # Attacker: ?url=https://evil.com
# Wrong: checking only prefix
def bad_validate(url):
return url.startswith("https://example.com")
# Bypassed by: https://example.com.evil.com
# Wrong: checking only domain presence
def bad_validate_2(url):
return "example.com" in url
# Bypassed by: https://evil.com/example.com
# Wrong: using path join incorrectly
def bad_redirect_2(path):
base = "https://example.com"
return redirect(urljoin(base, path))
# urljoin("https://example.com", "//evil.com") = "https://evil.com"
# Wrong: trusting Referer header
@app.route("/back")
def go_back():
return redirect(request.referrer) # Attacker-controlled!
```
## Bypass Techniques
```python
# Common bypass attempts to defend against:
bypasses = [
"//evil.com", # Protocol-relative
"https://evil.com", # Absolute URL
"//evil.com/example.com", # Domain in path
"https://example.com@evil.com", # Userinfo
"https://example.com.evil.com", # Subdomain
"/\\evil.com", # Backslash
"/%09/evil.com", # Tab character
"/%0d/evil.com", # Carriage return
"https:evil.com", # Missing slashes
"javascript:alert(1)", # JavaScript URI
"data:text/html,<script>", # Data URI
"\x00https://evil.com", # Null byte
]
def robust_validate(url: str) -> bool:
"""Defend against common bypasses."""
if not url:
return False
# Normalize
url = url.strip()
# Block dangerous schemes
lower = url.lower()
if any(lower.startswith(s) for s in ["javascript:", "data:", "vbscript:"]):
return False
# Block protocol-relative
if url.startswith("//"):
return False
# Block backslash tricks
if "\\" in url:
return False
# Block whitespace in scheme
if any(c in url[:10] for c in "\t\r\n"):
return False
# Only allow relative paths
parsed = urlparse(url)
if parsed.scheme or parsed.netloc:
return False
return True
```
## OAuth Redirect URI
```python
# OAuth redirect URIs need EXACT matching
REGISTERED_REDIRECT_URIS = {
"https://app.example.com/oauth/callback",
"https://app.example.com/auth/complete",
}
def validate_redirect_uri(uri: str) -> bool:
"""Exact match only - no partial matching!"""
return uri in REGISTERED_REDIRECT_URIS
# Wrong approaches:
def bad_oauth_validate(uri):
return uri.startswith("https://app.example.com/")
# Attacker: https://app.example.com/oauth/callback/../../../evil
# After normalization: still under app.example.com but different path
```
## Edge Cases
- URL encoding: `%2f` decoded to `/` after validation
- Case sensitivity: `HTTPS://EXAMPLE.COM` vs `https://example.com`
- IPv6 URLs: `http://[::1]/`
- Port numbers: `https://example.com:443` vs `https://example.com`
- Fragment identifiers: `#` portions not sent to server but affect client
- Meta refresh: `<meta http-equiv="refresh" content="0;url=evil.com">`
- JavaScript redirects: `window.location = userInput`