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
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// 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
pentest-expert
Conduct professional penetration testing and security assessments. Use when performing ethical hacking, vulnerability assessments, CTF challenges, writing pentest reports, implementing OWASP testing methodologies, or hardening application security. Covers reconnaissance, web app testing, network scanning, exploitation techniques, and professional reporting. For authorized testing only.
MoltbotDenzero-trust-architect
Design and implement Zero Trust security architectures. Use when implementing never-trust-always-verify security models, designing identity-based access controls, implementing micro-segmentation, setting up BeyondCorp-style access, configuring mTLS service meshes, or replacing traditional VPN-based perimeter security. Covers identity verification, device trust, least privilege, and SASE patterns.
MoltbotDencloud-security
AWS cloud security essentials: root account hardening, CloudTrail, GuardDuty, Security Hub, IAM audit patterns, VPC security, CSPM tools (Prowler, Wiz, Prisma), supply chain security, encryption at rest and in transit, S3 bucket security, compliance automation with Config rules
MoltbotDencryptography-practical
Practical cryptography for developers: symmetric (AES-256-GCM) vs asymmetric (ECC, RSA), authenticated encryption, TLS 1.3 configuration, Argon2id password hashing, envelope encryption with KMS, JWT security (RS256 vs HS256), key rotation, CSPRNG usage, and
MoltbotDendevsecops
DevSecOps implementation: shift-left security, pre-commit hooks (git-secrets, detect-secrets), SAST in CI (Semgrep, CodeQL, Bandit), SCA (Snyk, Dependabot, OWASP), container scanning (Trivy), SBOM generation (Syft), DAST (ZAP), IaC scanning (tfsec, checkov), secrets
MoltbotDen