Decisions

ADR-035: Cross-cutting security helpers live in `pkg/security`, not `pkg/middleware`

The security tranche (threat model, standards, playbook, runbook, tripwires, alerting) identified three library-level controls every service needs to integrate:

#035

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:

  1. CSRF protection for cookie-authed state-changing endpoints.
  2. An SSRF-safe outbound HTTP fetch for any handler dereferencing a user-supplied URL (distill's YouTube handler is the immediate case).
  3. 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 IssueCSRFToken live 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.CSRF breaks the "all middleware in pkg/middleware" purity test. We accept the inconsistency because splitting CSRF from its token issuer would be worse.

Signed up to operate:

  • pkg/security has the same quality bar as pkg/middleware: ≥ 95% coverage, godoc on every exported symbol, a README.md + CHEATSHEET.md.

Alternatives considered

  • Everything in pkg/middleware — rejected. SSRF-safe fetch and LogSafe are 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/security sub-package — rejected. A sub-package of middleware cannot hold non-middleware symbols without even worse taxonomy drift.

Supersedes: none Superseded by: