The new generator only runs when no key exists, so an installation that already has one keeps its 16.7-million-value secret and gets a warning written to stderr at boot — protected now only by a request quota the code itself notes is process-local.
What happened
NVD published CVE-2026-90562 at 11:17 a.m. UTC on Sunday, September 13, 2026. The record states: “LangBot before 4.10.11 generates password recovery keys with only 24 bits of entropy and applies no rate limiting to the unauthenticated reset-password endpoint.”
LangBot’s own package metadata calls it a “Production-grade platform for building agentic IM bots,” used to “quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.” It ships a web administration panel, and that panel is where the affected endpoint lives.
VulnCheck is the CNA and supplied both scores in the record, which do not agree on how bad this is. The CVSS v3.1 base score is 8.1 High, vector CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H, marked Primary. The CVSS v4.0 base score is 9.2 Critical, marked Secondary, with AT:P standing where v3.1 put AC:H. Same flaw, same assessor, and the severity label flips across the two frameworks. The weakness is CWE-331, insufficient entropy. The affected range runs from 4.0.8.1 up to but not including 4.10.11.
We downloaded the 4.10.10 and 4.10.11 wheels from PyPI and compared them, because the interesting part of this record is not the flaw but what the fix does and does not do.
In 4.10.10, pkg/core/stages/genkeys.py generates the recovery key once, at first boot:
ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper()
Three bytes of randomness rendered as six uppercase hexadecimal characters. That is 16,777,216 possible values — a number a laptop can enumerate in seconds if it is allowed to try.
The endpoint that checks it, in pkg/api/http/controller/groups/user.py, is registered with auth_type=group.AuthType.NONE. It reads a user email, a recovery key, and a new password from the request body, and the only brake on it is one line:
# hard sleep 3s for security
await asyncio.sleep(3)
That is an asynchronous sleep, not a lock. It delays each individual request by three seconds; it does not serialize them. Requests arriving in parallel sleep in parallel. An attacker holding 500 concurrent connections sustains roughly 167 attempts per second, which sweeps the entire 16.7-million keyspace in about 28 hours and expects a hit in about 14. Those figures are arithmetic from the code, not measured against a live instance, and the concurrency number is an illustration rather than an observation. The comparison in that version is also a plain != against the stored string.
What the fix changes, and what it leaves alone
Version 4.10.11 was published to PyPI on September 12, 2026 at 4:51 a.m. UTC. It does three things. It replaces the generator with eight draws from a 32-symbol alphabet, 23456789ABCDEFGHJKLMNPQRSTUVWXYZ, which the source comment describes as giving “40 random bits” — about 1.1 trillion values. It swaps the string comparison for hmac.compare_digest. And it adds a fixed-window admission quota in front of the endpoint: five attempts per 15 minutes, checked before the request body is even read.
All three are sound. The problem is the condition the new generator sits behind. In 4.10.11, the strong key is produced only when the stored key is empty:
if not ap.instance_config.data['system']['recovery_key']:
... generate the 40-bit key ...
elif len(ap.instance_config.data['system']['recovery_key']) < _RECOVERY_KEY_LENGTH:
_logger.warning(
'Low-entropy legacy recovery key detected (length < 8); '
'regenerate system.recovery_key in the configuration file '
'with a strong random value (#2392)'
)
An installation that has been running any earlier version already has a key, so it takes the elif branch. It keeps the six-character, 24-bit value it was issued at first boot, and emits a warning. Upgrading to the version NVD marks as unaffected does not remove the weak secret from a system that already has one. Only a human editing system.recovery_key in the configuration file does that.
The warning is easy to miss by design rather than by accident. A comment directly above it explains why: “This stage runs before SetupLoggerStage, so ap.logger is still None here; the module logger falls back to the stderr lastResort handler.” The one notice an upgrading operator gets is written by Python’s fallback handler to stderr, at boot, before the application’s logging is configured. In a container deployment that is a single unformatted line in the startup output.
The maintainers clearly know the old keys persist, because the limiter is written to cover them. Its comment says the quota “throttles both the legacy 24-bit keyspace exhaustion and brute-force on modern high-entropy keys.” On the arithmetic, it does: five attempts per 15 minutes against 16,777,216 values is roughly 96 years for a full sweep. The same comment states the limiter’s own boundary: “NOTE: this state is process-local; multi-worker deployments need a shared limiter upstream.” The quota is a module-level dictionary. Run LangBot behind four workers and the effective ceiling is twenty attempts per 15 minutes, not five.
Why it matters
The substance here is a version range that says “fixed” about a deployed system that is not. NVD’s record marks 4.10.11 and later unaffected, and every scanner reading that record will agree. For a fresh install that is correct. For the installed base — every operator who has been running LangBot for months and upgrades this week — it is wrong in the way that matters, because the secret an attacker has to guess is unchanged. What changed is the cost of guessing it, and that protection now rests entirely on a process-local counter whose own author documented where it stops working.
Secret rotation on upgrade is a hard problem and not rotating automatically is a defensible choice: a key silently replaced during a routine version bump locks out any operator who wrote the old one down, which is the whole point of a recovery key. The failure is not the decision. It is that the decision is communicated through a stderr line emitted before logging is set up, and that neither the CVE record, the advisory, nor the release notes say the words “existing installations keep their old key.” An operator who reads the CVE, upgrades, and closes the ticket has done everything the public record asked of them and is still holding a 24-bit secret.
The score divergence is worth noting for anyone triaging by number. The same assessor rated this 8.1 High under CVSS v3.1 and 9.2 Critical under v4.0. An organization whose threshold sits at 9.0 will or will not page someone depending on which column its tooling reads. Nothing about the flaw differs between the two rows.
This is the second story on this site today in which a shipped fix lands incompletely on real deployments. The ESPnet checkpoint deserialization fix published this morning depends on a PyTorch version the package does not require. The mechanism differs; the shape does not. A version number is a claim about a build artifact, and increasingly the security property depends on state outside that artifact — a dependency’s version, a value already written into a config file. Vulnerability records have no field for either.
What to do
Upgrade to LangBot 4.10.11. Then, separately, rotate the key — the upgrade will not.
Open the instance configuration and look at system.recovery_key. If it is six characters long, it is the old 24-bit value. Replace it and restart. Anything from python -c "import secrets; print(secrets.token_urlsafe(24))" will do; it only needs to be typeable if a human will type it.
Check the boot output for the string “Low-entropy legacy recovery key detected.” Its presence confirms the weak key is still in place. Its absence after a restart confirms the rotation took.
If you run LangBot behind more than one worker process, treat the built-in quota as advisory and put a real limit in front of /api/v1/user/reset-password at your reverse proxy. The endpoint is unauthenticated by design and has no legitimate reason to accept more than a handful of requests from the internet in any window.
Better still, do not expose the administration panel to the internet. Nothing about an IM bot platform requires its admin API to be publicly reachable, and an unauthenticated password-reset endpoint is a poor thing to leave facing outward regardless of the entropy behind it.
Sourcing note
Checked: the NVD record for CVE-2026-90562, including both CVSS metrics and their source; VulnCheck’s advisory page; the referenced GitHub commit; and the langbot 4.10.10 and 4.10.11 wheels downloaded from PyPI, read directly. Every code quotation above — the generator in both versions, the elif branch, the logger comment, the asyncio.sleep(3) line, and the limiter’s own comments about legacy keys and multi-worker deployments — comes from those two artifacts, not from any advisory. No advisory states them.
The keyspace and timing figures are arithmetic. The 28-hour and 14-hour sweeps assume 500 concurrent connections against a single instance and are offered to show that a three-second asynchronous sleep is not a rate limit; nobody has demonstrated this against a live target, and we did not attempt it.
cisa.gov returns 403 to automated fetching; we checked the cisagov/kev-data mirror instead. Catalog version 2026.09.11, 1,709 entries. CVE-2026-90562 is not listed and no federal deadline applies.
Unresolved: how much of the LangBot installed base is internet-facing, which nobody has measured. No source reports exploitation, and VulnCheck makes no exploitation claim. Whether a future release will force rotation of short legacy keys rather than warning about them is an open question for the project; the linked issue is #2392.