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
519 lines
20 KiB
Python
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
|