Files
Rodin 1eac5d3bcc 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.
2026-05-10 23:24:52 -07:00

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

  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.)
<!-- 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