Files
LangBot/tests/unit_tests/api/test_user_reset_password.py
T
fishzjp 267232c24f fix(security): harden password recovery with usable eight-character codes (#2477)
Use eight securely random recovery-code characters with concurrency-safe online throttling. Preserve existing keys and verify recovery through browser and real SQLite integration tests.

Co-authored-by: zhangjinpeng@mail.tuchong.com <zhangjinpeng@mail.tuchong.com>
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-07 15:31:54 +00:00

303 lines
12 KiB
Python

"""Regression tests for recovery-key hardening (#2392).
Covers two attack surfaces reported in GHSA-4xcp-6758-rxqv:
1. ``genkeys.py`` generated ``system.recovery_key`` with only 24 bits of
entropy (``secrets.token_hex(3)``), making the whole keyspace brute-forceable.
2. ``POST /api/v1/user/reset-password`` (unauthenticated) checked its failure
counter across ``await`` points, so concurrent guesses all passed the gate
before any accounting happened; admission is now a synchronous fixed-window
quota consumed at entry, plus constant-time key comparison.
"""
from __future__ import annotations
import asyncio
import logging
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import quart
from langbot.pkg.api.http.controller.groups import user as user_module
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
from langbot.pkg.core.stages.genkeys import GenKeysStage
pytestmark = pytest.mark.asyncio
STORED_KEY = 'ABCD2345'
@pytest.fixture(autouse=True)
def _reset_quota_state():
"""Reset the module-level admission-quota state before each test."""
user_module._reset_password_state['window_started_at'] = 0.0
user_module._reset_password_state['attempts'] = 0
yield
user_module._reset_password_state['window_started_at'] = 0.0
user_module._reset_password_state['attempts'] = 0
@pytest.fixture(autouse=True)
def _fast_sleep(monkeypatch):
"""Neutralize the fixed 3s delay so tests run instantly."""
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=AsyncMock()))
# ---------------------------------------------------------------------------
# genkeys.py: recovery-key generation and compatibility
# ---------------------------------------------------------------------------
def _make_genkeys_ap(existing_key: str) -> SimpleNamespace:
"""Build a minimal Application mock for GenKeysStage.
Mirrors the real boot order: no ``logger`` attribute is set because
GenKeysStage runs before SetupLoggerStage.
"""
return SimpleNamespace(
instance_config=SimpleNamespace(
data={'system': {'jwt': {'secret': 'jwt-secret'}, 'recovery_key': existing_key}},
dump_config=AsyncMock(),
),
)
async def test_recovery_key_generation_is_short_and_unambiguous():
"""Eight random base32 characters balance manual entry and online throttling."""
ap = _make_genkeys_ap(existing_key='')
await GenKeysStage().run(ap)
key = ap.instance_config.data['system']['recovery_key']
assert len(key) == 8
assert set(key) <= set('23456789ABCDEFGHJKLMNPQRSTUVWXYZ')
assert ap.instance_config.dump_config.called
async def test_legacy_low_entropy_key_preserved_with_warning(caplog):
"""A legacy 6-char key must keep working but emit a warning, without ap.logger."""
ap = _make_genkeys_ap(existing_key='ABC123')
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
await GenKeysStage().run(ap)
assert ap.instance_config.data['system']['recovery_key'] == 'ABC123'
assert any('Low-entropy' in record.message for record in caplog.records)
assert not ap.instance_config.dump_config.called
@pytest.mark.parametrize('existing_key', ['ABC123', 'ABCD2345', 'aB-_' * 10 + 'xYz', '自定义恢复密钥'])
async def test_recovery_key_generation_preserves_existing_key(existing_key):
"""An explicitly configured recovery key must not be regenerated on boot."""
ap = _make_genkeys_ap(existing_key=existing_key)
await GenKeysStage().run(ap)
assert ap.instance_config.data['system']['recovery_key'] == existing_key
assert not ap.instance_config.dump_config.called
async def test_generated_key_is_preserved_without_legacy_warning(caplog):
"""A restart must not warn about or replace the new eight-character key."""
ap = _make_genkeys_ap(existing_key='')
await GenKeysStage().run(ap)
key = ap.instance_config.data['system']['recovery_key']
assert len(key) == 8
ap.instance_config.dump_config.reset_mock()
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
await GenKeysStage().run(ap)
assert ap.instance_config.data['system']['recovery_key'] == key
assert not caplog.records
ap.instance_config.dump_config.assert_not_awaited()
async def test_eight_character_key_does_not_trigger_legacy_warning(caplog):
ap = _make_genkeys_ap(existing_key='ABCD2345')
with caplog.at_level(logging.WARNING, logger='langbot.pkg.core.stages.genkeys'):
await GenKeysStage().run(ap)
assert not caplog.records
# ---------------------------------------------------------------------------
# POST /api/v1/user/reset-password: admission quota + constant-time compare
# ---------------------------------------------------------------------------
async def _create_client(stored_key: str = STORED_KEY):
"""Create a Quart test client with a mocked Application."""
quart_app = quart.Quart(__name__)
user_obj = SimpleNamespace(uuid='user-uuid', user='admin@example.com')
reset_password = AsyncMock()
get_user_by_email = AsyncMock(return_value=user_obj)
ap = SimpleNamespace(
user_service=SimpleNamespace(
is_initialized=AsyncMock(return_value=True),
get_user_by_email=get_user_by_email,
reset_password=reset_password,
),
instance_config=SimpleNamespace(
data={'system': {'recovery_key': stored_key}},
),
)
router = UserRouterGroup(ap, quart_app)
await router.initialize()
client = quart_app.test_client()
return client, reset_password, get_user_by_email
def _payload(key: str = STORED_KEY) -> dict:
return {'user': 'admin@example.com', 'recovery_key': key, 'new_password': 'NewPass1!'}
@pytest.mark.parametrize('key', [STORED_KEY, 'ABC123', 'aB-_' * 10 + 'xYz', '自定义恢复密钥'])
async def test_correct_key_resets_password(key):
"""New, legacy and explicitly configured keys all remain usable verbatim."""
client, reset_password, _ = await _create_client(stored_key=key)
resp = await client.post('/api/v1/user/reset-password', json=_payload(key))
assert resp.status_code == 200
assert (await resp.get_json())['code'] == 0
reset_password.assert_awaited_once_with('admin@example.com', 'NewPass1!')
async def test_wrong_key_rejected_without_reset():
"""A wrong recovery key returns 403 and never touches the password."""
client, reset_password, _ = await _create_client()
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
assert resp.status_code == 403
reset_password.assert_not_awaited()
async def test_non_string_recovery_key_does_not_crash():
"""Malformed recovery-key payloads must be rejected, not raise a 500.
Constant-time comparison via hmac.compare_digest on bytes requires the
input to be a str; other JSON types must fail closed.
"""
client, reset_password, _ = await _create_client()
resp = await client.post(
'/api/v1/user/reset-password',
json={'user': 'admin@example.com', 'recovery_key': 12345, 'new_password': 'NewPass1!'},
)
assert resp.status_code == 403
reset_password.assert_not_awaited()
@pytest.mark.parametrize('key', ['奇数密钥不是ASCII', '\ud800', '\udfff'])
async def test_non_ascii_recovery_key_does_not_crash(key):
"""Non-ASCII keys must compare safely (encode-based constant-time compare)."""
client, _, _ = await _create_client()
resp = await client.post(
'/api/v1/user/reset-password',
json={'user': 'admin@example.com', 'recovery_key': key, 'new_password': 'NewPass1!'},
)
assert resp.status_code == 403
async def test_quota_exhausted_after_max_attempts():
"""After MAX admitted attempts even a correct key must be rejected with 429 (#2392).
Every admission consumes quota regardless of outcome; the legacy endpoint
accepted every guess independently, exhausting the 24-bit keyspace via bursts.
"""
client, reset_password, _ = await _create_client()
for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW):
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
assert resp.status_code == 403
# The very next request carries the CORRECT key but has no quota left.
resp = await client.post('/api/v1/user/reset-password', json=_payload())
assert resp.status_code == 429
reset_password.assert_not_awaited()
async def test_quota_rejects_before_touching_user_lookup():
"""An exhausted quota must reject early, before the sleep and any service calls."""
client, _, get_user_by_email = await _create_client()
user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW
user_module._reset_password_state['window_started_at'] = time.monotonic()
resp = await client.post('/api/v1/user/reset-password', json=_payload())
assert resp.status_code == 429
get_user_by_email.assert_not_awaited()
async def test_window_rolls_over_and_admits_again():
"""Once the fixed window elapses, the quota resets and a correct key works again."""
client, reset_password, _ = await _create_client()
user_module._reset_password_state['attempts'] = user_module._MAX_RESET_ATTEMPTS_PER_WINDOW
user_module._reset_password_state['window_started_at'] = time.monotonic() - user_module._RESET_WINDOW_SECONDS - 1
resp = await client.post('/api/v1/user/reset-password', json=_payload())
assert resp.status_code == 200
reset_password.assert_awaited_once()
async def test_success_does_not_restore_quota():
"""A successful reset does NOT restore quota: brute-force budget survives wins (#2392).
The legacy clear-on-success let attackers interleave correct-looking states;
success only proves knowledge of the key once, it must not refill attempts.
"""
client, _, _ = await _create_client()
for _ in range(user_module._MAX_RESET_ATTEMPTS_PER_WINDOW - 1):
resp = await client.post('/api/v1/user/reset-password', json=_payload(key='WRONG'))
assert resp.status_code == 403
# Last slot is spent on the genuine reset.
resp = await client.post('/api/v1/user/reset-password', json=_payload())
assert resp.status_code == 200
# Quota is exhausted; even a correct key waits for the next window.
resp = await client.post('/api/v1/user/reset-password', json=_payload())
assert resp.status_code == 429
async def test_concurrent_burst_cannot_bypass_quota(monkeypatch):
"""A 20-request burst yields exactly {403: 5, 429: 15} (#2392 regression).
The vulnerable version accounted failures after several awaits, letting all
concurrent requests pass the gate ({403: 20}). Admission is now synchronous
and await-free, so total admissions are capped regardless of scheduling.
"""
# Swap the AsyncMock sleep for a real cooperative yield so tasks actually
# interleave mid-handler like they do under production load.
async def _yield_sleep(_seconds):
await asyncio.sleep(0)
monkeypatch.setattr(user_module, 'asyncio', SimpleNamespace(sleep=_yield_sleep))
client, reset_password, _ = await _create_client()
responses = await asyncio.gather(
*(client.post('/api/v1/user/reset-password', json=_payload(key='WRONG')) for _ in range(20))
)
status_counts: dict[int, int] = {}
for resp in responses:
status_counts[resp.status_code] = status_counts.get(resp.status_code, 0) + 1
assert status_counts == {403: 5, 429: 15}
reset_password.assert_not_awaited()