Skip to content

fix(api): rate-limit magic-code verify, bound per-token attempts (GHSA-9pvm-fcf6-9234)#9130

Open
sriramveeraghanta wants to merge 4 commits into
previewfrom
fix/magic-code-brute-force
Open

fix(api): rate-limit magic-code verify, bound per-token attempts (GHSA-9pvm-fcf6-9234)#9130
sriramveeraghanta wants to merge 4 commits into
previewfrom
fix/magic-code-brute-force

Conversation

@sriramveeraghanta
Copy link
Copy Markdown
Member

@sriramveeraghanta sriramveeraghanta commented May 25, 2026

Summary

Fixes the brute-force account-takeover primitive described in GHSA-9pvm-fcf6-9234.

The magic-link sign-in / sign-up endpoints accept a 6-digit numeric code (~900k-value space, 600s TTL) but (a) never increment a failure counter on a wrong-code verify, and (b) extend django.views.View rather than DRF APIView, so DRF's AuthenticationThrottle never runs against them. The space-side MagicGenerateSpaceEndpoint also lacked throttle_classes. Combined, an unauthenticated attacker who knew a victim's email could brute-force the code within the TTL window and log in as the victim — bypassing password and TOTP.

Changes

  • MagicCodeProvider.set_user_data: on each wrong code, atomically increment a dedicated magic_<email>:verify_attempts counter via a Lua EVAL script (INCR + first-time EXPIRE aligned with the token TTL). Once the count reaches MAX_VERIFY_ATTEMPTS = 5, delete the token + counter and raise EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN/UP. The counter is also reset on each new token issuance and on successful verify. This is the load-bearing fix — it caps brute-force attempts per issued token regardless of request rate, and the atomic INCR closes the concurrency window flagged in review.
  • rate_limit.py: add authentication_throttle_allows(request) so plain django.views.View subclasses can apply AuthenticationThrottle without being converted to APIView (which would change CSRF + request-parsing semantics for the redirect-flow endpoints). Throttle rate is now configurable via the AUTHENTICATION_RATE_LIMIT env var (default 10/minute).
  • views/app/magic.py: apply the throttle helper at the top of MagicSignInEndpoint.post and MagicSignUpEndpoint.post.
  • views/space/magic.py: add throttle_classes = [AuthenticationThrottle] to MagicGenerateSpaceEndpoint to match its app sibling; apply the throttle helper to MagicSignInSpaceEndpoint and MagicSignUpSpaceEndpoint.
  • deployments/aio and deployments/cli variables.env: document AUTHENTICATION_RATE_LIMIT=10/minute.
  • tests/contract/app/test_authentication.py: new contract tests for the attempt cap (sign-in + sign-up), counter increment/reset semantics, and the per-IP throttle on both magic-sign-in/ and magic-sign-up/.

After this change the worst-case brute-force budget against any single email is 5 wrong codes per issued token × at most 3 regenerations (existing protection in MagicCodeProvider.initiate) — mathematically infeasible against a 900k-value space, on top of a 10-req/min anonymous throttle per IP.

Test plan

  • Existing magic-link contract tests (apps/api/plane/tests/contract/app/test_authentication.py): TestMagicLinkGenerate, TestMagicSignIn, TestMagicSignUp should pass unchanged.
  • TestMagicSignInVerifyAttempts.test_exhausted_after_max_wrong_attempts: POST 4 wrong codes (each INVALID_MAGIC_CODE_SIGN_IN), then the 5th attempt redirects with EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN; both magic_<email> and magic_<email>:verify_attempts keys are deleted; the follow-up verify reports EXPIRED.
  • TestMagicSignInVerifyAttempts.test_counter_increments_on_each_wrong_attempt: counter advances by exactly one per wrong POST (validates the Lua INCR path).
  • TestMagicSignInVerifyAttempts.test_counter_resets_on_token_regeneration: regenerating the magic-link clears the counter so users aren't pre-locked-out.
  • TestMagicSignUpVerifyAttempts.test_signup_exhausted_after_max_wrong_attempts: the SIGN_UP variant of EXHAUSTED is returned on the exhausting attempt.
  • TestAuthenticationThrottle.test_magic_sign_in_throttled / test_magic_sign_up_throttled: patching AuthenticationThrottle.rate and posting past the limit appends RATE_LIMIT_EXCEEDED to the redirect URL.
  • Manual: trigger magic-link for a real user, enter 5 wrong codes, confirm the UI surfaces the exhausted-attempts error and that requesting a fresh code works.
  • Manual: confirm the legitimate happy path (correct code on first try) still logs in.
  • Manual: override AUTHENTICATION_RATE_LIMIT (e.g. 5/minute) in env and verify the throttle kicks in at the new threshold.

Refs GHSA-9pvm-fcf6-9234.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added configurable rate limiting for authentication endpoints, including magic sign-in and sign-up flows (default: 10 requests per minute per IP, adjustable via environment configuration).
    • Magic code authentication now enforces a maximum of 5 incorrect verification attempts per token before token expiration.

Review Change Stack

…mpts

The magic-link sign-in / sign-up endpoints accept a 6-digit numeric code
(900k-value space, 600s TTL) but never increment a failure counter on a
wrong-code verify and extend django.views.View rather than DRF APIView,
so DRF's AuthenticationThrottle never runs against them. The space-side
generate endpoint also lacked throttle_classes. Combined, this allowed
an unauthenticated attacker who knew a victim's email to brute-force
the code within the TTL window and log in as the victim.

- Add MAX_VERIFY_ATTEMPTS=5 in MagicCodeProvider.set_user_data: failed
  comparisons now persist verify_attempts in Redis under the remaining
  TTL and, on hitting the limit, delete the key and raise
  EMAIL_CODE_ATTEMPT_EXHAUSTED. This is the load-bearing fix - it caps
  total attempts per issued token regardless of request rate.
- Add authentication_throttle_allows() so plain Django Views can apply
  AuthenticationThrottle without converting to APIView (would change
  CSRF + request-parsing semantics for the redirect-flow endpoints).
- Apply the throttle to MagicSignIn/UpEndpoint and the space variants;
  add throttle_classes to MagicGenerateSpaceEndpoint to match its app
  sibling.

Refs GHSA-9pvm-fcf6-9234.
Copilot AI review requested due to automatic review settings May 25, 2026 10:48
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 25, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 69c3aa6d-b52f-4d52-a970-18c45ea22654

📥 Commits

Reviewing files that changed from the base of the PR and between 4d836df and 8cdbeb9.

📒 Files selected for processing (2)
  • apps/api/plane/authentication/provider/credentials/magic_code.py
  • apps/api/plane/tests/contract/app/test_authentication.py

📝 Walkthrough

Walkthrough

This PR enforces a per-token MAX_VERIFY_ATTEMPTS tracked in Redis and adds per-IP rate-limiting for magic sign-in/sign-up flows, with early throttle checks that redirect throttled requests containing a RATE_LIMIT_EXCEEDED error payload.

Changes

Magic code attempt and rate limiting

Layer / File(s) Summary
Verification attempt limiting in provider
apps/api/plane/authentication/provider/credentials/magic_code.py
Adds MAX_VERIFY_ATTEMPTS = 5, an atomic Redis Lua script/key for per-token :verify_attempts, resets the counter on new token issue, deletes token+counter on successful verify, and deletes token+counter and raises sign-in/sign-up "attempt exhausted" errors when attempts reach the cap.
Rate limiting helper and throttle configuration
apps/api/plane/authentication/rate_limit.py
Makes AuthenticationThrottle.rate configurable via AUTHENTICATION_RATE_LIMIT env var (default 10/minute) and adds authentication_throttle_allows(request) to run throttling logic for non-DRF View flows.
App endpoint rate limiting
apps/api/plane/authentication/views/app/magic.py
Imports the throttle helper and adds early authentication_throttle_allows checks to MagicSignInEndpoint.post and MagicSignUpEndpoint.post that redirect with RATE_LIMIT_EXCEEDED and preserve next_path when throttled.
Space endpoint rate limiting
apps/api/plane/authentication/views/space/magic.py
Adds throttle_classes = [AuthenticationThrottle] to MagicGenerateSpaceEndpoint and adds early authentication_throttle_allows checks to Space sign-in/sign-up endpoints that redirect with RATE_LIMIT_EXCEEDED when throttled.
Deployment env config
deployments/.../variables.env
Adds AUTHENTICATION_RATE_LIMIT=10/minute and explanatory comments to community deployment variable files.
Contract tests and helpers
apps/api/plane/tests/contract/app/test_authentication.py
Adds tests and a helper to validate per-token verify_attempts exhaustion, that regenerate clears counters, and per-IP throttling behavior for redirect-based magic endpoints.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Five hops, five tries, the carrot's near,
Redis counts each timid fear.
Throttles hum to guard the gate,
Redirects steer the patient state.
Magic links now tread with care.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: fixing a brute-force account-takeover vulnerability by adding rate-limiting and per-token attempt bounds to magic-code verification.
Description check ✅ Passed The description provides a thorough explanation of the vulnerability, detailed change summary, test plan, and references the security advisory. All required template sections are present and well-completed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/magic-code-brute-force

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Addresses GHSA-9pvm-fcf6-9234 by hardening magic-link authentication against brute-force attempts and ensuring rate limiting is applied consistently across both DRF APIView and plain django.views.View redirect-flow endpoints.

Changes:

  • Add a per-token verify_attempts counter in Redis for magic-code verification and invalidate the token after too many wrong attempts.
  • Introduce authentication_throttle_allows(request) to apply AuthenticationThrottle to non-DRF (View) endpoints.
  • Apply throttling to magic-link sign-in/sign-up redirect endpoints and add throttling to space magic-link generation.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
apps/api/plane/authentication/views/space/magic.py Adds AuthenticationThrottle to space magic generate endpoint and applies manual throttle checks to space sign-in/sign-up redirect endpoints.
apps/api/plane/authentication/views/app/magic.py Applies manual throttle checks to app sign-in/sign-up redirect endpoints (which are plain View subclasses).
apps/api/plane/authentication/rate_limit.py Adds authentication_throttle_allows() helper for applying AuthenticationThrottle to plain Django requests.
apps/api/plane/authentication/provider/credentials/magic_code.py Persists and enforces a per-token wrong-code attempt counter (verify_attempts) with invalidation after reaching the cap.

Comment thread apps/api/plane/authentication/provider/credentials/magic_code.py Outdated
Comment thread apps/api/plane/authentication/provider/credentials/magic_code.py
Comment thread apps/api/plane/authentication/rate_limit.py
Comment on lines +138 to +142
if verify_attempts >= self.MAX_VERIFY_ATTEMPTS:
# Invalidate the token so further attempts must regenerate
# (which is itself attempt-counted on the generation path).
ri.delete(self.key)
if user_exists:
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Off-by-one resolved by updating the PR description's test plan to match the actual semantics: verify_attempts is checked with >= MAX_VERIFY_ATTEMPTS after increment, so the 5th wrong attempt itself returns EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_IN/UP (4 INVALID responses + 1 EXHAUSTED). This matches the advisory's suggested fix. Updated test plan: "POST 4 wrong codes, then the 5th attempt redirects with EXHAUSTED."

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/plane/authentication/provider/credentials/magic_code.py`:
- Around line 129-141: The current read-modify-write updating of verify_attempts
is racy; replace the JSON round-trip with an atomic Redis operation (e.g.
HINCRBY or a small EVAL Lua script) so increments and TTL management happen
atomically. Concretely, stop parsing/writing the whole JSON blob for
verify_attempts in the logic around verify_attempts/ri.set/ri.ttl/ri.delete and
instead store or update a numeric field atomically (use ri.hincrby(self.key,
"verify_attempts", 1) and ensure TTL with ri.expire(self.key, remaining_ttl) or
use ri.eval to increment the JSON field and reset TTL in one script), then check
the returned incremented value against self.MAX_VERIFY_ATTEMPTS and call
ri.delete(self.key) when it meets/exceeds the limit; apply the same atomic
approach to the regenerate counter path as well.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e300b9ae-05aa-4a81-833d-f4f0c17c5d2d

📥 Commits

Reviewing files that changed from the base of the PR and between e71a8f5 and f817880.

📒 Files selected for processing (4)
  • apps/api/plane/authentication/provider/credentials/magic_code.py
  • apps/api/plane/authentication/rate_limit.py
  • apps/api/plane/authentication/views/app/magic.py
  • apps/api/plane/authentication/views/space/magic.py

Comment thread apps/api/plane/authentication/provider/credentials/magic_code.py Outdated
…via env

Address PR review feedback:

- Replace the JSON read-modify-write of verify_attempts with a Lua
  EVAL script that INCRs a dedicated counter key and EXPIREs it only
  on the first increment. The previous round-trip was racy: parallel
  wrong-code requests could read the same value and both write the
  same incremented count, letting an attacker exceed MAX_VERIFY_ATTEMPTS
  under concurrency. Counter is now reset on each new token issuance
  and cleared on successful verify / exhaustion.
- Make AuthenticationThrottle.rate configurable via the
  AUTHENTICATION_RATE_LIMIT env var (default 10/minute, down from 30
  to tighten the budget on unauth auth-adjacent endpoints). Document
  it in deployments/aio and deployments/cli variables.env.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/plane/authentication/provider/credentials/magic_code.py`:
- Around line 149-163: The code reads remaining_ttl = ri.ttl(self.key) and only
clamps when None or < 0, but Redis may return 0 which causes EXPIRE key 0 to
delete the verify-attempt counter; change the clamp to ensure remaining_ttl is
at least 1 (e.g. if remaining_ttl is None or remaining_ttl <= 0 set to 1, or
replace with remaining_ttl = max(1, int(remaining_ttl or 0))) before calling
ri.eval with self._INCREMENT_VERIFY_ATTEMPTS_SCRIPT and
self._verify_attempts_key(self.key) so verify_attempts logic is preserved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4f090eab-ba62-4ce2-a429-8a841da342dc

📥 Commits

Reviewing files that changed from the base of the PR and between f817880 and 4d836df.

📒 Files selected for processing (4)
  • apps/api/plane/authentication/provider/credentials/magic_code.py
  • apps/api/plane/authentication/rate_limit.py
  • deployments/aio/community/variables.env
  • deployments/cli/community/variables.env
✅ Files skipped from review due to trivial changes (2)
  • deployments/cli/community/variables.env
  • deployments/aio/community/variables.env

Comment thread apps/api/plane/authentication/provider/credentials/magic_code.py
…ttle

Add the contract tests called out in the PR test plan:

- TestMagicSignInVerifyAttempts:
  - test_exhausted_after_max_wrong_attempts: after MAX_VERIFY_ATTEMPTS
    wrong codes the next verify redirects with EMAIL_CODE_ATTEMPT_
    EXHAUSTED_SIGN_IN and both Redis keys are deleted; a follow-up
    verify reports EXPIRED.
  - test_counter_increments_on_each_wrong_attempt: the dedicated
    verify_attempts counter advances by exactly one per wrong POST,
    matching the atomic Lua INCR.
  - test_counter_resets_on_token_regeneration: regenerating the
    magic-link clears the counter so the user isn't pre-locked-out by
    a prior session's wrong attempts.
- TestMagicSignUpVerifyAttempts.test_signup_exhausted_after_max_wrong_attempts:
  the sign-up endpoint returns EMAIL_CODE_ATTEMPT_EXHAUSTED_SIGN_UP on
  the exhausting attempt.
- TestAuthenticationThrottle: exercises authentication_throttle_allows
  on the plain-View redirect-flow endpoints by patching the rate down
  and asserting RATE_LIMIT_EXCEEDED is appended to the redirect URL
  once the per-IP budget is exceeded, for both magic-sign-in and
  magic-sign-up.

Each new class clears Django cache (DRF throttle storage) and the
per-email Redis keys around every test so runs are independent.
ri.ttl() returns 0 when the token has less than one second remaining
(Redis floors to whole seconds). The previous clamp only caught
None and < 0, so a sub-second TTL would pass through and the Lua
script's EXPIRE counter 0 would immediately delete the key — letting
an attacker bypass MAX_VERIFY_ATTEMPTS during the final second of the
token's life. Switch the comparison to <= 0.

Narrow real-world impact (sub-second window, throttle still bounds
the rate) but the cap should hold regardless of timing.
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.

2 participants