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
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
name: CI
permissions:
actions: read

# Enable Buildkit and let compose use it to speed up image building
env:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/s3_backup.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
name: Backup S3 Production Bucket
permissions: {}

on:
workflow_dispatch:
Expand Down
48 changes: 47 additions & 1 deletion envergo/analytics/tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
8 changes: 7 additions & 1 deletion envergo/analytics/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import ipaddress
import logging
import socket
from datetime import timedelta
Expand Down Expand Up @@ -31,14 +32,19 @@ 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:
host = socket.gethostbyaddr(request_ip)[0]
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:
Expand Down
9 changes: 7 additions & 2 deletions envergo/hedges/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import logging
from urllib.parse import urlparse

from django.http import JsonResponse, QueryDict
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion envergo/templates/admin/choice_filter.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<h3>{% blocktranslate with filter_title=title %}By {{ filter_title }}{% endblocktranslate %}</h3>
<div style="padding: 15px;">
<select onchange="window.location=this.value">
<select onchange="const url=this.value;if(url.startsWith('?'))window.location.search=url;">
{% for choice in choices %}
<option {% if choice.selected %}selected{% endif %}
value="{{ choice.query_string|iriencode }}">{{ choice.display }}</option>
Expand Down
Loading