# Patterns — Securing Backend Services ## Contents - Security-headers middleware - SSRF-safe URL fetcher - CSRF setup - Problem+json error mapper - Secrets loading - Dependency scanning in CI - Gotchas ## Security-headers middleware ```python SECURITY_HEADERS = { "Strict-Transport-Security": "max-age=31536000; includeSubDomains", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'", "Referrer-Policy": "strict-origin-when-cross-origin", "Cache-Control": "no-store", # for API responses carrying user data } @app.middleware("http") async def add_security_headers(request, call_next): resp = await call_next(request) for k, v in SECURITY_HEADERS.items(): resp.headers.setdefault(k, v) # setdefault: edge/proxy may own some return resp ``` ## SSRF-safe URL fetcher ```python import ipaddress, socket from urllib.parse import urlparse ALLOWED_HOSTS = {"api.partner.example"} # allowlist beats any blocklist def assert_public(host: str): for info in socket.getaddrinfo(host, None): ip = ipaddress.ip_address(info[4][0]) if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: raise ValueError(f"blocked address {ip}") def safe_fetch(url: str): p = urlparse(url) if p.scheme != "https" or p.hostname not in ALLOWED_HOSTS: raise ValueError("destination not allowed") assert_public(p.hostname) # resolve NOW, at request time return requests.get(url, timeout=10, allow_redirects=False) # re-check per hop if following ``` ## CSRF setup ```python # Use the framework middleware — do not hand-roll token comparison. # Django: CsrfViewMiddleware (default). Flask: flask-wtf CSRFProtect(app). # FastAPI cookie-auth: double-submit cookie via starlette-csrf. # Token in a custom header (X-CSRF-Token) read from a non-HttpOnly cookie; # SameSite=Lax remains as defense-in-depth, not the defense. ``` ## Problem+json error mapper ```python @app.exception_handler(Exception) async def unhandled(request, exc): cid = request.state.correlation_id logger.exception("unhandled error cid=%s", cid) # full detail → logs return JSONResponse(status_code=500, content={ # zero detail → client "type": "about:blank", "title": "Internal error", "status": 500, "correlation_id": cid, }) ``` ## Secrets loading ```python import os class Settings: def __init__(self): self.db_url = self._req("DATABASE_URL") self.signing_key = self._req("SIGNING_KEY") @staticmethod def _req(name: str) -> str: val = os.environ.get(name) if not val: raise RuntimeError(f"missing required secret {name}") # fail fast, no defaults return val ``` Pre-commit guard: `gitleaks protect --staged` blocks accidental secret commits. ## Dependency scanning in CI ```yaml # GitHub Actions - run: pip install pip-audit && pip-audit --strict # Python - run: npm audit --audit-level=high # Node # Fail the build on criticals; schedule a weekly run so quiet repos still alert. ``` ## Gotchas - **HSTS on a domain still serving plain HTTP paths** locks users out for max-age; deploy HTTPS fully first, then add the header, then preload. - **CSP `report-only` left on forever** — attackers are unaffected by reports; graduate to enforcing after a week of clean reports. - **SSRF via DNS rebinding** — validate the resolved IP at request time (as above), not in a separate pre-check the attacker can race. - **`allow_redirects=True` after an allowlist check** — the first hop is allowed, the redirect goes to the metadata IP; disable or re-validate per hop. - **Rotating a secret without invalidating derived artifacts** — old JWTs signed with the leaked key stay valid; rotate key AND revoke issued tokens. - **One shared "backend" IAM role** — a compromise anywhere is a compromise everywhere; one identity per service. - **Error middleware ordered after routers** — exceptions in earlier middleware bypass the mapper; register it outermost.