security: HSTS + per-IP rate limit on canopy-web BFF (bypasses ApiServer::router hardening)

Surfaced

External audit re-verified 2026-05-27.

Finding

canopy-web/src/main.rs:247-251 deliberately bypasses ApiServer::router() because that router applies JWT auth + /v1 nesting which are wrong for a browser-facing BFF. But the bypass also loses the security middleware that ApiServer carries:

Middleware ApiServer (program services) canopy-web (browser-facing BFF)
HSTS (Strict-Transport-Security) max-age=31536000; includeSubDomains; preload (crates/canopy-api/src/lib.rs:175-180) ✗ absent (grep STRICT_TRANSPORT services/canopy-web/src/main.rs empty)
Per-IP rate limit (governor) ✓ default 6000 rpm, IP-keyed (crates/canopy-api/src/lib.rs:131-137) ✗ absent (grep -rn 'governor|rate_limit|RateLimit' services/canopy-web/ empty)
Existing canopy-web layers ✓ X-Content-Type-Options=nosniff, X-Frame-Options=DENY, strict CSP, DefaultBodyLimit

The most-exposed surface in the system — login, OAuth callback, session cookie issuance, htmx endpoints — has no per-IP rate limit and no HSTS.

Concrete attack surfaces

  • /login, /auth/callback, /logout, /auth/discover (services/canopy-web/src/main.rs:258-269) — brute force / enumeration unprotected.
  • HSTS absence means the first plaintext-HTTP visit (or a downgrade attack) is unprotected. For an eligibility system carrying FTI / HIPAA traffic, this is an ATO-relevant gap.

Acceptance criteria

  1. Lift build_rate_limiter + middleware out of canopy-api so canopy-web can layer them on its own router (or inline a per-canopy-web rate-limiter — the layer is small).
  2. Add SetResponseHeaderLayer for Strict-Transport-Security alongside the existing X-Frame-Options block at services/canopy-web/src/main.rs:302-308.
  3. Stricter rate-limit on /login + /auth/* than on app routes (e.g. 30/min/IP vs 6000/min/IP). Config-driven via CANOPY_WEB__RATE_LIMIT_RPM + CANOPY_WEB__AUTH_RATE_LIMIT_RPM.
  4. Integration test: /login 31st request from same IP within 60s returns 429.

Why priority::high

  • ATO-relevant. Public-visible (BFF reachable from the internet in any non-dev deploy).
  • Both fixes are ~5-line additions; the engineering pattern already exists in canopy-api.

Labels

type::security, priority::high, workflow::ready, service::web, program::infrastructure