fix(security): harden recovery key entropy and reset-password brute-force protection - #2477
fix(security): harden recovery key entropy and reset-password brute-force protection#2477fishzjp wants to merge 2 commits into
Conversation
…orce protection The recovery key was generated with only 24 bits of entropy (secrets.token_hex(3)), and the unauthenticated reset-password endpoint relied solely on a fixed asyncio.sleep(3), which does not throttle concurrent requests. An attacker could exhaust the keyspace in hours and take over the admin account (GHSA-4xcp-6758-rxqv). - Generate recovery keys with secrets.token_urlsafe(32), matching the API-key strength; existing configured keys are preserved, and legacy low-entropy keys trigger a startup warning instead of being reset. - Add a failure-counter lockout on the endpoint: after 5 wrong keys every attempt (including correct ones) is rejected with 429 for 15 minutes, checked before the sleep and any service call. - Compare the key with hmac.compare_digest on encoded bytes so malformed or non-ASCII payloads fail closed instead of leaking a timing side channel. Fixes langbot-app#2392.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
dadachann
left a comment
There was a problem hiding this comment.
Two blocking issues remain:
- The web reset-password form is still hard-coded to a six-character, alphanumeric OTP and uppercases the value.
token_urlsafe(32)produces a case-sensitive 43-character base64url key (including possible-/_), so newly generated keys cannot be submitted through the UI. - The lockout check happens before an await, while failure accounting happens after multiple awaits. Concurrent requests can all pass the initial check before any request sets
locked_until; a 20-request barrier test produced 20×403 and 0×429. This does not enforce the claimed five attempts per 15 minutes and leaves the concurrent brute-force path open.
Please adapt the frontend input and make attempt admission/accounting concurrency-safe, with regression tests for both.
| ap.instance_config.data['system']['recovery_key'] = secrets.token_hex(3).upper() | ||
| # 256-bit key, aligned with the API key strength; the legacy 24-bit | ||
| # key (token_hex(3)) was brute-forceable within hours (#2392). | ||
| ap.instance_config.data['system']['recovery_key'] = secrets.token_urlsafe(32) |
There was a problem hiding this comment.
Blocking frontend compatibility issue: token_urlsafe(32) is 43 characters and case-sensitive, with -/_ possible. web/src/app/reset-password/page.tsx still uses InputOTP maxLength={6}, an alphanumeric-only pattern, six slots, and uppercases every value. A newly generated recovery key therefore cannot be entered through the UI. Please update the reset form and add frontend coverage.
| new_password = json_data['new_password'] | ||
|
|
||
| # Reject while locked out, before any sleep or service call (#2392) | ||
| if time.monotonic() < _recovery_key_state['locked_until']: |
There was a problem hiding this comment.
Blocking concurrency bypass: every concurrent request can pass this check before the first await asyncio.sleep(3), while the failure counter/lock is only updated after later awaits. In a 20-request barrier test, all 20 wrong guesses returned 403 (none returned 429), despite the five-attempt limit. Attempt admission/accounting needs to be atomic/concurrency-safe, and the regression test should launch concurrent requests.
Admission check and slot bump now share one await-free synchronous critical section, so concurrent bursts within a single event loop can no longer slip past failure accounting (langbot-app#2392). Quota uses a rolling 15-minute fixed window and every admitted attempt consumes it, throttling both legacy 24-bit keyspace exhaustion and brute-force on modern high-entropy keys. Web: replace the digit-capped OTP input with a plain monospace field so full-length case-sensitive recovery keys can be entered verbatim. Tests: rewrite unit coverage around fixed-window semantics and add a concurrent-burst regression asserting {403: 5, 429: 15} for 20-way races.
Follow-up hardening —
|
| Check | Result |
|---|---|
Concurrent-burst regression (asserts exactly {403: 5, 429: 15}) ×10 runs |
10/10 pass |
| Unit tests (target file) | 12 pass |
| Full unit suite | 2808 pass, 1 skip |
| Integration suite | 208 pass, 33 skip (env-dependent services) |
ruff check + ruff format --check |
pass |
web tsc --noEmit |
pass |
| web eslint (full project) | 0 errors; 34 warnings all pre-existing outside touched files |
| Adversarial script: burst distribution / honest path / window rollover | 3/3 phases PASS |
Also verified per repo conventions: /reset-password is AuthType.NONE and not exposed by LangBot's own MCP server, so no MCP tool / skill sync was required.
Fixes #2392(对应 GHSA-4xcp-6758-rxqv)。
概述 / Overview
恢复密钥此前仅由
secrets.token_hex(3)生成(6 位 hex = 24 位熵,全键空间 1677 万),而未鉴权的POST /api/v1/user/reset-password端点唯一的防自动化措施是固定的asyncio.sleep(3)——异步 sleep 不限制并发吞吐,攻击者可用并发请求在数小时内穷举键空间并重置管理员密码。三层加固:
secrets.token_urlsafe(32)(256 位,与 API key 强度对齐)。已配置的旧密钥不会被自动重置(避免锁死现有部署),但检测到旧低熵密钥(长度 < 16)时启动输出 warning 提示运维更换。hmac.compare_digest(bytes 编码),非字符串 / 非 ASCII 载荷安全降级为拒绝,消除时序侧信道。更改前后对比 / Before & After
修改前(并发爆破,
sleep(3)形同虚设):修改后:
验证 / Verification
tests/unit_tests/api/test_user_reset_password.py),修复前全部失败(_recovery_key_state不存在 / 旧 6 字符密钥),修复后全部通过,覆盖:uv run pytest tests/unit_tests -q— 2807 passed, 1 skipped(master 基线 2796 + 11)uv run pytest tests/integration/api/test_smoke.py -k recovery— 通过(存量集成测试兼容)token_urlsafe(32)产物经skills/scripts/e2e的读取正则 + YAML 往返 1000 次模拟全部匹配uv run ruff check/ruff format --check— 通过兼容性说明 / Compatibility
{"code":-1,"msg":...})检查清单 / Checklist
PR 作者完成 / For PR author
项目维护者完成 / For project maintainer
system.recovery_key配置键)