diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 757bd63006..5b5a82caae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,6 @@ name: CI +permissions: + actions: read # Enable Buildkit and let compose use it to speed up image building env: diff --git a/.github/workflows/s3_backup.yml b/.github/workflows/s3_backup.yml index 17c2c8fa59..e31a3cb9c4 100644 --- a/.github/workflows/s3_backup.yml +++ b/.github/workflows/s3_backup.yml @@ -1,4 +1,5 @@ name: Backup S3 Production Bucket +permissions: {} on: workflow_dispatch: diff --git a/envergo/analytics/tests/test_utils.py b/envergo/analytics/tests/test_utils.py index e06afb01a2..d8f5b81b06 100644 --- a/envergo/analytics/tests/test_utils.py +++ b/envergo/analytics/tests/test_utils.py @@ -1,7 +1,9 @@ +from unittest.mock import patch + import pytest from envergo.analytics.models import Event -from envergo.analytics.utils import log_event +from envergo.analytics.utils import is_request_from_a_bot, log_event pytestmark = pytest.mark.django_db @@ -26,3 +28,47 @@ def test_log_event(rf, user, admin_user, site): request.user = admin_user log_event("Category", "Event", request, **metadata) assert event_qs.count() == 1 + + +def test_is_request_from_a_bot_no_ip(rf): + """Returns False when no IP is provided.""" + request = rf.get("/") + request.META.pop("HTTP_X_REAL_IP", None) + assert is_request_from_a_bot(request) is False + + +def test_is_request_from_a_bot_invalid_ip(rf): + """Returns False when IP is invalid (protects against injection).""" + request = rf.get("/") + request.META["HTTP_X_REAL_IP"] = "invalid\ninjection" + assert is_request_from_a_bot(request) is False + + request.META["HTTP_X_REAL_IP"] = "not-an-ip" + assert is_request_from_a_bot(request) is False + + +@patch("envergo.analytics.utils.socket.gethostbyaddr") +def test_is_request_from_a_bot_dns_error(mock_gethostbyaddr, rf): + """Returns False when DNS lookup fails.""" + mock_gethostbyaddr.side_effect = OSError("DNS lookup failed") + request = rf.get("/") + request.META["HTTP_X_REAL_IP"] = "66.249.66.1" + assert is_request_from_a_bot(request) is False + + +@patch("envergo.analytics.utils.socket.gethostbyaddr") +def test_is_request_from_a_bot_googlebot(mock_gethostbyaddr, rf): + """Returns True for known bot domains.""" + mock_gethostbyaddr.return_value = ("crawl-66-249-66-1.googlebot.com", [], []) + request = rf.get("/") + request.META["HTTP_X_REAL_IP"] = "66.249.66.1" + assert is_request_from_a_bot(request) is True + + +@patch("envergo.analytics.utils.socket.gethostbyaddr") +def test_is_request_from_a_bot_not_a_bot(mock_gethostbyaddr, rf): + """Returns False for unknown domains.""" + mock_gethostbyaddr.return_value = ("some-random-host.example.com", [], []) + request = rf.get("/") + request.META["HTTP_X_REAL_IP"] = "192.168.1.1" + assert is_request_from_a_bot(request) is False diff --git a/envergo/analytics/utils.py b/envergo/analytics/utils.py index a7d20c1b92..14ce35d4b6 100644 --- a/envergo/analytics/utils.py +++ b/envergo/analytics/utils.py @@ -1,3 +1,4 @@ +import ipaddress import logging import socket from datetime import timedelta @@ -31,6 +32,11 @@ def is_request_from_a_bot(request): if request_ip is None: return False + try: + ipaddress.ip_address(request_ip) + except ValueError: + return False + # Find domain corresponding to ip socket.setdefaulttimeout(3) try: @@ -38,7 +44,7 @@ def is_request_from_a_bot(request): except OSError: return False - logger.info(f"Request from ip {request_ip}, found matching domain {host}") + logger.info("Request from ip %s, found matching domain %r", request_ip, host) # Does the request's domain matches a known bot domain? for bot_domain in bot_domains: diff --git a/envergo/hedges/views.py b/envergo/hedges/views.py index 32943db195..2445e5880f 100644 --- a/envergo/hedges/views.py +++ b/envergo/hedges/views.py @@ -1,4 +1,5 @@ import json +import logging from urllib.parse import urlparse from django.http import JsonResponse, QueryDict @@ -18,6 +19,8 @@ from envergo.moulinette.models import ConfigHaie from envergo.moulinette.views import MoulinetteMixin +logger = logging.getLogger(__name__) + # VueJS, in the full build, uses the `eval` js method to compile it's templates # This make it incompatible with csp unless we allow "unsafe-eval", which makes csp @@ -169,7 +172,8 @@ def post(self, request, *args, **kwargs): except json.JSONDecodeError: return JsonResponse({"error": "Invalid JSON data"}, status=400) except Exception as e: - return JsonResponse({"error": str(e)}, status=500) + logger.exception(e) + return JsonResponse({"error": "An internal error has occurred"}, status=500) def log_moulinette_event(self, moulinette, context, **kwargs): return @@ -199,7 +203,8 @@ def post(self, request, *args, **kwargs): except json.JSONDecodeError: return JsonResponse({"error": "Invalid JSON data"}, status=400) except Exception as e: - return JsonResponse({"error": str(e)}, status=500) + logger.exception(e) + return JsonResponse({"error": "An internal error has occurred"}, status=500) def log_moulinette_event(self, moulinette, context, **kwargs): return diff --git a/envergo/templates/admin/choice_filter.html b/envergo/templates/admin/choice_filter.html index 4a5df62f27..0c2e81dc1e 100644 --- a/envergo/templates/admin/choice_filter.html +++ b/envergo/templates/admin/choice_filter.html @@ -2,7 +2,7 @@

{% blocktranslate with filter_title=title %}By {{ filter_title }}{% endblocktranslate %}

- {% for choice in choices %}