Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions django-backend/soroscan/ingest/tests/test_ip_logging_middleware.py
Original file line number Diff line number Diff line change
@@ -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)
42 changes: 39 additions & 3 deletions django-backend/soroscan/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,15 +171,51 @@ 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:
response["Deprecation"] = "true"
response["Sunset"] = config.get("sunset", "")
response["Link"] = f'<{config.get("replacement", "")}>; rel="replacement"'
break
return response
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)
10 changes: 10 additions & 0 deletions django-backend/soroscan/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading