Add security regression tests for all hardening fixes

- New: test_security_headers.py — verify all headers present, HSTS conditional on HTTPS
- New: test_passkey_error_handling.py — generic vs user-facing error messages
- New: test_poi_validation.py — field length and coordinate range constraints
- Extend test_rate_limiter.py — client IP depth selection, in-memory fallback enforcement
- Extend test_models.py — sqm range validation
- Extend test_task_service.py — IDOR 404, ownership 200, traceback suppression in production
This commit is contained in:
Viktor Barzin 2026-02-08 19:42:53 +00:00
parent 727dd537ef
commit 492921424e
No known key found for this signature in database
GPG key ID: 0EB088298288D958
6 changed files with 365 additions and 0 deletions

View file

@ -30,6 +30,7 @@ def _make_config(**overrides: object) -> RateLimitConfig:
"geojson_stream_batch_size_cap": 200,
"rate_limit_redis_db": 3,
"metrics_allowed_ips": "127.0.0.1",
"trusted_proxy_depth": 1,
}
defaults.update(overrides)
return RateLimitConfig(**defaults) # type: ignore[arg-type]
@ -236,3 +237,92 @@ class TestRateLimitConfig:
# Should fall back to default
assert config.endpoint_limits["/api/listing"].max_requests == 30
assert config.endpoint_limits["/api/listing"].window_seconds == 60
from api.rate_limiter import _client_ip
class TestClientIp:
"""Tests for _client_ip with trusted proxy depth."""
def test_uses_rightmost_ip_with_depth_1(self) -> None:
scope = {
"type": "http",
"headers": [(b"x-forwarded-for", b"spoofed.ip, proxy1, real.ip")],
}
request = Request(scope)
assert _client_ip(request, depth=1) == "real.ip"
def test_uses_second_from_right_with_depth_2(self) -> None:
scope = {
"type": "http",
"headers": [(b"x-forwarded-for", b"spoofed.ip, real.ip, proxy1")],
}
request = Request(scope)
assert _client_ip(request, depth=2) == "real.ip"
def test_falls_back_to_connection_ip(self) -> None:
scope = {
"type": "http",
"headers": [],
"client": ("192.168.1.1", 12345),
}
request = Request(scope)
assert _client_ip(request) == "192.168.1.1"
def test_no_client_returns_unknown(self) -> None:
scope = {"type": "http", "headers": []}
request = Request(scope)
assert _client_ip(request) == "unknown"
def test_single_ip_with_depth_1(self) -> None:
scope = {
"type": "http",
"headers": [(b"x-forwarded-for", b"single.ip")],
}
request = Request(scope)
assert _client_ip(request, depth=1) == "single.ip"
class TestInMemoryFallback:
"""Tests for in-memory rate limit fallback when Redis is unavailable."""
@mock.patch("api.rate_limiter._get_rate_limit_redis")
def test_fallback_enforces_limits_when_redis_unavailable(self, mock_get_redis: mock.MagicMock) -> None:
"""When Redis is unavailable at startup, in-memory fallback should enforce limits."""
import redis as redis_lib
mock_get_redis.side_effect = redis_lib.RedisError("connection refused")
config = _make_config()
app = _build_app(config)
client = TestClient(app)
# Should allow first 3 requests (limit=3)
for i in range(3):
resp = client.get("/api/listing")
assert resp.status_code == 200, f"Request {i+1} should be allowed"
# 4th request should be rate limited
resp = client.get("/api/listing")
assert resp.status_code == 429
@mock.patch("api.rate_limiter._get_rate_limit_redis")
def test_fallback_activates_on_redis_error_during_request(self, mock_get_redis: mock.MagicMock) -> None:
"""When Redis errors during request handling, fallback should activate."""
import redis as redis_lib
mock_redis = mock.MagicMock()
mock_pipe = mock.MagicMock()
mock_pipe.execute.side_effect = redis_lib.RedisError("connection lost")
mock_redis.pipeline.return_value = mock_pipe
mock_redis.ping.return_value = True
mock_get_redis.return_value = mock_redis
config = _make_config()
app = _build_app(config)
client = TestClient(app)
# First request should succeed via fallback
resp = client.get("/api/listing")
assert resp.status_code == 200
assert "X-RateLimit-Limit" in resp.headers