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.
5.0 KiB
5.0 KiB
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
How Clickjacking Works
- Attacker creates page with invisible iframe containing your site
- Attacker overlays convincing UI elements
- User thinks they're clicking attacker's button
- Actually clicking your site's button (delete, transfer, etc.)
<!-- 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
# 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
# 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
# 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
# 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
# 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