diff --git a/django-backend/soroscan/ingest/tests/test_ip_logging_middleware.py b/django-backend/soroscan/ingest/tests/test_ip_logging_middleware.py new file mode 100644 index 00000000..bed55ebc --- /dev/null +++ b/django-backend/soroscan/ingest/tests/test_ip_logging_middleware.py @@ -0,0 +1,73 @@ +""" +Tests for ClientIPLoggingMiddleware (issue #426). +""" +import logging + +from django.test import RequestFactory + +from soroscan.middleware import ClientIPLoggingMiddleware + + +def _make_response(): + from django.http import HttpResponse + return HttpResponse("ok") + + +def _noop(request): + return _make_response() + + +class TestClientIPLoggingMiddleware: + def setup_method(self): + self.factory = RequestFactory() + self.middleware = ClientIPLoggingMiddleware(_noop) + + def test_logs_method_path_and_ip(self, caplog): + request = self.factory.get("/api/ingest/contracts/") + request.META["REMOTE_ADDR"] = "203.0.113.42" + with caplog.at_level(logging.INFO, logger="soroscan.ip_access"): + self.middleware(request) + assert any( + "203.0.113.42" in r.message and "GET" in r.message and "/api/ingest/contracts/" in r.message + for r in caplog.records + ) + + def test_logs_post_request(self, caplog): + request = self.factory.post("/api/ingest/record/", data={}) + request.META["REMOTE_ADDR"] = "10.0.0.1" + with caplog.at_level(logging.INFO, logger="soroscan.ip_access"): + self.middleware(request) + assert any("POST" in r.message and "10.0.0.1" in r.message for r in caplog.records) + + def test_static_files_not_logged(self, caplog): + for path in ("/static/app.js", "/media/image.png", "/favicon.ico"): + request = self.factory.get(path) + request.META["REMOTE_ADDR"] = "1.2.3.4" + with caplog.at_level(logging.INFO, logger="soroscan.ip_access"): + self.middleware(request) + assert not any(r.name == "soroscan.ip_access" for r in caplog.records) + + def test_unknown_ip_fallback(self, caplog): + request = self.factory.get("/api/health/") + request.META.pop("REMOTE_ADDR", None) + with caplog.at_level(logging.INFO, logger="soroscan.ip_access"): + self.middleware(request) + assert any("unknown" in r.message for r in caplog.records) + + def test_extra_fields_attached(self, caplog): + request = self.factory.get("/api/ingest/events/") + request.META["REMOTE_ADDR"] = "192.168.1.5" + with caplog.at_level(logging.INFO, logger="soroscan.ip_access"): + self.middleware(request) + record = next(r for r in caplog.records if r.name == "soroscan.ip_access") + assert record.client_ip == "192.168.1.5" + assert record.method == "GET" + assert record.path == "/api/ingest/events/" + + def test_proxy_ip_used_after_remote_addr_override(self, caplog): + """Simulate ReverseProxyFixedIPMiddleware having already set REMOTE_ADDR.""" + request = self.factory.get("/api/ingest/contracts/") + request.META["REMOTE_ADDR"] = "185.220.101.1" # already resolved from X-Forwarded-For + with caplog.at_level(logging.INFO, logger="soroscan.ip_access"): + self.middleware(request) + assert any("185.220.101.1" in r.message for r in caplog.records) diff --git a/django-backend/soroscan/middleware.py b/django-backend/soroscan/middleware.py index 6de45760..35a76d66 100644 --- a/django-backend/soroscan/middleware.py +++ b/django-backend/soroscan/middleware.py @@ -171,10 +171,10 @@ def __init__(self, get_response): def __call__(self, request): response = self.get_response(request) deprecated_endpoints = getattr(settings, "DEPRECATED_ENDPOINTS", {}) - + # Normalize request path: remove leading/trailing slashes norm_request_path = request.path.strip("/") - + for path, config in deprecated_endpoints.items(): # Normalize config path if path.strip("/") == norm_request_path: @@ -182,4 +182,40 @@ def __call__(self, request): response["Sunset"] = config.get("sunset", "") response["Link"] = f'<{config.get("replacement", "")}>; rel="replacement"' break - return response \ No newline at end of file + return response + + +_STATIC_PATH_PREFIXES = ("/static/", "/media/", "/favicon.ico") + +ip_logger = logging.getLogger("soroscan.ip_access") + + +class ClientIPLoggingMiddleware: + """ + Log the client IP address, HTTP method, and request path for every + incoming API request. + + The client IP is read from REMOTE_ADDR, which is expected to already + be set correctly by ReverseProxyFixedIPMiddleware when running behind + a proxy. Static-asset paths are excluded to avoid log noise. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + path = request.path + if not path.startswith(_STATIC_PATH_PREFIXES): + client_ip = request.META.get("REMOTE_ADDR", "unknown") + ip_logger.info( + "%s %s from %s", + request.method, + path, + client_ip, + extra={ + "client_ip": client_ip, + "method": request.method, + "path": path, + }, + ) + return self.get_response(request) \ No newline at end of file diff --git a/django-backend/soroscan/settings.py b/django-backend/soroscan/settings.py index f8b695ad..04da004b 100644 --- a/django-backend/soroscan/settings.py +++ b/django-backend/soroscan/settings.py @@ -109,6 +109,7 @@ def _load_software_version() -> str: "django.middleware.security.SecurityMiddleware", "corsheaders.middleware.CorsMiddleware", "soroscan.middleware.ReverseProxyFixedIPMiddleware", + "soroscan.middleware.ClientIPLoggingMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "soroscan.middleware.RequestIdMiddleware", "soroscan.middleware.PlatformVersionMiddleware", @@ -498,6 +499,15 @@ def _load_software_version() -> str: "propagate": False, } +# --------------------------------------------------------------------------- +# IP access logger — client IP, method, and path for every API request +# --------------------------------------------------------------------------- +LOGGING["loggers"]["soroscan.ip_access"] = { + "handlers": ["console"], + "level": "INFO", + "propagate": False, +} + # --------------------------------------------------------------------------- # Django Silk profiler (Issue: perf monitoring) — enabled via ENABLE_SILK=true # ---------------------------------------------------------------------------