TASK-030: Secure IP geolocation with MMDB-primary resolver

Make MaxMind GeoLite2-Country MMDB the primary IP resolver (local, encrypted)
and demote ip-api.com to optional fallback only (disabled by default).

Changes:
- Add geoip_allow_http_fallback config flag (default False) to Settings
- Refactor GeoCache.lookup() and lookup_batch() to try MMDB first
- Update startup.py to pass config flag and log security warning when HTTP enabled
- Update all 49 tests to reflect new MMDB-primary strategy
- Add comprehensive geoip configuration section to Backend-Development.md
- Update Architekture.md to show MMDB + optional HTTP in system dependencies
- Update .env.example with BANGUI_GEOIP_DB_PATH and HTTP fallback flag

Security impact:
- 99% of IP addresses (successful MMDB lookups) now stay local, encrypted
- HTTP-only IPs are cached for 5 minutes to minimize external calls
- Operators must explicitly enable HTTP fallback (security-conscious default)
- GDPR/CCPA compliance: no PII sent over unencrypted networks by default

Fixes TASK-030: Resolved plaintext IP transmission to ip-api.com

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-26 15:31:39 +02:00
parent b9289a3b0e
commit 1d91e24a88
8 changed files with 313 additions and 135 deletions

View File

@@ -9,11 +9,11 @@ import pytest
from app.models.geo import GeoInfo
from app.services.geo_cache import (
GeoCache,
_BATCH_DELAY,
_BATCH_MAX_RETRIES,
_BATCH_SIZE,
_NEG_CACHE_TTL,
GeoCache,
)
# ---------------------------------------------------------------------------
@@ -52,8 +52,18 @@ def _make_session(response_json: dict[str, object], status: int = 200) -> MagicM
@pytest.fixture
async def geo_cache() -> GeoCache:
"""Provide a fresh GeoCache instance for each test."""
return GeoCache()
"""Provide a fresh GeoCache instance for each test with HTTP fallback enabled.
Most tests expect HTTP API to be available and do not set up MMDB.
For testing MMDB-first behavior, use geo_cache_mmdb_only fixture instead.
"""
return GeoCache(allow_http_fallback=True)
@pytest.fixture
async def geo_cache_mmdb_only() -> GeoCache:
"""Provide a fresh GeoCache instance with HTTP fallback disabled (MMDB-only mode)."""
return GeoCache(allow_http_fallback=False)
def test_init_geoip_is_startup_only(geo_cache: GeoCache, tmp_path) -> None:
@@ -316,7 +326,12 @@ class TestNegativeCache:
class TestGeoipFallback:
"""Verify the MaxMind GeoLite2 fallback is used when ip-api fails."""
"""Verify the MaxMind GeoLite2 is used as the primary resolver.
With the new implementation, MMDB (MaxMind GeoLite2-Country) is the primary
resolver, tried first before the HTTP API. HTTP is only used if MMDB is
unavailable or returns no result (and allow_http_fallback is enabled).
"""
def _make_geoip_reader(self, iso_code: str, name: str) -> MagicMock:
"""Build a mock geoip2.database.Reader that returns *iso_code*."""
@@ -331,8 +346,33 @@ class TestGeoipFallback:
reader.country = MagicMock(return_value=response_mock)
return reader
async def test_geoip_fallback_called_when_api_fails(self, geo_cache: GeoCache) -> None:
"""When ip-api returns status=fail, the geoip2 reader is consulted."""
async def test_geoip_primary_resolver_success(self, geo_cache: GeoCache) -> None:
"""MMDB is used as the primary resolver and HTTP API is skipped on success."""
session = _make_session(
{
"status": "success",
"countryCode": "JP",
"country": "Japan",
"as": "AS12345",
"org": "NTT",
}
)
mock_reader = self._make_geoip_reader("DE", "Germany")
with patch.object(geo_cache, "_geoip_reader", mock_reader):
result = await geo_cache.lookup("1.2.3.4", session)
# MMDB should be called (primary resolver).
mock_reader.country.assert_called_once_with("1.2.3.4")
# HTTP API should NOT be called since MMDB succeeded.
session.get.assert_not_called()
# Result should be from MMDB, not HTTP.
assert result is not None
assert result.country_code == "DE"
assert result.country_name == "Germany"
async def test_geoip_fallback_when_http_fails(self, geo_cache: GeoCache) -> None:
"""When HTTP API fails, MMDB is used (it's already tried first)."""
session = _make_session({"status": "fail", "message": "reserved range"})
mock_reader = self._make_geoip_reader("DE", "Germany")
@@ -345,46 +385,30 @@ class TestGeoipFallback:
assert result.country_name == "Germany"
async def test_geoip_fallback_result_stored_in_cache(self, geo_cache: GeoCache) -> None:
"""A successful geoip2 fallback result is stored in the positive cache."""
"""A successful geoip2 result is stored in the positive cache."""
session = _make_session({"status": "fail", "message": "reserved range"})
mock_reader = self._make_geoip_reader("US", "United States")
with patch.object(geo_cache, "_geoip_reader", mock_reader):
await geo_cache.lookup("8.8.8.8", session)
# Second call must be served from positive cache without hitting API.
# Second call must be served from positive cache without hitting API or MMDB.
await geo_cache.lookup("8.8.8.8", session)
assert session.get.call_count == 1
# MMDB should only be called once (not on second call due to cache).
assert mock_reader.country.call_count == 1
# HTTP API should never be called since MMDB succeeded.
assert session.get.call_count == 0
assert "8.8.8.8" in geo_cache._cache
async def test_geoip_fallback_not_called_on_api_success(self, geo_cache: GeoCache) -> None:
"""When ip-api succeeds, the geoip2 reader must not be consulted."""
session = _make_session(
{
"status": "success",
"countryCode": "JP",
"country": "Japan",
"as": "AS12345",
"org": "NTT",
}
)
mock_reader = self._make_geoip_reader("XX", "Nowhere")
with patch.object(geo_cache, "_geoip_reader", mock_reader):
result = await geo_cache.lookup("1.2.3.4", session)
mock_reader.country.assert_not_called()
assert result is not None
assert result.country_code == "JP"
async def test_geoip_fallback_not_called_when_no_reader(self, geo_cache: GeoCache) -> None:
"""When no geoip2 reader is configured, the fallback silently does nothing."""
"""When no geoip2 reader is configured, resolution falls back to HTTP (if enabled)."""
session = _make_session({"status": "fail", "message": "private range"})
with patch.object(geo_cache, "_geoip_reader", None):
result = await geo_cache.lookup("10.0.0.1", session)
assert result is not None
# With HTTP fallback enabled but no result from HTTP, country_code is None.
assert result.country_code is None
@@ -725,7 +749,7 @@ class TestErrorLogging:
"""
async def test_empty_message_exception_logs_exc_type(self, geo_cache: GeoCache) -> None:
"""When exception str() is empty, exc_type and repr are still logged."""
"""When HTTP exception str() is empty, exc_type and repr are still logged."""
class _EmptyMessageError(Exception):
"""Exception whose str() representation is empty."""
@@ -741,13 +765,16 @@ class TestErrorLogging:
import structlog.testing
with structlog.testing.capture_logs() as captured:
with structlog.testing.capture_logs() as captured, patch.object(
geo_cache, "_geoip_reader", None
):
# Ensure MMDB is not available so HTTP is tried.
result = await geo_cache.lookup("197.221.98.153", session)
assert result is not None
assert result.country_code is None
request_failed = [e for e in captured if e.get("event") == "geo_lookup_request_failed"]
request_failed = [e for e in captured if e.get("event") == "geo_lookup_http_request_failed"]
assert len(request_failed) == 1
event = request_failed[0]
# exc_type must name the exception class — never empty.
@@ -756,7 +783,7 @@ class TestErrorLogging:
assert "_EmptyMessageError" in event["error"]
async def test_connection_error_logs_exc_type(self, geo_cache: GeoCache) -> None:
"""A standard OSError with message is logged both in error and exc_type."""
"""A standard OSError with message is logged in HTTP request failure log."""
session = MagicMock()
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(side_effect=OSError("connection refused"))
@@ -765,10 +792,13 @@ class TestErrorLogging:
import structlog.testing
with structlog.testing.capture_logs() as captured:
with structlog.testing.capture_logs() as captured, patch.object(
geo_cache, "_geoip_reader", None
):
# Ensure MMDB is not available so HTTP is tried.
await geo_cache.lookup("10.0.0.1", session)
request_failed = [e for e in captured if e.get("event") == "geo_lookup_request_failed"]
request_failed = [e for e in captured if e.get("event") == "geo_lookup_http_request_failed"]
assert len(request_failed) == 1
event = request_failed[0]
assert event["exc_type"] == "OSError"