Skip to content

feat(security): bound credential guessing and close the backend audit - #145

Merged
RomeoCavazza merged 1 commit into
mainfrom
hardening/auth-rate-limit
Aug 3, 2026
Merged

feat(security): bound credential guessing and close the backend audit#145
RomeoCavazza merged 1 commit into
mainfrom
hardening/auth-rate-limit

Conversation

@RomeoCavazza

Copy link
Copy Markdown
Member

Why

A manual audit of every externally reachable backend surface found one real gap. Everything else held:

Surface Finding
SSRF on the HTTP REAction Full IANA blocklist, HTTPS+443 only, userinfo rejected, DNS resolved then pinned via resolve_to_addrs (defeats rebinding), redirect::Policy::none()
WebSocket Watch verifies Team membership before joining a watcher roster; StatusTyping additionally requires the can_signal_typing capability
RBAC / IDOR Enforced in the use-case layer — 34 of 46 use cases resolve a role, the rest are pre-auth, self-scoped or internal workers
Secret leakage Never returned; proven by tests asserting the serialized response omits the signing secret and the OAuth tokens
Passwords Argon2 with an OsRng salt
SQL Zero dynamic SQL — everything through compile-checked sqlx::query!
JWT Validation::default() pins HS256 and validates exp; server-side revocation on logout

The gap: no rate limiting anywhere, application or ingress (no tower_governor, no Traefik middleware in k8s/). POST /api/auth/sign-in was an unbounded guessing loop.

Changes

Rate limiting on /api/auth/*. An in-process token bucket keyed by the caller address that resolve_client_ip already derives — so OPSWARDEN_TRUSTED_PROXY_HOPS decides how far an X-Forwarded-For chain is trusted. Without that, one reverse proxy would share a single bucket across every user, and a spoofed header would mint a fresh budget per request. Defaults to 20 attempts per 5 minutes; denials answer 429 with Retry-After.

Written rather than pulled in: tower_governor adds four transitive crates to a deliberately curated tree gated by tooling/deny.toml. ~100 owned lines for one middleware is the better trade. State is per replica — the goal is to bound a guessing loop, not to meter quota, and that limitation is documented in the module.

README collaboration limits. VIGIL requires the reaction emoji set and the private-message length to be documented in the README. The six emojis were claimed as documented but never listed; the 2 000-character limit lived only in the portal catalogue. Both are now in a table, with the new auth budget.

Two dead endpoints deleted. PUT /service-connections/http had no consumer anywhere in the repo; PUT /service-connections/github was reached only by tooling/seed_demo.sh. Both duplicate by-service/{service}, which dispatches to the same use-case methods — callers move over with no behaviour change.

Verification

  • 329 unit tests, including seven for the limiter: capacity, per-address isolation, continuous refill, full-window restore, Retry-After accuracy, no over-refill after idling, and pruning so attacker-generated keys cannot grow the map.
  • Two integration tests: a spent budget answers 429 with a usable Retry-After, and one address cannot lock out another.
  • Full local CI mirror green: cargo fmt --check, clippy --all-targets --all-features -D warnings, cargo deny (advisories, bans, licenses, sources all ok), offline SQLX_OFFLINE build, cargo test --workspace.
  • No new dependency.

Not in this PR

The orphan Channels feature (backend complete, zero frontend) is deliberately left alone — whether the war room becomes the channel is a product decision that belongs with the frontend mechanics model, not a backend cleanup.

A manual audit of the externally reachable surfaces found one real gap. The
rest held: the outbound HTTP REAction pins its resolved address against DNS
rebinding, the WebSocket checks Team membership before granting a watch,
authorization is resolved in the use-case layer on every Team route, secrets
never leave the server, and passwords use Argon2.

The gap: no rate limiting anywhere, in the application or at the ingress.
POST /api/auth/sign-in was an unbounded guessing loop.

- Add an in-process token-bucket limiter on the three unauthenticated
  /api/auth/* routes, keyed by the caller address that `resolve_client_ip`
  already derives, so the configured proxy depth decides how far an
  X-Forwarded-For chain is trusted. Default 20 attempts per 5 minutes,
  overridable; denials answer 429 with Retry-After.
  Written rather than pulled in: `tower_governor` would add four transitive
  crates to a curated tree for one middleware. State is per replica, which
  bounds the loop without pretending to meter quota.

- Document the collaboration limits in the README as VIGIL requires. The six
  reaction emojis were claimed as documented but never listed, and the 2 000
  character private-message limit only lived in the portal catalogue.

- Delete two dead endpoints. `PUT /service-connections/http` had no consumer
  anywhere; `PUT /service-connections/github` was reached only by the demo
  seed script. Both duplicate `by-service/{service}`, which dispatches to the
  same use-case methods, so callers move over with no behaviour change.

329 unit tests including seven for the limiter, plus two integration tests
proving a spent budget answers 429 with a usable Retry-After and that one
address cannot lock out another. No new dependency: cargo deny stays green.
@RomeoCavazza
RomeoCavazza force-pushed the hardening/auth-rate-limit branch from 3fa346b to e35c635 Compare August 3, 2026 16:18
@RomeoCavazza
RomeoCavazza merged commit c904a3b into main Aug 3, 2026
11 checks passed
@RomeoCavazza
RomeoCavazza deleted the hardening/auth-rate-limit branch August 3, 2026 16:29
@RomeoCavazza RomeoCavazza mentioned this pull request Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant