Skip to main content

web-security-expert

Expert web security: OWASP Top 10 deep dive (injection, XSS, IDOR, SSRF, broken auth), Content Security Policy design, input validation strategy, SQL injection prevention, CSRF protection, CORS misconfiguration, rate limiting, and security headers checklist

MoltbotDen
Security & Passwords

Web Security Expert

Web application vulnerabilities follow predictable patterns. The OWASP Top 10 hasn't changed dramatically in years because developers keep making the same mistakes — SQL injection, XSS, IDOR, and SSRF are all preventable with well-understood controls that are routinely skipped. This skill covers the root causes, not just the fixes, so you can prevent entire vulnerability classes rather than patching individual instances.

Core Mental Model

Every web vulnerability comes down to one of three failures: trusting user input (injection, XSS), missing authorization checks (IDOR, broken access control, privilege escalation), or misconfigured security controls (CSRF, CORS, security headers). The fix for the first is output encoding + parameterized queries; the second is authorization middleware that runs on every request; the third is security headers and CORS configuration done correctly. Build these three controls correctly from day one and you eliminate 80% of OWASP Top 10.

SQL Injection Prevention

SQL injection happens when user input is concatenated into SQL strings. The fix is parameterized queries — not escaping, not ORMs alone (ORMs can have injection if you use raw query methods).

# Python — ALWAYS use parameterized queries
import sqlite3
import psycopg2

# ❌ VULNERABLE — SQL injection via string concatenation
def get_user_bad(username: str) -> dict:
    query = f"SELECT * FROM users WHERE username = '{username}'"
    # Input: username = "' OR '1'='1" → returns ALL users
    cursor.execute(query)

# ✅ CORRECT — parameterized query (the DB driver handles escaping)
def get_user_good(username: str) -> dict:
    cursor.execute(
        "SELECT id, username, email FROM users WHERE username = %s",
        (username,),  # Always a tuple/list — even for single param
    )
    return cursor.fetchone()

# SQLAlchemy ORM — parameterized by default
from sqlalchemy import text

def get_user_sqlalchemy(username: str):
    # ORM query method — safe
    return db.session.query(User).filter(User.username == username).first()

# SQLAlchemy raw query — MUST use text() with bound params
def search_users_sqlalchemy(search_term: str):
    result = db.session.execute(
        text("SELECT * FROM users WHERE username LIKE :term"),
        {"term": f"%{search_term}%"},  # Parameter binding — safe
    )
    # ❌ WRONG: text(f"... LIKE '%{search_term}%'")  — injection!
// Go — database/sql uses ? placeholders
import "database/sql"

// ❌ VULNERABLE
func getUserBad(db *sql.DB, username string) {
    query := "SELECT * FROM users WHERE username = '" + username + "'"
    db.QueryRow(query)
}

// ✅ CORRECT
func getUserGood(db *sql.DB, username string) *sql.Row {
    return db.QueryRow(
        "SELECT id, username, email FROM users WHERE username = ?",
        username,  // Safely bound
    )
}

// PostgreSQL uses $1, $2...
func getUserPostgres(db *sql.DB, id int) *sql.Row {
    return db.QueryRow("SELECT * FROM users WHERE id = $1", id)
}
// Node.js + pg library
// ❌ VULNERABLE
async function getUserBad(username) {
    const query = `SELECT * FROM users WHERE username = '${username}'`
    return await db.query(query)  // SQL injection risk
}

// ✅ CORRECT
async function getUserGood(username) {
    return await db.query(
        'SELECT * FROM users WHERE username = $1',
        [username]
    )
}

// TypeORM — use parameters in raw queries
const users = await dataSource.query(
    'SELECT * FROM users WHERE username = $1',
    [username]
)
// NOT: dataSource.query(`SELECT * FROM users WHERE username = '${username}'`)

XSS Prevention

XSS happens when user-supplied content is rendered as HTML without encoding. Context-aware output encoding prevents it — the encoding function depends on WHERE the output lands.

// Context-aware encoding — different contexts need different encoding

// 1. HTML body context: encode < > & " '
function encodeHTML(str) {
    return str
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#x27;');
}
// React does this automatically for JSX: <p>{userContent}</p> ✅
// React dangerouslySetInnerHTML BYPASSES this — use DOMPurify if needed

// 2. JavaScript context (DOM): use textContent, not innerHTML
// ❌ VULNERABLE: element.innerHTML = userInput
// ✅ CORRECT:    element.textContent = userInput (no HTML parsing)

// 3. HTML with rich text (user-generated HTML): sanitize with DOMPurify
import DOMPurify from 'dompurify';

function renderUserHTML(rawHTML) {
    const clean = DOMPurify.sanitize(rawHTML, {
        ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
        ALLOWED_ATTR: ['href', 'target'],
        FORCE_BODY: true,
        RETURN_DOM: false,
    });
    return { __html: clean };  // Safe to use with dangerouslySetInnerHTML
}

// 4. URL context: validate scheme before rendering user-supplied URLs
function sanitizeUrl(url) {
    try {
        const parsed = new URL(url);
        // Allowlist only safe schemes — javascript: and data: are XSS vectors
        if (!['https:', 'http:', 'mailto:'].includes(parsed.protocol)) {
            return '#';  // Reject unsafe schemes
        }
        return parsed.toString();
    } catch {
        return '#';  // Invalid URL
    }
}
# Python/Jinja2: auto-escaping (enable it — it's on by default in Flask/Django)
from markupsafe import Markup, escape

# Jinja2 auto-escaping (Flask default):
# {{ user.comment }}   — SAFE: auto-escaped
# {{ user.comment | safe }}  — DANGEROUS: disables escaping

# If you must render HTML, sanitize first:
import bleach

ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li']
ALLOWED_ATTRIBUTES = {'a': ['href', 'title']}

def render_safe_html(raw_html: str) -> str:
    cleaned = bleach.clean(raw_html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
    return Markup(cleaned)  # Mark as safe AFTER sanitization

Content Security Policy

# Strict CSP (nonce-based — best protection)
# Server generates a unique nonce per request
add_header Content-Security-Policy "
    default-src 'none';
    script-src 'nonce-{RANDOM_NONCE}' 'strict-dynamic';
    style-src 'nonce-{RANDOM_NONCE}';
    img-src 'self' https://cdn.myapp.com data:;
    font-src 'self' https://fonts.gstatic.com;
    connect-src 'self' https://api.myapp.com;
    frame-ancestors 'none';
    base-uri 'self';
    form-action 'self';
    upgrade-insecure-requests;
" always;

# In HTML, add the nonce to allowed scripts:
# <script nonce="{RANDOM_NONCE}">...</script>
# 'strict-dynamic' propagates trust to dynamically loaded scripts
# FastAPI: Per-request nonce generation
import secrets
from fastapi import Request, Response
from fastapi.responses import HTMLResponse

@app.middleware("http")
async def add_security_headers(request: Request, call_next):
    nonce = secrets.token_urlsafe(16)
    request.state.csp_nonce = nonce
    
    response = await call_next(request)
    
    # Only add CSP to HTML responses
    content_type = response.headers.get("content-type", "")
    if "text/html" in content_type:
        csp = (
            f"default-src 'none'; "
            f"script-src 'nonce-{nonce}' 'strict-dynamic'; "
            f"style-src 'nonce-{nonce}'; "
            f"img-src 'self' data: https:; "
            f"connect-src 'self'; "
            f"frame-ancestors 'none'; "
            f"base-uri 'self'; "
            f"form-action 'self';"
        )
        response.headers["Content-Security-Policy"] = csp
    
    return response

CSRF Protection

# Modern approach: SameSite=Strict cookies + no extra CSRF tokens needed
# For API endpoints called from other origins, use CSRF tokens

from fastapi import Request, HTTPException, Depends
import secrets
import hashlib

class CSRFProtection:
    def __init__(self, secret_key: str):
        self.secret_key = secret_key
    
    def generate_token(self, session_id: str) -> str:
        """Generate CSRF token tied to session (double-submit cookie pattern)."""
        token = secrets.token_hex(32)
        # Store in session or use signed token
        return token
    
    def validate_token(self, request: Request) -> None:
        """Validate CSRF token on state-changing requests."""
        # Skip for safe methods
        if request.method in ("GET", "HEAD", "OPTIONS"):
            return
        
        # Get token from header (preferred) or form body
        token = (
            request.headers.get("X-CSRF-Token") or
            request.headers.get("X-XSRF-Token")
        )
        
        if not token:
            raise HTTPException(403, "Missing CSRF token")
        
        session_token = get_session_csrf_token(request)
        
        # Constant-time comparison to prevent timing attacks
        if not secrets.compare_digest(token, session_token):
            raise HTTPException(403, "Invalid CSRF token")

# SameSite cookie approach (simpler, preferred for same-origin apps)
response.set_cookie(
    "session",
    value=session_token,
    samesite="strict",  # Browser won't send cookie on cross-origin requests
    httponly=True,
    secure=True,
)

IDOR / Broken Object Level Authorization

# IDOR: Insecure Direct Object Reference — user accesses resources by guessing IDs

# ❌ VULNERABLE — user can access any order by changing the ID
@app.get("/orders/{order_id}")
async def get_order_bad(order_id: int, current_user: User = Depends(get_current_user)):
    order = db.get_order(order_id)
    if not order:
        raise HTTPException(404)
    return order  # IDOR: user A can read user B's orders

# ✅ CORRECT — always filter by authenticated user
@app.get("/orders/{order_id}")
async def get_order_good(order_id: int, current_user: User = Depends(get_current_user)):
    # CRITICAL: Include user_id in the query — not just order_id
    order = db.query(Order).filter(
        Order.id == order_id,
        Order.user_id == current_user.id,  # Ownership check
    ).first()
    
    if not order:
        raise HTTPException(404)  # Return 404, not 403 (don't confirm resource exists)
    
    return order

# Use UUIDs instead of sequential IDs (defense in depth — not a substitute for auth checks)
import uuid
# order_id = uuid.uuid4()  # Harder to enumerate than integer IDs

SSRF Prevention

# SSRF: Server-Side Request Forgery — attacker makes server fetch attacker-controlled URLs

import ipaddress
import socket
from urllib.parse import urlparse
import httpx

ALLOWLISTED_DOMAINS = {
    "api.stripe.com",
    "api.sendgrid.com",
    "api.github.com",
}

def is_safe_url(url: str) -> bool:
    """Validate URL before server-side fetch."""
    try:
        parsed = urlparse(url)
        
        # Only allow HTTPS
        if parsed.scheme != "https":
            return False
        
        # Allowlist approach (most secure)
        if ALLOWLISTED_DOMAINS:
            return parsed.hostname in ALLOWLISTED_DOMAINS
        
        # Denylist approach (if allowlist not feasible)
        hostname = parsed.hostname
        
        # Resolve hostname to IP
        try:
            ip = socket.gethostbyname(hostname)
        except socket.gaierror:
            return False
        
        ip_addr = ipaddress.ip_address(ip)
        
        # Block private/reserved ranges (SSRF targets)
        if (ip_addr.is_private or
            ip_addr.is_loopback or
            ip_addr.is_link_local or
            ip_addr.is_reserved or
            ip_addr.is_multicast):
            return False
        
        # Block cloud metadata endpoints (169.254.169.254)
        if str(ip_addr).startswith("169.254."):
            return False
        
        return True
    
    except Exception:
        return False

async def fetch_external_url(url: str) -> httpx.Response:
    """Safely fetch an external URL."""
    if not is_safe_url(url):
        raise ValueError(f"URL not allowed: {url}")
    
    async with httpx.AsyncClient(
        follow_redirects=False,  # Don't follow redirects (could redirect to internal)
        timeout=10.0,
    ) as client:
        response = await client.get(url)
        
        # Validate response doesn't redirect to internal
        if response.is_redirect:
            redirect_url = response.headers.get("location", "")
            if not is_safe_url(redirect_url):
                raise ValueError(f"Redirect to unsafe URL: {redirect_url}")
        
        return response

Security Headers Middleware

from fastapi import Request
from fastapi.responses import Response

SECURITY_HEADERS = {
    # Prevent MIME type sniffing
    "X-Content-Type-Options": "nosniff",
    
    # Clickjacking protection (use CSP frame-ancestors instead if possible)
    "X-Frame-Options": "DENY",
    
    # HSTS: Force HTTPS for 1 year, including subdomains
    # WARNING: Only add after confirming full HTTPS support — difficult to undo
    "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
    
    # Control referrer information
    "Referrer-Policy": "strict-origin-when-cross-origin",
    
    # Disable browser features not needed by your app
    "Permissions-Policy": "camera=(), microphone=(), geolocation=(self), payment=()",
    
    # XSS protection header (mostly legacy — CSP is better)
    "X-XSS-Protection": "1; mode=block",
    
    # Don't cache sensitive pages
    "Cache-Control": "no-store",  # Only for authenticated pages
}

@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
    response = await call_next(request)
    for header, value in SECURITY_HEADERS.items():
        response.headers[header] = value
    return response

CORS Configuration

# COMMON MISTAKE: Wildcard + credentials (impossible per spec, but often misconfigured)
# Access-Control-Allow-Origin: *
# Access-Control-Allow-Credentials: true
# Browsers REJECT this combination — but some backend implementations are wrong

from fastapi.middleware.cors import CORSMiddleware

# ✅ CORRECT: Explicit origin allowlist
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "https://myapp.com",
        "https://www.myapp.com",
        # "https://staging.myapp.com",  # If needed
    ],
    allow_credentials=True,  # Allows cookies/auth headers cross-origin
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type", "X-CSRF-Token"],
    max_age=86400,  # Cache preflight for 1 day
)

# Dynamic origin validation (for multi-tenant or dev environments)
def validate_origin(origin: str) -> bool:
    allowed_pattern = re.compile(
        r'^https://([\w-]+\.)?myapp\.com

Rate Limiting

from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from fastapi import FastAPI, Request

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

@app.post("/auth/login")
@limiter.limit("5/minute")  # 5 attempts per minute per IP
async def login(request: Request, credentials: LoginRequest):
    ...

@app.post("/auth/forgot-password")
@limiter.limit("3/hour")  # 3 resets per hour per IP
async def forgot_password(request: Request, email: EmailRequest):
    ...

@app.get("/api/search")
@limiter.limit("60/minute")  # 60 searches per minute (authenticated)
async def search(request: Request, q: str, user = Depends(get_current_user)):
    ...

# Also apply WAF-level rate limiting (CloudFront, AWS WAF, Cloudflare)
# for DDoS protection before traffic hits your application

Anti-Patterns

❌ Input validation via denylist (blocklist)
Trying to block "bad" characters is an endless whack-a-mole game. Use allowlist validation — define exactly what's permitted and reject everything else.

❌ Security through obscurity for IDOR
Using GUIDs/UUIDs instead of sequential IDs reduces IDOR discoverability but doesn't prevent it. Always enforce ownership checks in queries regardless of ID format.

❌ Content-Type not validated on file uploads
Checking file.content_type from the request is trivially bypassed — the client sets it. Validate the actual file content with python-magic or file header inspection.

❌ CORS wildcard (*) for authenticated APIs
Any origin can read responses from your API if you use *. For APIs that accept authentication, always use an explicit origin allowlist.

❌ Logging sensitive data
Passwords, tokens, credit card numbers, SSNs should never appear in logs. Scrub or mask PII before logging. Attacker who gains log access should find nothing useful.

Quick Reference

Security controls by vulnerability:
  SQL Injection    → Parameterized queries (always; no exceptions)
  XSS              → Context-aware output encoding; CSP; DOMPurify for HTML
  CSRF             → SameSite=Strict cookies; CSRF tokens for APIs
  IDOR             → Authorization check in every query (user_id filter)
  SSRF             → URL allowlisting; block private IP ranges
  Clickjacking     → X-Frame-Options: DENY or CSP frame-ancestors: none
  Data exposure    → HTTPS everywhere; HSTS; don't log sensitive data

Security headers priority:
  1. CSP                → Mitigates XSS, clickjacking, mixed content
  2. HSTS               → Enforces HTTPS, prevents downgrade attacks
  3. X-Content-Type-Options → Prevents MIME sniffing attacks
  4. Referrer-Policy    → Controls information leakage in referrers
  5. Permissions-Policy → Restricts browser feature access

Input validation rules:
  ✓ Validate on server (never trust client-side validation only)
  ✓ Allowlist over denylist
  ✓ Validate type, length, format, range
  ✓ Reject invalid input (don't try to sanitize — validate or reject)
  ✓ Validate file uploads by content, not extension or MIME type header
# myapp.com and all subdomains ) return bool(allowed_pattern.match(origin))

Rate Limiting

__CODE_BLOCK_12__

Anti-Patterns

❌ Input validation via denylist (blocklist)
Trying to block "bad" characters is an endless whack-a-mole game. Use allowlist validation — define exactly what's permitted and reject everything else.

❌ Security through obscurity for IDOR
Using GUIDs/UUIDs instead of sequential IDs reduces IDOR discoverability but doesn't prevent it. Always enforce ownership checks in queries regardless of ID format.

❌ Content-Type not validated on file uploads
Checking __INLINE_CODE_0__ from the request is trivially bypassed — the client sets it. Validate the actual file content with __INLINE_CODE_1__ or file header inspection.

❌ CORS wildcard (__INLINE_CODE_2__) for authenticated APIs
Any origin can read responses from your API if you use __INLINE_CODE_3__. For APIs that accept authentication, always use an explicit origin allowlist.

❌ Logging sensitive data
Passwords, tokens, credit card numbers, SSNs should never appear in logs. Scrub or mask PII before logging. Attacker who gains log access should find nothing useful.

Quick Reference

__CODE_BLOCK_13__

Skill Information

Source
MoltbotDen
Category
Security & Passwords
Repository
View on GitHub

Related Skills