diff --git a/README.md b/README.md index 164a503..ee51d9c 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,38 @@ # Security Patterns -Scannable patterns for security code review. Each file has: -- **Rule** — what to do -- **Correct Pattern** — code that works (Python) -- **Incorrect Pattern** — common mistakes -- **Edge Cases** — gotchas +A focused security checklist for AI-assisted code review. -Based on OWASP Top 10:2025 and recent security research. +## Philosophy -## Patterns - -### Fundamentals - -| File | Topic | OWASP 2025 | -|------|-------|------------| -| [secure-defaults.md](secure-defaults.md) | Fail closed, deny by default, defense in depth | A06 | -| [input-validation.md](input-validation.md) | Allowlist > blocklist, validate at boundaries | A03 | -| [credential-handling.md](credential-handling.md) | No hardcoded secrets, environment/secret manager | — | -| [audit-logging.md](audit-logging.md) | What to log, what not to log | A09 | -| [error-handling.md](error-handling.md) | Fail closed, no sensitive info in errors | A10 | - -### Identity & Session - -| File | Topic | OWASP 2025 | -|------|-------|------------| -| [authentication.md](authentication.md) | Passwords, tokens, MFA, brute force protection | A07 | -| [authorization.md](authorization.md) | Permission checks, IDOR prevention, privilege escalation | A01 | -| [jwt-security.md](jwt-security.md) | Algorithm confusion, weak secrets, expiration | A07 | -| [session-management.md](session-management.md) | Session fixation, hijacking, secure cookies | A07 | - -### Injection & Request Attacks - -| File | Topic | OWASP 2025 | -|------|-------|------------| -| [injection-prevention.md](injection-prevention.md) | SQL, command, template, path traversal | A05 | -| [ssrf.md](ssrf.md) | Server-side request forgery, metadata endpoints | A10 | -| [xxe.md](xxe.md) | XML external entities, DTD attacks | A05 | -| [deserialization.md](deserialization.md) | Untrusted data deserialization, pickle, yaml | A08 | -| [open-redirect.md](open-redirect.md) | URL validation, OAuth redirect URI | A01 | - -### Client-Side Security - -| File | Topic | OWASP 2025 | -|------|-------|------------| -| [csp.md](csp.md) | Content Security Policy, nonces, hashes | A05 | -| [cors.md](cors.md) | Origin validation, credential handling | A01 | -| [clickjacking.md](clickjacking.md) | X-Frame-Options, frame-ancestors | A01 | - -### Application Logic - -| File | Topic | OWASP 2025 | -|------|-------|------------| -| [race-conditions.md](race-conditions.md) | TOCTOU, atomic check-and-act, database locks | — | -| [dos-prevention.md](dos-prevention.md) | Rate limiting, resource bounds, algorithmic complexity | — | -| [file-upload.md](file-upload.md) | Content validation, safe storage, malware scanning | A04 | - -### AI/LLM Security - -| File | Topic | OWASP 2025 | -|------|-------|------------| -| [prompt-injection.md](prompt-injection.md) | LLM security, data/instruction separation | — | - -### Infrastructure - -| File | Topic | OWASP 2025 | -|------|-------|------------| -| [supply-chain.md](supply-chain.md) | SBOM, dependency scanning, signed packages | A03 | -| [cryptography.md](cryptography.md) | Strong algorithms, key management, TLS | A04 | - -## OWASP Top 10:2025 Coverage - -| # | Category | Patterns | -|---|----------|----------| -| A01 | Broken Access Control | authorization, cors, clickjacking, open-redirect | -| A02 | Security Misconfiguration | secure-defaults | -| A03 | Software Supply Chain Failures | supply-chain | -| A04 | Cryptographic Failures | cryptography, file-upload | -| A05 | Injection | injection-prevention, xxe, csp | -| A06 | Insecure Design | secure-defaults | -| A07 | Authentication Failures | authentication, jwt-security, session-management | -| A08 | Software or Data Integrity Failures | deserialization | -| A09 | Security Logging and Alerting Failures | audit-logging | -| A10 | Mishandling of Exceptional Conditions | error-handling, ssrf | - -## Sources - -- [OWASP Top 10:2025](https://owasp.org/Top10/2025/) -- [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/) -- [OWASP LLM Top 10](https://owasp.org/www-project-top-10-for-large-language-model-applications/) -- [CWE (Common Weakness Enumeration)](https://cwe.mitre.org/) +Models already know *what* SQL injection or XSS are. What they need is a checklist to ensure nothing is missed during review. This repo provides that checklist, not tutorials. ## Usage -Reference these patterns when building or reviewing systems. Code examples are in Python for universal model comprehension; concepts apply to any language. +The `SECURITY-CHECKLIST.md` file is designed to be loaded as context for a security-focused code reviewer. Point your review bot's `patterns-files` at this repo. + +## Contents + +- `SECURITY-CHECKLIST.md` - The review checklist covering: + - Input & Validation + - Authentication & Sessions + - Authorization + - Secrets & Credentials + - Request Handling + - Response & Headers + - Concurrency & State + - File Operations + - Logging & Audit + - Dependencies & Supply Chain + - AI/LLM Specific + +## Integration + +```yaml +# In your review workflow +patterns-repo: rodin/security-patterns +patterns-files: '.' +``` + +## License + +MIT diff --git a/SECURITY-CHECKLIST.md b/SECURITY-CHECKLIST.md new file mode 100644 index 0000000..1341d0c --- /dev/null +++ b/SECURITY-CHECKLIST.md @@ -0,0 +1,97 @@ +# Security Review Checklist + +Focused prompts for code review. Models know *what* these are - this is a checklist to ensure nothing is missed. + +## Input & Validation + +- [ ] All external input validated (allowlist preferred over blocklist) +- [ ] SQL/NoSQL queries use parameterized statements, never string interpolation +- [ ] Command execution avoids shell when possible; if required, use allowlist for commands/args +- [ ] Path traversal prevented (resolve base + canonicalize + verify prefix) +- [ ] XML parsing disables external entities (XXE) +- [ ] Deserialization uses safe formats (JSON) or strict type allowlists + +## Authentication & Sessions + +- [ ] Passwords hashed with bcrypt/argon2/scrypt (not sha256/md5) +- [ ] Timing-safe comparison for secrets (`hmac.compare_digest`, `crypto.timingSafeEqual`) +- [ ] Session tokens cryptographically random, sufficient entropy (≥128 bits) +- [ ] Session invalidated on logout and password change +- [ ] JWT: verify signature, check `exp`/`iat`/`nbf`, validate `iss`/`aud`, reject `alg: none` +- [ ] MFA for sensitive operations + +## Authorization + +- [ ] Server-side enforcement (never trust client for authz) +- [ ] Check ownership on every resource access (IDOR prevention) +- [ ] Principle of least privilege for service accounts and API keys +- [ ] Admin functions have explicit role checks + +## Secrets & Credentials + +- [ ] No hardcoded secrets in code or config files +- [ ] Secrets loaded from environment/vault at runtime +- [ ] API keys have minimal scopes +- [ ] Credentials never logged (even at debug level) + +## Request Handling + +- [ ] SSRF: validate/allowlist URLs before server-side requests; block internal IPs +- [ ] Open redirect: validate redirect targets against allowlist +- [ ] CSRF tokens on state-changing operations +- [ ] Rate limiting on authentication and expensive endpoints +- [ ] Request size limits enforced + +## Response & Headers + +- [ ] CSP header set (script-src, default-src) +- [ ] CORS: explicit origin allowlist, avoid `*` with credentials +- [ ] X-Frame-Options or CSP frame-ancestors (clickjacking) +- [ ] Sensitive data not in URLs (appears in logs/referer) +- [ ] Error messages don't leak internals (stack traces, SQL, file paths) + +## Concurrency & State + +- [ ] Race conditions: use transactions or locks for check-then-act patterns +- [ ] TOCTOU: verify state at moment of action, not before +- [ ] Idempotency keys for payment/critical operations +- [ ] Optimistic locking where appropriate + +## File Operations + +- [ ] Upload: validate content type (magic bytes, not just extension) +- [ ] Upload: store outside webroot or with non-executable permissions +- [ ] Upload: generate random filenames, don't use user-provided names +- [ ] Serve user content with `Content-Disposition: attachment` or from separate domain + +## Logging & Audit + +- [ ] Security events logged: auth success/failure, privilege changes, sensitive access +- [ ] Logs don't contain secrets, tokens, or full credentials +- [ ] Logs are immutable/append-only for forensics +- [ ] Structured logging with correlation IDs + +## Dependencies & Supply Chain + +- [ ] Dependencies pinned to exact versions +- [ ] Lockfile committed and verified in CI +- [ ] Dependency audit in CI pipeline +- [ ] Minimal dependencies (smaller attack surface) + +## AI/LLM Specific + +- [ ] User input clearly delimited from system instructions +- [ ] Output validation before tool execution +- [ ] Rate limiting on LLM-powered features +- [ ] No secrets accessible to LLM context + +--- + +## When to Escalate + +Flag for human security review if: +- Crypto implementation (not just usage of established libraries) +- Authentication/authorization architecture changes +- New external integrations with sensitive data +- Payment or financial transaction handling +- Changes to logging/audit infrastructure diff --git a/audit-logging.md b/audit-logging.md deleted file mode 100644 index b4b4988..0000000 --- a/audit-logging.md +++ /dev/null @@ -1,134 +0,0 @@ -# Audit Logging - -## Rule - -Log security-relevant events. Never log secrets. - -**Source:** [OWASP Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) - -## What to Log - -| Event | Log Level | Required Fields | -|-------|-----------|-----------------| -| Authentication success/failure | INFO/WARN | user_id, ip, timestamp, method | -| Authorization failure | WARN | user_id, resource, action, ip | -| Input validation failure | WARN | endpoint, validation_error, ip | -| Privilege escalation | WARN | user_id, old_role, new_role, by_whom | -| Data access (sensitive) | INFO | user_id, resource_type, resource_id | -| Configuration change | INFO | user_id, setting, old_value, new_value | -| Security control disabled | ALERT | user_id, control, reason | - -## Correct Pattern - -```python -import logging -import hashlib -from datetime import datetime - -# Structured logging -security_logger = logging.getLogger("security") - -def log_auth_attempt(user_id: str, success: bool, ip: str, method: str): - security_logger.info( - "authentication_attempt", - extra={ - "event_type": "auth", - "user_id": user_id, - "success": success, - "ip_address": ip, - "auth_method": method, - "timestamp": datetime.utcnow().isoformat(), - } - ) - -def log_access(user_id: str, resource: str, action: str, allowed: bool): - level = logging.INFO if allowed else logging.WARNING - security_logger.log( - level, - "access_attempt", - extra={ - "event_type": "access", - "user_id": user_id, - "resource": resource, - "action": action, - "allowed": allowed, - "timestamp": datetime.utcnow().isoformat(), - } - ) - -# Mask sensitive data in logs -def mask_sensitive(data: dict) -> dict: - """Mask sensitive fields for logging.""" - sensitive_keys = {"password", "token", "secret", "api_key", "ssn", "credit_card"} - masked = {} - for key, value in data.items(): - if any(s in key.lower() for s in sensitive_keys): - masked[key] = "[REDACTED]" - elif isinstance(value, dict): - masked[key] = mask_sensitive(value) - else: - masked[key] = value - return masked -``` - -## Incorrect Pattern - -```python -# Wrong: logging secrets -logging.info(f"User login with password: {password}") -logging.debug(f"API call with key: {api_key}") - -# Wrong: no context -logging.warning("Invalid input") # Which input? Where? Who? - -# Wrong: user-controlled data in log format string -logging.info(user_input) # Log injection possible - -# Wrong: logging PII without purpose -logging.info(f"User {name} with SSN {ssn} logged in") -``` - -## Log Injection Prevention - -```python -# Wrong: allows log injection -def log_user_action(action: str): - logging.info(f"User action: {action}") - # Input: "action\n2024-01-01 INFO: Admin granted" - -# Correct: escape or use structured logging -def log_user_action(action: str): - # Option 1: escape newlines - safe_action = action.replace("\n", "\\n").replace("\r", "\\r") - logging.info(f"User action: {safe_action}") - - # Option 2: structured logging (preferred) - logging.info("user_action", extra={"action": action}) -``` - -## Retention and Protection - -```python -# Log retention policy -RETENTION_DAYS = { - "security": 365, # Keep security logs 1 year - "access": 90, # Access logs 90 days - "debug": 7, # Debug logs 7 days -} - -# Tamper detection -def log_with_hash(event: dict): - """Append hash for integrity verification.""" - event["_hash"] = hashlib.sha256( - json.dumps(event, sort_keys=True).encode() - ).hexdigest() - security_logger.info(event) -``` - -## Edge Cases - -- Logs themselves become attack surface (log4shell) -- PII in logs may violate GDPR/CCPA -- High-volume logging can be used for DOS -- Stack traces may leak sensitive info -- Correlation IDs needed for distributed tracing diff --git a/authentication.md b/authentication.md deleted file mode 100644 index bb916d3..0000000 --- a/authentication.md +++ /dev/null @@ -1,159 +0,0 @@ -# Authentication - -## Rule - -Verify identity before granting access. Use proven libraries, not DIY crypto. - -**Source:** [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) - -## Password Handling - -### Correct Pattern - -```python -import bcrypt -import secrets - -def hash_password(password: str) -> bytes: - """Hash password using bcrypt with automatic salt.""" - return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) - -def verify_password(password: str, hashed: bytes) -> bool: - """Verify password against hash. Constant-time comparison.""" - return bcrypt.checkpw(password.encode(), hashed) - -# Password requirements -MIN_PASSWORD_LENGTH = 12 -COMMON_PASSWORDS = load_common_passwords() # Top 10k list - -def validate_password(password: str) -> list[str]: - """Return list of validation errors.""" - errors = [] - if len(password) < MIN_PASSWORD_LENGTH: - errors.append(f"Password must be at least {MIN_PASSWORD_LENGTH} characters") - if password.lower() in COMMON_PASSWORDS: - errors.append("Password is too common") - return errors -``` - -### Incorrect Pattern - -```python -# Wrong: plain text storage -user.password = password - -# Wrong: weak hashing -user.password = hashlib.md5(password.encode()).hexdigest() - -# Wrong: SHA without salt -user.password = hashlib.sha256(password.encode()).hexdigest() - -# Wrong: reversible encryption -user.password = encrypt(password, key) - -# Wrong: timing attack vulnerable -if user.password == submitted_password: - grant_access() -``` - -## Token Management - -### Correct Pattern - -```python -import secrets -from datetime import datetime, timedelta - -def generate_token() -> str: - """Generate cryptographically secure token.""" - return secrets.token_urlsafe(32) - -def generate_session(user_id: str) -> dict: - """Create session with expiration.""" - return { - "token": generate_token(), - "user_id": user_id, - "created_at": datetime.utcnow(), - "expires_at": datetime.utcnow() + timedelta(hours=24), - } - -def validate_session(session: dict) -> bool: - """Check session validity.""" - if datetime.utcnow() > session["expires_at"]: - return False - return True -``` - -### Incorrect Pattern - -```python -# Wrong: predictable tokens -token = f"session_{user_id}_{int(time.time())}" - -# Wrong: no expiration -session = {"token": token, "user_id": user_id} - -# Wrong: client-controlled expiration -if request.cookies.get("expires") > now: # User can modify! - grant_access() -``` - -## Multi-Factor Authentication - -```python -import pyotp - -def setup_totp(user_id: str) -> str: - """Generate TOTP secret for user.""" - secret = pyotp.random_base32() - store_totp_secret(user_id, secret) - return secret - -def verify_totp(user_id: str, code: str) -> bool: - """Verify TOTP code with time window.""" - secret = get_totp_secret(user_id) - totp = pyotp.TOTP(secret) - return totp.verify(code, valid_window=1) # ±30 seconds -``` - -## Brute Force Protection - -```python -from collections import defaultdict -import time - -class LoginRateLimiter: - def __init__(self): - self.attempts = defaultdict(list) - self.lockouts = {} - - def record_attempt(self, identifier: str, success: bool): - now = time.time() - - if not success: - self.attempts[identifier].append(now) - # Clean old attempts - self.attempts[identifier] = [ - t for t in self.attempts[identifier] - if now - t < 3600 # 1 hour window - ] - - # Lockout after 5 failures - if len(self.attempts[identifier]) >= 5: - self.lockouts[identifier] = now + 900 # 15 min lockout - else: - self.attempts[identifier] = [] - self.lockouts.pop(identifier, None) - - def is_locked(self, identifier: str) -> bool: - lockout_until = self.lockouts.get(identifier, 0) - return time.time() < lockout_until -``` - -## Edge Cases - -- Timing attacks on username enumeration -- Account lockout as DOS vector -- Session fixation attacks -- Token leakage in logs/URLs -- Password reset token reuse diff --git a/authorization.md b/authorization.md deleted file mode 100644 index 9b6d7e8..0000000 --- a/authorization.md +++ /dev/null @@ -1,134 +0,0 @@ -# Authorization - -## Rule - -Verify permissions on every request. Default deny. Check at the resource, not just the route. - -**Source:** [OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html) - -## Correct Pattern - -```python -from enum import Enum -from functools import wraps - -class Permission(Enum): - READ = "read" - WRITE = "write" - DELETE = "delete" - ADMIN = "admin" - -def check_permission(user_id: str, resource_type: str, - resource_id: str, permission: Permission) -> bool: - """Check if user has permission on specific resource.""" - # Get user's roles - roles = get_user_roles(user_id) - - # Check resource-level permissions - resource_perms = get_resource_permissions(resource_type, resource_id) - - for role in roles: - if permission in resource_perms.get(role, []): - return True - - # Check ownership - if get_resource_owner(resource_type, resource_id) == user_id: - if permission in [Permission.READ, Permission.WRITE]: - return True - - return False # Default deny - -def require_permission(resource_type: str, permission: Permission): - """Decorator to enforce authorization.""" - def decorator(func): - @wraps(func) - def wrapper(*args, **kwargs): - user_id = get_current_user_id() - resource_id = kwargs.get("resource_id") or args[0] - - if not check_permission(user_id, resource_type, resource_id, permission): - log_access(user_id, f"{resource_type}/{resource_id}", - permission.value, allowed=False) - raise PermissionDenied() - - log_access(user_id, f"{resource_type}/{resource_id}", - permission.value, allowed=True) - return func(*args, **kwargs) - return wrapper - return decorator - -@require_permission("document", Permission.READ) -def get_document(resource_id: str): - return Document.query.get(resource_id) -``` - -## Incorrect Pattern - -```python -# Wrong: checking only authentication, not authorization -@login_required -def delete_document(doc_id): - Document.query.get(doc_id).delete() # Any logged-in user can delete! - -# Wrong: client-side only checks -if user.role == "admin": # Checked in JavaScript only - show_admin_panel() - -# Wrong: IDOR vulnerability -@app.route("/api/users//profile") -def get_profile(user_id): - return User.query.get(user_id).to_dict() # No ownership check! - -# Wrong: relying on hidden URLs -@app.route("/admin/secret/delete-all") # Security through obscurity -def delete_all(): - ... -``` - -## IDOR Prevention - -```python -# Insecure Direct Object Reference - always verify ownership - -# Wrong -@app.route("/api/orders/") -def get_order(order_id): - return Order.query.get(order_id) # Any user can view any order - -# Correct -@app.route("/api/orders/") -def get_order(order_id): - order = Order.query.get(order_id) - if order.user_id != current_user.id: - if not current_user.has_permission("orders.view_all"): - raise PermissionDenied() - return order -``` - -## Privilege Escalation Prevention - -```python -def update_user_role(actor_id: str, target_user_id: str, new_role: str): - """Prevent privilege escalation.""" - actor = get_user(actor_id) - - # Can't grant roles higher than your own - if ROLE_HIERARCHY[new_role] > ROLE_HIERARCHY[actor.role]: - raise PermissionDenied("Cannot grant role higher than your own") - - # Can't modify users with higher roles - target = get_user(target_user_id) - if ROLE_HIERARCHY[target.role] >= ROLE_HIERARCHY[actor.role]: - raise PermissionDenied("Cannot modify user with equal or higher role") - - target.role = new_role - log_role_change(actor_id, target_user_id, target.role, new_role) -``` - -## Edge Cases - -- Time-of-check to time-of-use (TOCTOU) race conditions -- Horizontal privilege escalation (user A accesses user B's data) -- Vertical privilege escalation (user becomes admin) -- Permission caching leading to stale authz -- Implicit permissions from group membership diff --git a/clickjacking.md b/clickjacking.md deleted file mode 100644 index ab6ba5f..0000000 --- a/clickjacking.md +++ /dev/null @@ -1,174 +0,0 @@ -# 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 - - -
Click to win a prize!
- -``` - -## 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 -""" - -""" -# Bypassed by: - -# 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 (``, ``) 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 `` tag -- Cursorjacking: manipulating cursor position (similar attack) -- Likejacking: clicking social media Like buttons diff --git a/cors.md b/cors.md deleted file mode 100644 index a29c220..0000000 --- a/cors.md +++ /dev/null @@ -1,183 +0,0 @@ -# CORS Misconfiguration - -## Rule - -Never reflect Origin blindly. Allowlist specific origins. Don't use credentials with wildcards. - -**Source:** [OWASP CORS Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) - -## CORS Basics - -Browser blocks cross-origin requests by default. CORS headers selectively allow them: - -| Header | Purpose | -|--------|---------| -| `Access-Control-Allow-Origin` | Which origins can access | -| `Access-Control-Allow-Credentials` | Allow cookies/auth | -| `Access-Control-Allow-Methods` | Allowed HTTP methods | -| `Access-Control-Allow-Headers` | Allowed request headers | - -## Correct Pattern - -```python -from flask import Flask, request - -ALLOWED_ORIGINS = { - "https://app.example.com", - "https://admin.example.com", -} - -def add_cors_headers(response): - origin = request.headers.get("Origin") - - # Validate against allowlist - if origin in ALLOWED_ORIGINS: - response.headers["Access-Control-Allow-Origin"] = origin - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE" - response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization" - response.headers["Vary"] = "Origin" # Important for caching! - - return response - -# For public APIs without credentials -def add_public_cors(response): - response.headers["Access-Control-Allow-Origin"] = "*" - # Note: credentials CANNOT be used with wildcard - response.headers["Access-Control-Allow-Methods"] = "GET" - return response - -# Handle preflight requests -@app.route("/api/", methods=["OPTIONS"]) -def preflight(path): - response = make_response() - return add_cors_headers(response) -``` - -## Incorrect Pattern - -```python -# Wrong: reflect any origin (allows any site to access) -@app.after_request -def bad_cors(response): - origin = request.headers.get("Origin") - response.headers["Access-Control-Allow-Origin"] = origin # Reflected! - response.headers["Access-Control-Allow-Credentials"] = "true" - return response - # Attack: evil.com can now make authenticated requests - -# Wrong: wildcard with credentials -response.headers["Access-Control-Allow-Origin"] = "*" -response.headers["Access-Control-Allow-Credentials"] = "true" -# Browser will reject, but shows misunderstanding - -# Wrong: regex bypass -def check_origin(origin): - return origin.endswith(".example.com") - # Bypassed by: attacker-example.com - -# Wrong: null origin allowed -ALLOWED_ORIGINS = {"https://app.example.com", "null"} -# "null" origin sent by sandboxed iframes, file:// URLs - attacker controlled! - -# Wrong: substring match -def check_origin(origin): - return "example.com" in origin - # Bypassed by: example.com.evil.com -``` - -## Origin Validation - -```python -from urllib.parse import urlparse - -ALLOWED_ORIGINS = {"https://app.example.com", "https://admin.example.com"} - -def is_valid_origin(origin: str) -> bool: - """Strict origin validation.""" - if not origin: - return False - - # Never allow null - if origin == "null": - return False - - # Exact match against allowlist - if origin in ALLOWED_ORIGINS: - return True - - # If you need subdomain matching, be careful: - try: - parsed = urlparse(origin) - # Must be HTTPS - if parsed.scheme != "https": - return False - - # Exact domain match (not suffix!) - allowed_domains = {"app.example.com", "admin.example.com"} - if parsed.netloc in allowed_domains: - return True - - # Subdomain of specific parent (careful!) - if parsed.netloc.endswith(".trusted.example.com"): - # Verify it's actually a subdomain, not suffix attack - parts = parsed.netloc.split(".") - if len(parts) >= 4 and parts[-3:] == ["trusted", "example", "com"]: - return True - except Exception: - return False - - return False -``` - -## Attack Scenarios - -```python -# Scenario 1: Data theft via reflected origin -# -# Vulnerable server reflects any Origin with credentials -# -# Attacker's evil.com: -# - -# Scenario 2: CSRF via CORS -# -# If CORS allows credentials from evil.com, -# evil.com can make authenticated state-changing requests -``` - -## Preflight Caching - -```python -@app.after_request -def cors_headers(response): - origin = request.headers.get("Origin") - if origin in ALLOWED_ORIGINS: - response.headers["Access-Control-Allow-Origin"] = origin - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Max-Age"] = "86400" # Cache preflight 24h - response.headers["Vary"] = "Origin" # CRITICAL for caching - return response - -# Why Vary: Origin matters: -# Without it, CDN might cache response for origin A -# Then serve that cached response to origin B (wrong ACAO header!) -``` - -## Edge Cases - -- WebSocket connections don't use CORS (use Origin header manually) -- `Access-Control-Expose-Headers` needed for custom response headers -- Preflight not sent for "simple" requests (GET, POST with basic headers) -- Internal APIs should still validate Origin (defense in depth) -- Browser extensions can bypass CORS (not a vulnerability) -- Server-to-server requests don't involve CORS diff --git a/credential-handling.md b/credential-handling.md deleted file mode 100644 index e006fc3..0000000 --- a/credential-handling.md +++ /dev/null @@ -1,90 +0,0 @@ -# Credential Handling - -## Rule - -Never hardcode secrets. Load from environment or secret manager at runtime. - -**Source:** [CWE-798: Use of Hard-coded Credentials](https://cwe.mitre.org/data/definitions/798.html) - -## Correct Pattern - -```python -import os -from functools import lru_cache - -@lru_cache(maxsize=1) -def get_api_key() -> str: - """Load API key from environment. Fail fast if missing.""" - key = os.environ.get("API_KEY") - if not key: - raise RuntimeError("API_KEY environment variable not set") - return key - -# For cloud environments, use secret manager -def get_secret(name: str) -> str: - """Load secret from cloud secret manager.""" - from google.cloud import secretmanager - client = secretmanager.SecretManagerServiceClient() - response = client.access_secret_version(name=name) - return response.payload.data.decode("UTF-8") -``` - -## Incorrect Pattern - -```python -# Wrong: hardcoded secret -API_KEY = "sk-1234567890abcdef" - -# Wrong: secret in config file checked into git -config = {"api_key": "sk-1234567890abcdef"} - -# Wrong: secret in default argument -def call_api(key="sk-1234567890abcdef"): - ... - -# Wrong: secret in error message -def validate_key(key): - if key != expected_key: - raise ValueError(f"Invalid key: {key}") # Leaks the key! - -# Wrong: secret in log -logging.info(f"Using API key: {api_key}") -``` - -## Secret Detection - -Block these patterns in CI: - -```python -import re - -SECRET_PATTERNS = [ - r'(?i)(api[_-]?key|apikey)\s*[=:]\s*["\'][^"\']+["\']', - r'(?i)(secret|password|passwd|pwd)\s*[=:]\s*["\'][^"\']+["\']', - r'(?i)bearer\s+[a-zA-Z0-9_-]+', - r'sk-[a-zA-Z0-9]{32,}', # OpenAI-style keys - r'ghp_[a-zA-Z0-9]{36}', # GitHub PAT -] - -def scan_for_secrets(content: str) -> list[str]: - findings = [] - for pattern in SECRET_PATTERNS: - if re.search(pattern, content): - findings.append(f"Potential secret: {pattern}") - return findings -``` - -## Environment Separation - -| Environment | Source | Notes | -|-------------|--------|-------| -| Development | `.env` file (gitignored) | Never commit | -| CI | CI secrets / vault | Injected at runtime | -| Production | Secret manager | Rotated automatically | - -## Edge Cases - -- Secrets in Docker build args leak to image history -- Environment variables visible in `/proc` on Linux -- Secrets in URLs get logged by proxies/load balancers -- Clipboard managers may capture pasted secrets diff --git a/cryptography.md b/cryptography.md deleted file mode 100644 index 2bfdff9..0000000 --- a/cryptography.md +++ /dev/null @@ -1,140 +0,0 @@ -# Cryptographic Failures - -## Rule - -Use strong, modern algorithms. Never implement your own crypto. Manage keys securely. - -**Source:** [OWASP Top 10 2025 - A04 Cryptographic Failures](https://owasp.org/Top10/2025/A04_2025-Cryptographic_Failures/) - -## Algorithms to Use - -| Purpose | Recommended | Avoid | -|---------|-------------|-------| -| Symmetric encryption | AES-256-GCM | DES, 3DES, RC4, ECB mode | -| Hashing (general) | SHA-256, SHA-3 | MD5, SHA-1 | -| Password hashing | bcrypt, Argon2, scrypt | SHA-*, MD5, plain hash | -| Key exchange | ECDH, X25519 | RSA < 2048 bits | -| Signatures | Ed25519, ECDSA | RSA < 2048 bits | -| TLS | 1.2+ | SSL, TLS 1.0, 1.1 | - -## Correct Pattern - -```python -from cryptography.fernet import Fernet -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -import os -import base64 - -# Generate a secure key -def generate_key() -> bytes: - return Fernet.generate_key() - -# Encrypt data -def encrypt(data: bytes, key: bytes) -> bytes: - f = Fernet(key) - return f.encrypt(data) - -# Decrypt data -def decrypt(ciphertext: bytes, key: bytes) -> bytes: - f = Fernet(key) - return f.decrypt(ciphertext) - -# Derive key from password (for encryption, not storage) -def derive_key(password: str, salt: bytes) -> bytes: - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=salt, - iterations=600000, # OWASP 2023 recommendation - ) - return base64.urlsafe_b64encode(kdf.derive(password.encode())) - -# Generate secure random values -def generate_token(length: int = 32) -> str: - return base64.urlsafe_b64encode(os.urandom(length)).decode() -``` - -## Incorrect Pattern - -```python -import hashlib -import random - -# Wrong: MD5 for anything security-related -hash = hashlib.md5(data).hexdigest() - -# Wrong: SHA-256 for passwords (no salt, too fast) -password_hash = hashlib.sha256(password.encode()).hexdigest() - -# Wrong: predictable random -token = random.randint(0, 999999) # Not cryptographically secure - -# Wrong: hardcoded key -KEY = b"mysecretkey12345" - -# Wrong: ECB mode (patterns visible in ciphertext) -from Crypto.Cipher import AES -cipher = AES.new(key, AES.MODE_ECB) - -# Wrong: rolling your own crypto -def my_encrypt(data, key): - return bytes(a ^ b for a, b in zip(data, cycle(key))) -``` - -## Key Management - -```python -import os - -# Load keys from environment or secret manager -def get_encryption_key() -> bytes: - key = os.environ.get("ENCRYPTION_KEY") - if not key: - raise RuntimeError("ENCRYPTION_KEY not set") - return base64.urlsafe_b64decode(key) - -# Key rotation -class KeyManager: - def __init__(self): - self.current_key_id = os.environ["CURRENT_KEY_ID"] - self.keys = self._load_keys() - - def encrypt(self, data: bytes) -> dict: - key = self.keys[self.current_key_id] - ciphertext = encrypt(data, key) - return {"key_id": self.current_key_id, "data": ciphertext} - - def decrypt(self, envelope: dict) -> bytes: - key = self.keys[envelope["key_id"]] - return decrypt(envelope["data"], key) -``` - -## TLS Configuration - -```python -import ssl - -# Correct: modern TLS settings -def create_ssl_context() -> ssl.SSLContext: - context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) - context.minimum_version = ssl.TLSVersion.TLSv1_2 - context.verify_mode = ssl.CERT_REQUIRED - context.check_hostname = True - context.load_default_certs() - return context - -# Wrong: disabling verification -context = ssl.create_default_context() -context.check_hostname = False -context.verify_mode = ssl.CERT_NONE # Never do this! -``` - -## Edge Cases - -- IV/nonce reuse breaks encryption security -- Timing attacks on comparison operations -- Side-channel attacks on key operations -- Key material in swap/core dumps -- Encrypted data without integrity (use AEAD) -- Insufficient entropy at startup diff --git a/csp.md b/csp.md deleted file mode 100644 index b8d5908..0000000 --- a/csp.md +++ /dev/null @@ -1,166 +0,0 @@ -# Content Security Policy (CSP) - -## Rule - -Define strict CSP to prevent XSS. Start restrictive, loosen only as needed. Never use `unsafe-inline` for scripts. - -**Source:** [MDN Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) - -## CSP Directives - -| Directive | Controls | -|-----------|----------| -| `default-src` | Fallback for all resource types | -| `script-src` | JavaScript sources | -| `style-src` | CSS sources | -| `img-src` | Image sources | -| `connect-src` | XHR, fetch, WebSocket | -| `frame-src` | iframe sources | -| `frame-ancestors` | Who can embed this page | -| `form-action` | Form submission targets | -| `base-uri` | `` tag restrictions | - -## Correct Pattern - -```python -# Strict CSP with nonces (recommended) -import secrets - -def generate_csp_nonce() -> str: - return secrets.token_urlsafe(16) - -def get_csp_header(nonce: str) -> str: - """Generate strict CSP header.""" - return "; ".join([ - "default-src 'self'", - f"script-src 'nonce-{nonce}' 'strict-dynamic'", - "style-src 'self' 'nonce-{nonce}'", - "img-src 'self' data: https:", - "font-src 'self'", - "connect-src 'self' https://api.example.com", - "frame-ancestors 'none'", - "form-action 'self'", - "base-uri 'self'", - "upgrade-insecure-requests", - ]) - -@app.after_request -def add_security_headers(response): - nonce = generate_csp_nonce() - g.csp_nonce = nonce # Make available to templates - response.headers["Content-Security-Policy"] = get_csp_header(nonce) - return response - -# In template: -# -``` - -## Incorrect Pattern - -```python -# Wrong: unsafe-inline allows XSS -csp = "script-src 'self' 'unsafe-inline'" - -# Wrong: unsafe-eval allows eval() -csp = "script-src 'self' 'unsafe-eval'" - -# Wrong: wildcard allows any source -csp = "script-src *" - -# Wrong: no CSP at all -# (missing header) - -# Wrong: report-only without enforcement -# Use for testing, but deploy with enforcement -response.headers["Content-Security-Policy-Report-Only"] = csp -# ^ Only reports, doesn't block! - -# Wrong: data: in script-src -csp = "script-src 'self' data:" -# Attacker can inject: