Skip to content

fix(api): rate-limit state-changing endpoints on five controllers#332

Merged
thomasluizon merged 4 commits into
mainfrom
fix/rate-limit-state-changing-endpoints
Jul 12, 2026
Merged

fix(api): rate-limit state-changing endpoints on five controllers#332
thomasluizon merged 4 commits into
mainfrom
fix/rate-limit-state-changing-endpoints

Conversation

@thomasluizon

Copy link
Copy Markdown
Owner

What

Five controllers exposed authenticated, state-changing endpoints reachable without any distributed rate limiting, leaving them open to spam/abuse. This applies the existing [DistributedRateLimit] attribute (per-user partitioning, automatic for authenticated requests) and registers sensible new policies in DistributedRateLimitService.

Controller Endpoints newly limited Policy Limit
TagsController CreateTag, UpdateTag, DeleteTag, RestoreTag, AssignTags tags 60 / min
FriendsController AcceptRequest, RemoveFriend friend-mutations 50 / 24h
AchievementsController ReportEvent achievements 30 / min
AiController ConfirmPendingOperation, ExecutePendingOperation ai-operations (tighter) 15 / min
AccountabilityController Accept, End, SetHabits accountability-mutations 50 / 24h

Policy design mirrors sibling conventions: per-minute sliding windows (SegmentCount 4) for interactive write bursts (tags/achievements) and 24h fixed windows for social/relationship mutations (matching block/unblock/friend-requests). The two AI agent operations get a deliberately tighter cap (15/min) since they can drive costly downstream execution, consistent with habit-suggest/tag-suggest. Sharing one policy per controller domain follows the established challenges/chat precedent.

Why not a client/contract change

Server-side hardening only — no new endpoints, no DTO fields, no renamed/removed contract. Request signatures and success responses are unchanged; a throttled request returns the standard 429 every client already handles. No packages/shared or consumer edit is warranted.

Tests

  • Per-controller attribute-presence coverage (mirrors FriendsControllerRateLimitTests) for Tags, Achievements, Ai, Accountability.
  • Service-level behavior: each new policy rejects past its permit limit, plus per-user partition isolation for tags (a second user is unaffected).
  • Updated FriendsControllerRateLimitTests since AcceptRequest/RemoveFriend are now limited.

dotnet build clean; full suite green (4697 passed, 0 failed).

Refs thomasluizon/orbit-ui-mobile#243

Five controllers had authenticated state-changing endpoints reachable
without any distributed rate limiting, leaving them open to abuse/spam:

- TagsController: create/update/delete/restore/assign (policy "tags", 60/min)
- FriendsController: accept-request/remove-friend (policy "friend-mutations", 50/24h)
- AchievementsController: report-event (policy "achievements", 30/min)
- AiController: confirm/execute pending operation (tighter policy "ai-operations", 15/min)
- AccountabilityController: accept/end/set-habits (policy "accountability-mutations", 50/24h)

Applies the existing [DistributedRateLimit] attribute with per-user
partitioning (automatic for authenticated requests) and registers the
new policies in DistributedRateLimitService. Limits mirror the tightness
of sibling policies; the AI ops get a deliberately tighter cap.

Tests: attribute-presence coverage per controller plus service-level
"blocks after permit limit" and per-user partition-isolation behavior for
each new policy. Updates FriendsController coverage since accept/remove
are now rate-limited.

Refs thomasluizon/orbit-ui-mobile#243

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #332

Scope: PR #332 (fix/rate-limit-state-changing-endpoints → main)
Recommendation: APPROVE

Summary

Purely additive, server-side hardening: applies the existing [DistributedRateLimit] attribute to previously-unthrottled, authenticated, state-changing endpoints on TagsController, FriendsController, AchievementsController, AiController, and AccountabilityController, plus five new policy entries in DistributedRateLimitService. No routes, DTOs, or response shapes change. All 12 changed files were read in full. No prior reviews/threads exist on this PR — this is a first pass.

Findings

Critical

None.

High

None.

Medium

None.

Low / Info

  • [Info] Out-of-scope observation, not a defect in this diff: several other controllers with state-changing endpoints (UserFactsController, SyncControllerMutations, GoalsController, ChecklistTemplatesController, ApiKeysController, GamificationController, CalendarController, ConfigController, NotificationController, ReferralController, SubscriptionController, HabitsControllerRequests) still carry zero [DistributedRateLimit] attributes. The PR body explicitly scopes itself to "five controllers," and this diff doesn't touch or regress those others — flagged only as a possible future follow-up.
  • [Info] AiControllerRateLimitTests lacks the "reads/already-limited actions stay unaffected" negative test that TagsControllerRateLimitTests and AccountabilityControllerRateLimitTests include. The two actually-changed actions (ConfirmPendingOperation, ExecutePendingOperation) are covered; this is a minor coverage-consistency nit only.

Subagents

Agent Verdict
security-reviewer PASS — no findings. Verified: no remaining unprotected authenticated state-changing endpoint on the five touched controllers; partition key is user:{userId} for all five new policies (all controllers are class-level [Authorize], no IP/email fallback reachable); the fail-open "support" special-case doesn't match any of the five new policy names so they fail closed on a TryAcquireAsync exception, consistent with existing policies; new Policies dict entries use correct TimeSpan units and SegmentCount conventions matching sibling policies; no new [AllowAnonymous], no new DTO fields, no sensitive-data logging.
contract-aligner NOT VERIFIABLE IN CI — orbit-ui-mobile not checked out. The diff itself changes no DTO, route, or request/response shape (only adds attributes + internal policy-dictionary entries), so there's nothing to drift-check even in principle. Backward-compat guard: no field add/remove/rename anywhere; a throttled request returns the existing 429 shape every client already handles from other pre-existing policies (chat, block, etc.). No ⚠️ breaks old mobile clients candidates.

Validation

Check Result
Build (dotnet) N/A — skipped in CI per instructions; covered by separate required check
Tests (dotnet) N/A — skipped in CI per instructions; covered by separate required check

Deferred — N/A dimensions & files not verdicted

  • Dimension 8 (DESIGN.md/AI-slop) — N/A, no apps/* files touched.
  • Dimension 9 (Parity web↔mobile) — N/A, backend-only diff.
  • Dimension 10 (i18n) — N/A, no new user-facing strings.
  • Dimension 14 (FEATURES.md parity) — N/A, no user-facing feature/screen/tool/plan-gating change (invisible infra hardening; a throttled request just gets a 429 clients already handle).
  • Dimension 11 (Contract drift) — not independently verifiable in CI (sibling repo not checked out), but the diff contains no DTO/route/shape change to check.
  • All 12 changed files were read in full and verdicted: AccountabilityController.cs, AchievementsController.cs, AiController.cs, FriendsController.cs, TagsController.cs, DistributedRateLimitService.cs, and the six test files. Nothing deferred among changed files.

What's good

  • Tight, mechanical scope: attribute additions + dictionary entries + matching unit tests only.
  • Policy limits/windows consistently mirror existing sibling conventions (per-minute sliding windows for interactive bursts vs. 24h fixed windows for social/relationship mutations).
  • Solid test discipline: attribute-presence coverage per controller, service-level permit-exhaustion tests for every new policy, per-user partition-isolation test for tags; the existing FriendsControllerRateLimitTests was correctly updated (moved, not just extended) since two actions changed category.
  • Correctly reasons about backward compatibility in the PR body — verified true against the actual diff.

Recommendation

Ready to merge as-is. No Critical/High findings; the two Info notes are optional follow-ups, not blockers.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review: PR #332 — fix(api): rate-limit state-changing endpoints on five controllers

Recommendation: APPROVE

Summary

The PR adds [DistributedRateLimit] to previously-unprotected authenticated, state-changing endpoints on TagsController, FriendsController, AchievementsController, AiController, and AccountabilityController, and registers five new policies (ai-operations, tags, achievements, friend-mutations, accountability-mutations) in DistributedRateLimitService. No routes, DTOs, or response shapes changed — server-side hardening only. New/updated tests cover attribute presence per action and service-level permit-exhaustion + per-user partition isolation for each new policy. Coverage was verified against every action on all five controllers; nothing abuse-prone was left unprotected, and the new limits are consistent with the existing policy scale.

Findings

Critical

None.

High

None.

Medium

None.

Low / Info

[Info] New policy limits track existing sibling conventions

  • location: src/Orbit.Infrastructure/Services/DistributedRateLimitService.cs:20,25,26,29,33
  • ai-operations (15/min) mirrors habit-suggest/tag-suggest; tags (60/min, shared across 5 CRUD actions) sits above uploads/public-profile (30/min) but is reasonable for a per-user CRUD bucket; friend-mutations/accountability-mutations (50/24h) match block/unblock/challenges.
  • risk: None — noted for the record, no change requested.

Subagents

Agent Verdict
security-reviewer PASS — full coverage confirmed across all five controllers' remaining mutating actions; filter-ordering ([Authorize] runs before the rate-limit filter, so partitioning is always user:{id} for these endpoints) and fail-closed behavior verified correct; no injection/data-exposure/logging issues.
contract-aligner N/A — no DTO, Controller route, or packages/shared type/endpoints.ts changed (attribute-only diff); not verifiable in this CI job since orbit-ui-mobile is not checked out here, but the diff itself contains no route/DTO edits.

Validation

Check Result
Build (dotnet) Skipped in this run — runs as separate required CI checks (Build / Unit Tests / SonarCloud).
Tests (dotnet) Skipped in this run — same as above.

Deferred — N/A dimensions

  • DESIGN.md/AI-slop — N/A, no apps/* UI files in this diff (backend-only repo).
  • Parity (web ↔ mobile) — not verifiable in this job (sibling orbit-ui-mobile repo not checked out); diff shows no contract-surface change (429 is a status every client already handles).
  • i18n — N/A, no new user-facing strings.
  • FEATURES.md parity — N/A, pure enforcement hardening on existing endpoints, no new/changed/removed feature surface.

What's good

  • Coverage is genuinely complete: cross-checked every mutating action on all five controllers, not just the ones the PR body lists, confirming no sibling endpoint was missed.
  • Test strategy mirrors the existing FriendsControllerRateLimitTests pattern exactly (attribute-presence theory + explicit non-rate-limited read actions), plus adds real permit-exhaustion and per-user-partition-isolation coverage at the service level for every new policy.
  • Policy values are chosen by analogy to existing sibling policies rather than invented ad hoc, keeping the rate-limit table internally consistent.
  • Correctly scoped as server-only: no unnecessary contract churn, no speculative DTO changes.

Recommendation

Approve as-is. No follow-up required before merge.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary — PR #332 (fix/rate-limit-state-changing-endpoints)

Recommendation: APPROVE

Scope

Adds distributed rate-limit policies (tags, achievements, ai-operations, friend-mutations, accountability-mutations) to five state-changing controllers, plus corresponding unit tests. Purely additive: attribute annotations + new Policies dictionary entries + new/extended tests. No route, DTO, or response-shape changes.

Verification performed

  • Read the full diff (gh pr diff 332) across all 12 changed files.
  • Confirmed TagsController.SuggestTags (covered by the new TagsControllerRateLimitTests) already carried a pre-existing tag-suggest rate-limit attribute, untouched by this diff — the test addition is accurate, not speculative.
  • Read src/Orbit.Api/RateLimiting/DistributedRateLimitAttribute.cs in full:
    • Partition key resolution is user:{GetUserId()} for any authenticated request; IP/email fallback only applies to unauthenticated requests under the auth/waitlist policies (irrelevant here).
    • On TryAcquireAsync failure the filter fails closed (rethrows) for every policy except the hardcoded "support" fail-open case — none of the five new policy names match it.
  • Grepped all five touched controllers and confirmed each carries class-level [Authorize] with no [AllowAnonymous] override on the touched actions, so partitioning is genuinely per-user, not IP-shared, for all new limits.

Findings

  • Critical: None
  • High: None
  • Medium: None
  • Low/Info: None new. Prior review passes noted non-blocking observations on policy-limit rationale and other unthrottled controllers being out of scope — not re-litigated here as no new signal emerged.

Not verifiable in this CI job

  • Cross-repo contract check (orbit-ui-mobile not checked out) — not applicable in principle either, since the diff has no DTO/route/contract surface change.
  • Build/Unit Tests/SonarCloud — run as separate required CI checks, skipped here per instructions.

This is the third independent pass over this PR (including two prior automated APPROVEs); this run corroborates the prior findings with direct code verification and surfaces nothing new.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated /pr-review pass — see full report in conversation. Recommendation: APPROVE. No Critical/High findings survived adversarial verification; one High finding (MCP transport bypasses the new ai-operations REST rate limit) was downgraded to Medium/follow-up after a skeptic pass confirmed it's a pre-existing gap unrelated to this diff (AgentTools.cs is not touched by PR #332).

@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit 46d6b90 into main Jul 12, 2026
19 checks passed
@thomasluizon
thomasluizon deleted the fix/rate-limit-state-changing-endpoints branch July 12, 2026 14:30
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