Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

519 lines
20 KiB
Python

"""
Tests for authentication rate limiting (login and registration endpoints).
Tests that brute force protection is working correctly.
"""
import shutil
import tempfile
from pathlib import Path
import pytest
class TestAuthRateLimiting:
"""Test rate limiting on authentication endpoints."""
@pytest.fixture
def temp_data_dir(self):
"""Create a temporary data directory for testing."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
def app(self, temp_data_dir, monkeypatch):
"""Create a test Flask app with rate limiting."""
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
from local_deep_research.database.auth_db import init_auth_database
from local_deep_research.database.encrypted_db import db_manager
from local_deep_research.web.app_factory import create_app
from local_deep_research.security.rate_limiter import limiter
# Reset db_manager state
db_manager.close_all_databases()
db_manager.data_dir = temp_data_dir / "encrypted_databases"
db_manager.data_dir.mkdir(parents=True, exist_ok=True)
# Initialize auth database
init_auth_database()
app, _ = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False # Disable CSRF for testing
# In CI, rate limiting is disabled at startup so init_app() returns
# early without initializing storage or registering error handlers.
# Re-call init_app() with RATELIMIT_ENABLED=True to fully initialize.
app.config["RATELIMIT_ENABLED"] = True
limiter.enabled = True
limiter.init_app(app)
# Reset limiter storage between tests to prevent cross-test pollution
limiter.reset()
yield app
# Restore original state
limiter.enabled = True
db_manager.close_all_databases()
@pytest.fixture
def client(self, app):
"""Create a test client."""
return app.test_client()
def test_login_rate_limit_allows_5_attempts(self, client):
"""Test that login allows 5 attempts before rate limiting."""
# Make 5 login attempts - should all be allowed
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: tighten to assert status_code == 401 (invalid creds), not membership of 3 codes).
for i in range(5):
response = client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
follow_redirects=False,
)
# Should get 401 (invalid credentials), NOT 429 (rate limit).
# Tightened from `status_code in [200, 401, 400]` (PUNCHLIST
# H8_STATUS_OR) — that broad list masked bugs where the auth
# path silently returned 400 / 200 for wrong credentials.
assert response.status_code == 401, (
f"Attempt {i + 1}: wrong-credential login must return 401, "
f"got {response.status_code}"
)
def test_login_rate_limit_blocks_6th_attempt(self, client):
"""Test that login blocks the 6th attempt within 15 minutes."""
# Make 5 login attempts
for i in range(5):
client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
)
# 6th attempt should be rate limited
response = client.post(
"/auth/login",
data={"username": "testuser6", "password": "wrongpassword"},
)
assert response.status_code == 429, "6th attempt should be rate limited"
def test_login_rate_limit_returns_proper_error(self, client):
"""Test that rate limit returns proper JSON error response."""
# Trigger rate limit
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: remove conditional — assert response.status_code == 429 unconditionally before checking body).
for i in range(6):
response = client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
)
# Confirm rate limit actually fired before inspecting the body.
# Previously the body checks were gated by `if response.status_code == 429`,
# which silently passed when rate limiting didn't fire — masking real bugs.
assert response.status_code == 429, (
"6th attempt must be rate-limited; "
f"got status {response.status_code}"
)
data = response.get_json()
assert "error" in data
assert "message" in data
assert "Too many" in data["message"] or "Too many" in data["error"]
def test_login_rate_limit_includes_retry_after_header(self, client):
"""Test that 429 response includes Retry-After header."""
# Trigger rate limit
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: remove conditional and assert 429 first).
for i in range(6):
response = client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
)
# Confirm rate limit actually fired before inspecting headers.
# Previously the header check was gated by `if response.status_code == 429`,
# which silently passed when rate limiting didn't fire.
assert response.status_code == 429, (
"6th attempt must be rate-limited; "
f"got status {response.status_code}"
)
assert (
"Retry-After" in response.headers
or "X-RateLimit-Reset" in response.headers
), "Rate limit response should include retry timing header"
def test_registration_rate_limit_allows_3_attempts(self, client):
"""Test that registration allows 3 attempts before rate limiting."""
# Make 3 registration attempts - should all be allowed
for i in range(3):
response = client.post(
"/auth/register",
data={
"username": f"newuser{i}",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
follow_redirects=False,
)
# Should get 200/302 (success/redirect) or 400 (validation error)
# but not 429 (rate limit)
assert response.status_code in [
200,
302,
400,
], f"Attempt {i + 1} should not be rate limited"
def test_registration_rate_limit_blocks_4th_attempt(self, client):
"""Test that registration blocks the 4th attempt within 1 hour."""
# Make 3 registration attempts
for i in range(3):
client.post(
"/auth/register",
data={
"username": f"newuser{i}",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# 4th attempt should be rate limited
response = client.post(
"/auth/register",
data={
"username": "newuser4",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code == 429, "4th attempt should be rate limited"
def test_password_change_rate_limit_blocks_6th_attempt(self, client, app):
"""Test that password change blocks the 6th attempt."""
# Disable exception propagation so template rendering errors
# (e.g. missing 'research.index' endpoint) return 500 instead
# of crashing the test. Rate limiting fires before the handler,
# so non-429 responses still count toward the limit.
app.config["PROPAGATE_EXCEPTIONS"] = False
app.config["TESTING"] = False
with client.session_transaction() as sess:
sess["username"] = "testuser"
for i in range(5):
response = client.post(
"/auth/change-password",
data={
"current_password": "",
"new_password": "NewStrongP4ss!",
"confirm_password": "NewStrongP4ss!",
},
)
assert response.status_code != 429, (
f"Attempt {i + 1} should not be rate limited"
)
# 6th attempt should be rate limited
response = client.post(
"/auth/change-password",
data={
"current_password": "",
"new_password": "NewStrongP4ss!",
"confirm_password": "NewStrongP4ss!",
},
)
assert response.status_code == 429, (
"6th password change attempt should be rate limited"
)
def test_password_change_has_separate_limit_from_login(self, client, app):
"""Test that login and password change have independent rate limits."""
app.config["PROPAGATE_EXCEPTIONS"] = False
app.config["TESTING"] = False
# Exhaust login limit (5 attempts)
for i in range(5):
client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
)
# Verify login is rate limited
response = client.post(
"/auth/login",
data={"username": "testuser6", "password": "wrongpassword"},
)
assert response.status_code == 429, "Login should be rate limited"
# Password change should still work (separate scope)
with client.session_transaction() as sess:
sess["username"] = "testuser"
response = client.post(
"/auth/change-password",
data={
"current_password": "",
"new_password": "NewStrongP4ss!",
"confirm_password": "NewStrongP4ss!",
},
)
assert response.status_code != 429, (
"Password change should have separate rate limit from login"
)
def test_different_endpoints_have_separate_limits(self, client):
"""Test that login and registration have independent rate limits."""
# Exhaust login limit (5 attempts)
for i in range(5):
client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
)
# Registration should still work (separate limit)
response = client.post(
"/auth/register",
data={
"username": "newuser1",
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
assert response.status_code != 429, (
"Registration should have separate rate limit from login"
)
def test_rate_limit_is_per_ip(self, client, app):
"""Test that rate limiting is applied per IP address."""
# Make 5 requests from "IP 1"
for i in range(5):
with app.test_request_context(
"/auth/login",
method="POST",
environ_base={"REMOTE_ADDR": "192.168.1.1"},
):
client.post(
"/auth/login",
data={
"username": f"testuser{i}",
"password": "wrongpassword",
},
environ_base={"REMOTE_ADDR": "192.168.1.1"},
)
# 6th request from "IP 1" should be rate limited
response1 = client.post(
"/auth/login",
data={"username": "testuser6", "password": "wrongpassword"},
environ_base={"REMOTE_ADDR": "192.168.1.1"},
)
# Request from "IP 2" should still work (different IP)
response2 = client.post(
"/auth/login",
data={"username": "testuser7", "password": "wrongpassword"},
environ_base={"REMOTE_ADDR": "192.168.1.2"},
)
assert response1.status_code == 429, "IP 1 should be rate limited"
assert response2.status_code != 429, (
"IP 2 should not be rate limited (different IP)"
)
def test_proxy_headers_are_respected(self, client):
"""Test that X-Forwarded-For headers are used for rate limiting."""
# Make 5 requests with same X-Forwarded-For header
for i in range(5):
client.post(
"/auth/login",
data={"username": f"testuser{i}", "password": "wrongpassword"},
headers={"X-Forwarded-For": "10.0.0.1"},
)
# 6th request with same X-Forwarded-For should be rate limited
response = client.post(
"/auth/login",
data={"username": "testuser6", "password": "wrongpassword"},
headers={"X-Forwarded-For": "10.0.0.1"},
)
assert response.status_code == 429, (
"Requests from same X-Forwarded-For IP should be rate limited"
)
def test_successful_login_still_counts_toward_limit(self, client):
"""Test that successful logins also count toward rate limit."""
# This prevents attackers from resetting the limit with valid credentials
# Create a test user first (this uses the programmatic API, not the web endpoint)
from local_deep_research.database.encrypted_db import db_manager
test_username = "ratelimituser"
test_password = "TestPass123"
# Create user if doesn't exist
if not db_manager.user_exists(test_username):
db_manager.create_user_database(test_username, test_password)
# Make 5 successful login attempts
for i in range(5):
client.post(
"/auth/login",
data={"username": test_username, "password": test_password},
)
# 6th attempt should still be rate limited, even with valid credentials
response = client.post(
"/auth/login",
data={"username": test_username, "password": test_password},
)
assert response.status_code == 429, (
"Even successful logins should count toward rate limit"
)
# Clean up: delete test user
db_manager.close_user_database(test_username)
def test_account_enumeration_prevented(self, client):
"""Test that registration errors don't reveal username existence."""
# First, register a real user so the second attempt below collides.
# The `app` fixture scope is `function`, so the DB is fresh; the user
# this test relies on must be created here rather than depending on
# any other test's side effects.
# audit: PUNCHLIST reviewed 2026-05 — issue resolved by prior PR (recommendation: unconditionally assert both responses == 400 before content checks).
existing_username = "enum_target_user"
setup_response = client.post(
"/auth/register",
data={
"username": existing_username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Registration must succeed for the rest of the test to be meaningful.
# Accept 200 or 302 (some flows redirect after success).
assert setup_response.status_code in (200, 201, 302), (
"Setup user registration must succeed; "
f"got {setup_response.status_code}"
)
# Try to register with a username that definitely doesn't exist
# but with an invalid password so validation rejects it.
response1 = client.post(
"/auth/register",
data={
"username": "definitelynonexistentuser12345",
"password": "short", # Will fail validation
"confirm_password": "short",
"acknowledge": "true",
},
)
# Try to register with the username that now exists (collision)
response2 = client.post(
"/auth/register",
data={
"username": existing_username,
"password": "TestPass123",
"confirm_password": "TestPass123",
"acknowledge": "true",
},
)
# Both should return generic errors, not revealing if username exists.
# Previously the body checks were gated by
# `if response1.status_code == 400 and response2.status_code == 400`,
# which silently passed if either response was anything else — masking
# account-enumeration regressions (the whole point of this test). Worse,
# the test relied on a leftover user from a sibling test, which never
# existed because the fixture is function-scoped, so response2 was
# always a successful registration and the body checks were always
# skipped.
assert response1.status_code == 400, (
"Short-password registration must return 400; "
f"got {response1.status_code}"
)
assert response2.status_code == 400, (
"Duplicate-username registration must return 400; "
f"got {response2.status_code}"
)
# Check that error messages are generic
data2 = response2.get_data(as_text=True)
# Should NOT contain "Username already exists"
assert "Username already exists" not in data2, (
"Error should not reveal username existence"
)
# Should contain generic message
assert (
"Registration failed" in data2
or "try a different username" in data2
), "Should use generic error message"
class TestRateLimitReset:
"""Test that rate limits reset after the time window."""
@pytest.fixture
def temp_data_dir(self):
"""Create a temporary data directory for testing."""
temp_dir = tempfile.mkdtemp()
yield Path(temp_dir)
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
def app(self, temp_data_dir, monkeypatch):
"""Create a test Flask app with rate limiting."""
monkeypatch.setenv("LDR_DATA_DIR", str(temp_data_dir))
from local_deep_research.database.auth_db import init_auth_database
from local_deep_research.database.encrypted_db import db_manager
from local_deep_research.web.app_factory import create_app
from local_deep_research.security.rate_limiter import limiter
db_manager.close_all_databases()
db_manager.data_dir = temp_data_dir / "encrypted_databases"
db_manager.data_dir.mkdir(parents=True, exist_ok=True)
init_auth_database()
app, _ = create_app()
app.config["TESTING"] = True
app.config["WTF_CSRF_ENABLED"] = False
# Enable rate limiting AFTER init_app (CI sets LDR_DISABLE_RATE_LIMITING=true)
app.config["RATELIMIT_ENABLED"] = True
limiter.enabled = True
limiter.init_app(app)
limiter.reset()
yield app
db_manager.close_all_databases()
@pytest.fixture
def client(self, app):
"""Create a test client."""
return app.test_client()
@pytest.mark.slow
def test_rate_limit_resets_after_time_window(self, client):
"""Test that rate limit resets after 15 minutes (for login)."""
# Note: This test would take 15 minutes to run in real time
# In practice, you'd mock time or use a shorter limit for testing
pytest.skip(
"This test requires mocking time or waiting 15 minutes - "
"implement with time mocking if needed"
)
# Implementation would look like:
# 1. Make 5 login attempts
# 2. Verify 6th attempt is blocked
# 3. Fast-forward time by 15 minutes (using time mocking)
# 4. Verify new attempt is allowed