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 |
|
||||
| [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
@@ -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