Frontend Labs
⋯

49. Web security in Python: injection, templates, and uploads

49.1 SQL injection: parameters, always

import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, role TEXT)")
db.executemany("INSERT INTO users (name, role) VALUES (?, ?)", [("ada", "admin"), ("bob", "user")])

name = "bob' OR '1'='1"                                   # attacker input

# WRONG: string formatting builds the SQL
rows = db.execute(f"SELECT name, role FROM users WHERE name = '{name}'").fetchall()
print("formatted:", rows)                                 # returns EVERY user

# Right: a placeholder; the driver sends the value separately from the SQL text
rows = db.execute("SELECT name, role FROM users WHERE name = ?", (name,)).fetchall()
print("parameterized:", rows)                             # []

Every Python database driver supports parameters (?, %s, or :name depending on the driver). ORMs (SQLAlchemy, Django) parameterize for you, until someone uses text(f"...") or .raw(f"..."). Placeholders cannot be used for identifiers (table or column names); for those, check against an allow-list. This is the injection class from Security Ch 42.

49.2 Templates and XSS

from jinja2 import Environment, select_autoescape

env = Environment(autoescape=select_autoescape(["html"]))   # autoescape ON for HTML
tpl = env.from_string("<p>Hello {{ name }}</p>")
print(tpl.render(name="<script>alert(1)</script>"))
# <p>Hello &lt;script&gt;alert(1)&lt;/script&gt;</p>

Bare jinja2.Environment() has autoescape off by default (for backwards compatibility); Flask and Django turn it on for HTML. Every |safe, Markup(...), or mark_safe(...) is a place to review (Security Ch 43). And never build a template from user input (env.from_string(user_text)): that is server-side template injection, which in Jinja2 leads to code execution (Security in Depth Ch 36).

49.3 File uploads

import secrets
from pathlib import Path

ALLOWED = {b"\x89PNG\r\n\x1a\n": ".png", b"%PDF-": ".pdf", b"\xff\xd8\xff": ".jpg"}
MAX_UPLOAD = 10 * 2**20

def store_upload(data: bytes, dest: Path) -> Path:
    if len(data) > MAX_UPLOAD:
        raise ValueError("too large")
    ext = next((e for magic, e in ALLOWED.items() if data.startswith(magic)), None)
    if ext is None:
        raise ValueError("unsupported file type")          # decide by content, not by filename
    path = dest / f"{secrets.token_hex(16)}{ext}"          # never use the client's filename
    path.write_bytes(data)
    return path

dest = Path("uploads"); dest.mkdir(exist_ok=True)
print(store_upload(b"%PDF-1.7 ...", dest))

Generate the stored name yourself, check the type by content, cap the size, store outside the web root, and serve with Content-Disposition: attachment and a fixed Content-Type. Image and PDF parsers are attack surface too: process uploads in a separate, sandboxed worker.

49.4 Framework settings that matter

Area Flask / FastAPI / Django setting
Debug mode debug=False in production: the Werkzeug debugger is a remote Python shell
Cookies Secure, HttpOnly, SameSite=Lax or Strict (Net Ch 41)
CORS explicit origins; never allow_origins=["*"] with credentials (Net Ch 42)
CSRF Django's middleware on; for cookie-authenticated APIs, a CSRF token or SameSite cookies
Hosts Django ALLOWED_HOSTS; validate Host behind proxies
Proxies trust X-Forwarded-For only from your own proxy (--forwarded-allow-ips in uvicorn)
Secrets SECRET_KEY from the environment or a secrets manager, rotated

Generated from real-life-python-guide.md · 8 Oct 2026 16:36