ADR-035: Cross-cutting security helpers live in pkg/security, not pkg/middleware
Status: Accepted Date: 2026-04-21
Context
The security tranche (threat model, standards, playbook, runbook, tripwires, alerting) identified three library-level controls every service needs to integrate:
- CSRF protection for cookie-authed state-changing endpoints.
- An SSRF-safe outbound HTTP fetch for any handler dereferencing a user-supplied URL (distill's YouTube handler is the immediate case).
- A log-injection-safe encoder for attacker-controlled values flowing into log fields.
pkg/middleware already ships transport-level gates — request IDs,
panic recovery, security headers, CORS, rate limiting, body caps,
ownership/IDOR — and has been growing one file at a time. The
temptation was to drop csrf.go, ssrf.go, input.go alongside
cors.go and ratelimit.go.
We resisted. pkg/middleware has a specific shape: every symbol is a
func(http.Handler) http.Handler, and the implicit consumer target is
"every HTTP server in the fleet wants these". CSRF fits that shape —
but SSRF-safe fetch is an outbound-client helper, not a middleware, and
LogSafe is a string utility used at log sites inside handlers. The
three sit together as an application-level defence-in-depth tranche,
not a gates-and-guards tranche.
Decision
Ship pkg/security as a new sibling package to pkg/middleware and
pkg/observ. security.CSRF is the one symbol that also fits the
middleware shape; it lives in the new package for cohesion with
IssueCSRFToken, SafeFetch, and LogSafe rather than being split
across packages.
The division of responsibility becomes:
| Package | Consumer target | Concern |
|---|---|---|
middleware |
Every HTTP server | Per-request transport gates |
observ |
Every logger | Already-captured output hygiene |
security |
Handlers and outbound clients | Application-level defence-in-depth |
Consequences
Easier:
- Readers see a package whose name matches the compliance pillar the tranche docs talk about (security-standards.md, security-tripwires.md).
- Future additions —
security.JWTVerifier,security.AllowlistProxy,security.SecretSniff— have a clear home. - The CSRF middleware and
IssueCSRFTokenlive in the same file so the cookie name default cannot drift between issuer and validator.
Harder:
- Services now import two security-adjacent packages
(
middleware+security). The chain setup in the bootstrap guide needs a small update. security.CSRFbreaks the "all middleware in pkg/middleware" purity test. We accept the inconsistency because splittingCSRFfrom its token issuer would be worse.
Signed up to operate:
pkg/securityhas the same quality bar aspkg/middleware: ≥ 95% coverage, godoc on every exported symbol, aREADME.md+CHEATSHEET.md.
Alternatives considered
- Everything in
pkg/middleware— rejected. SSRF-safe fetch andLogSafeare not middleware; shoehorning them would dilute the package's "http.Handler wrapper" contract. - One package per control (
pkg/csrf,pkg/ssrf,pkg/loginput) — rejected. Three tiny packages, each with its own README, raise cognitive load without clarifying anything. The controls share a mental category. pkg/middleware/securitysub-package — rejected. A sub-package ofmiddlewarecannot hold non-middleware symbols without even worse taxonomy drift.
Supersedes: none Superseded by: —