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 <script>alert(1)</script></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 |