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:
@@ -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 |
|
| [jwt-security.md](jwt-security.md) | Algorithm confusion, weak secrets, expiration | A07 |
|
||||||
| [session-management.md](session-management.md) | Session fixation, hijacking, secure cookies | A07 |
|
| [session-management.md](session-management.md) | Session fixation, hijacking, secure cookies | A07 |
|
||||||
|
|
||||||
### Attack Prevention
|
### Injection & Request Attacks
|
||||||
|
|
||||||
| File | Topic | OWASP 2025 |
|
| File | Topic | OWASP 2025 |
|
||||||
|------|-------|------------|
|
|------|-------|------------|
|
||||||
| [injection-prevention.md](injection-prevention.md) | SQL, command, template, path traversal | A05 |
|
| [injection-prevention.md](injection-prevention.md) | SQL, command, template, path traversal | A05 |
|
||||||
| [ssrf.md](ssrf.md) | Server-side request forgery, metadata endpoints | A10 |
|
| [ssrf.md](ssrf.md) | Server-side request forgery, metadata endpoints | A10 |
|
||||||
| [xxe.md](xxe.md) | XML external entities, DTD attacks | A05 |
|
| [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 |
|
| [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 |
|
| [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
|
### Infrastructure
|
||||||
|
|
||||||
@@ -51,18 +70,18 @@ Based on OWASP Top 10:2025 and recent security research.
|
|||||||
|
|
||||||
## OWASP Top 10:2025 Coverage
|
## OWASP Top 10:2025 Coverage
|
||||||
|
|
||||||
| # | Category | Pattern |
|
| # | Category | Patterns |
|
||||||
|---|----------|---------|
|
|---|----------|----------|
|
||||||
| A01 | Broken Access Control | authorization.md, cors.md |
|
| A01 | Broken Access Control | authorization, cors, clickjacking, open-redirect |
|
||||||
| A02 | Security Misconfiguration | secure-defaults.md |
|
| A02 | Security Misconfiguration | secure-defaults |
|
||||||
| A03 | Software Supply Chain Failures | supply-chain.md |
|
| A03 | Software Supply Chain Failures | supply-chain |
|
||||||
| A04 | Cryptographic Failures | cryptography.md |
|
| A04 | Cryptographic Failures | cryptography, file-upload |
|
||||||
| A05 | Injection | injection-prevention.md, xxe.md |
|
| A05 | Injection | injection-prevention, xxe, csp |
|
||||||
| A06 | Insecure Design | secure-defaults.md |
|
| A06 | Insecure Design | secure-defaults |
|
||||||
| A07 | Authentication Failures | authentication.md, jwt-security.md, session-management.md |
|
| A07 | Authentication Failures | authentication, jwt-security, session-management |
|
||||||
| A08 | Software or Data Integrity Failures | deserialization.md |
|
| A08 | Software or Data Integrity Failures | deserialization |
|
||||||
| A09 | Security Logging and Alerting Failures | audit-logging.md |
|
| A09 | Security Logging and Alerting Failures | audit-logging |
|
||||||
| A10 | Mishandling of Exceptional Conditions | error-handling.md, ssrf.md |
|
| A10 | Mishandling of Exceptional Conditions | error-handling, ssrf |
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
|
|||||||
+174
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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`
|
||||||
Reference in New Issue
Block a user