194 lines
5.5 KiB
Python
194 lines
5.5 KiB
Python
import sqlite3
|
|
|
|
from src.login_attempt_store import LoginAttemptStore, client_source
|
|
|
|
|
|
def test_forwarded_client_is_used_only_for_explicitly_trusted_proxies():
|
|
trusted = "127.0.0.0/8, 10.0.0.0/8"
|
|
|
|
assert client_source("198.51.100.4", "203.0.113.7", trusted) == "198.51.100.4"
|
|
assert (
|
|
client_source("127.0.0.1", "203.0.113.7, 10.1.2.3", trusted)
|
|
== "203.0.113.7"
|
|
)
|
|
assert client_source("127.0.0.1", "not-an-ip", trusted) == "127.0.0.1"
|
|
|
|
|
|
def test_failure_budget_is_shared_across_store_instances_and_expires(tmp_path):
|
|
now = [1_000.0]
|
|
database = tmp_path / "login-attempts.sqlite3"
|
|
first = LoginAttemptStore(
|
|
database,
|
|
clock=lambda: now[0],
|
|
max_failures=3,
|
|
window_seconds=60,
|
|
)
|
|
second = LoginAttemptStore(
|
|
database,
|
|
clock=lambda: now[0],
|
|
max_failures=3,
|
|
window_seconds=60,
|
|
)
|
|
|
|
assert first.retry_after("203.0.113.7") == 0
|
|
first.record_failure("203.0.113.7")
|
|
second.record_failure("203.0.113.7")
|
|
first.record_failure("203.0.113.7")
|
|
|
|
assert second.retry_after("203.0.113.7") == 60
|
|
now[0] = 1_060.0
|
|
assert first.retry_after("203.0.113.7") == 0
|
|
|
|
|
|
def test_recording_a_failure_prunes_expired_source_records(tmp_path):
|
|
now = [1_000.0]
|
|
store = LoginAttemptStore(
|
|
tmp_path / "login-attempts.sqlite3",
|
|
clock=lambda: now[0],
|
|
max_failures=3,
|
|
window_seconds=60,
|
|
)
|
|
store.record_failure("203.0.113.1")
|
|
store.record_failure("203.0.113.2")
|
|
|
|
now[0] = 1_060.0
|
|
store.record_failure("203.0.113.3")
|
|
|
|
with sqlite3.connect(store.path) as connection:
|
|
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (1,)
|
|
|
|
|
|
def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
|
|
store = LoginAttemptStore(
|
|
tmp_path / "login-attempts.sqlite3",
|
|
clock=lambda: 1_000.0,
|
|
max_failures=3,
|
|
window_seconds=60,
|
|
max_entries=2,
|
|
)
|
|
|
|
for address in ("203.0.113.1", "203.0.113.2", "203.0.113.3"):
|
|
store.record_failure(address)
|
|
|
|
with sqlite3.connect(store.path) as connection:
|
|
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
|
|
|
|
|
|
def test_failed_sign_ins_are_aggregated_across_workers_and_survive_throttle_clear(tmp_path):
|
|
now = [1_000.0]
|
|
database = tmp_path / "login-attempts.sqlite3"
|
|
first = LoginAttemptStore(
|
|
database,
|
|
clock=lambda: now[0],
|
|
max_failures=3,
|
|
window_seconds=60,
|
|
alert_bucket_seconds=300,
|
|
)
|
|
second = LoginAttemptStore(
|
|
database,
|
|
clock=lambda: now[0],
|
|
max_failures=3,
|
|
window_seconds=60,
|
|
alert_bucket_seconds=300,
|
|
)
|
|
|
|
first.record_failure("203.0.113.7", method="token")
|
|
now[0] = 1_010.0
|
|
second.record_failure("198.51.100.4", method="token")
|
|
first.clear("203.0.113.7")
|
|
|
|
assert second.list_alerts() == [
|
|
{
|
|
"method": "token",
|
|
"failed_count": 2,
|
|
"blocked_count": 0,
|
|
"first_at": 1_000,
|
|
"last_at": 1_010,
|
|
}
|
|
]
|
|
|
|
|
|
def test_rate_blocked_sign_ins_increment_a_distinct_privacy_safe_count(tmp_path):
|
|
store = LoginAttemptStore(
|
|
tmp_path / "login-attempts.sqlite3",
|
|
clock=lambda: 1_000.0,
|
|
max_failures=3,
|
|
window_seconds=60,
|
|
)
|
|
|
|
store.record_blocked(method="token")
|
|
|
|
assert store.list_alerts() == [
|
|
{
|
|
"method": "token",
|
|
"failed_count": 0,
|
|
"blocked_count": 1,
|
|
"first_at": 1_000,
|
|
"last_at": 1_000,
|
|
}
|
|
]
|
|
with sqlite3.connect(store.path) as connection:
|
|
columns = [row[1] for row in connection.execute("PRAGMA table_info(login_alerts)")]
|
|
assert "source_hash" not in columns
|
|
|
|
|
|
def test_expired_sign_in_alert_buckets_are_not_returned(tmp_path):
|
|
now = [1_000.0]
|
|
store = LoginAttemptStore(
|
|
tmp_path / "login-attempts.sqlite3",
|
|
clock=lambda: now[0],
|
|
max_failures=3,
|
|
window_seconds=60,
|
|
alert_retention_seconds=300,
|
|
)
|
|
store.record_failure("203.0.113.7")
|
|
|
|
now[0] = 1_301.0
|
|
|
|
assert store.list_alerts() == []
|
|
|
|
|
|
def test_sign_in_alert_bucket_count_is_bounded_during_rotating_source_floods(tmp_path):
|
|
now = [1_000.0]
|
|
store = LoginAttemptStore(
|
|
tmp_path / "login-attempts.sqlite3",
|
|
clock=lambda: now[0],
|
|
max_failures=3,
|
|
window_seconds=60,
|
|
alert_bucket_seconds=60,
|
|
max_alert_buckets=2,
|
|
)
|
|
for index, timestamp in enumerate((1_000.0, 1_060.0, 1_120.0)):
|
|
now[0] = timestamp
|
|
store.record_failure(f"203.0.113.{index}")
|
|
|
|
with sqlite3.connect(store.path) as connection:
|
|
count = connection.execute("SELECT COUNT(*) FROM login_alerts").fetchone()[0]
|
|
|
|
assert count == 2
|
|
|
|
|
|
def test_named_admission_budget_is_atomic_across_instances_and_source_scoped(tmp_path):
|
|
now = [1_000.0]
|
|
database = tmp_path / "login-attempts.sqlite3"
|
|
first = LoginAttemptStore(
|
|
database,
|
|
clock=lambda: now[0],
|
|
max_failures=3,
|
|
window_seconds=60,
|
|
)
|
|
second = LoginAttemptStore(
|
|
database,
|
|
clock=lambda: now[0],
|
|
max_failures=3,
|
|
window_seconds=60,
|
|
)
|
|
|
|
assert first.admit("passkey_options", "203.0.113.7", limit=2) == 0
|
|
assert second.admit("passkey_options", "203.0.113.7", limit=2) == 0
|
|
assert first.admit("passkey_options", "203.0.113.7", limit=2) == 60
|
|
assert first.admit("passkey_options", "203.0.113.8", limit=2) == 0
|
|
|
|
now[0] = 1_060.0
|
|
assert second.admit("passkey_options", "203.0.113.7", limit=2) == 0
|