1eac5d3bcc
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.
175 lines
5.0 KiB
Markdown
175 lines
5.0 KiB
Markdown
# 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
|