diff --git a/core/templatetags/extensions.py b/core/templatetags/extensions.py index e48f6274..10592d8b 100644 --- a/core/templatetags/extensions.py +++ b/core/templatetags/extensions.py @@ -47,12 +47,23 @@ class HoneypotExtension(Extension): def parse(self, parser: Parser) -> nodes.Output: lineno = parser.stream.expect("name:render_honeypot_field").lineno + key = nodes.Name("render_honeypot_field", "load", lineno=lineno) + if parser.stream.current.type != "block_end": + field_name = parser.parse_expression() + else: + field_name = nodes.Const(None) call = self.call_method( "_render", - [nodes.Name("render_honeypot_field", "load", lineno=lineno)], + [key, field_name], lineno=lineno, ) return nodes.Output([nodes.MarkSafe(call)]) - def _render(self, render_honeypot_field: Callable[[str | None], str]): - return render_to_string("honeypot/honeypot_field.html", render_honeypot_field()) + def _render( + self, + render_honeypot_field: Callable[[str | None], str], + field_name: str | None = None, + ): + return render_to_string( + "honeypot/honeypot_field.html", render_honeypot_field(field_name=field_name) + ) diff --git a/core/utils.py b/core/utils.py index 87f3ab50..41d132bc 100644 --- a/core/utils.py +++ b/core/utils.py @@ -25,6 +25,7 @@ from typing import Optional import PIL from django.conf import settings from django.core.files.base import ContentFile +from django.http import HttpRequest from django.utils import timezone from PIL import ExifTags from PIL.Image import Resampling @@ -297,3 +298,16 @@ def bbcode_to_markdown(text): new_text.append(line) return "\n".join(new_text) + + +def get_client_ip(request: HttpRequest) -> str | None: + headers = ( + "X_FORWARDED_FOR", # Common header for proixes + "FORWARDED", # Standard header defined by RFC 7239. + "REMOTE_ADDR", # Default IP Address (direct connection) + ) + for header in headers: + if (ip := request.META.get(header)) is not None: + return ip + + return None diff --git a/forum/templates/forum/reply.jinja b/forum/templates/forum/reply.jinja index b8ab53e5..e4b5f5f0 100644 --- a/forum/templates/forum/reply.jinja +++ b/forum/templates/forum/reply.jinja @@ -29,6 +29,7 @@ {% endif %}
{% csrf_token %} + {% render_honeypot_field settings.HONEYPOT_FIELD_NAME_FORUM %} {{ form.as_p() }}

diff --git a/forum/tests.py b/forum/tests.py index a8d3b583..14c6ca56 100644 --- a/forum/tests.py +++ b/forum/tests.py @@ -14,6 +14,7 @@ # import pytest +from django.conf import settings from django.test import Client from django.urls import reverse from pytest_django.asserts import assertRedirects @@ -24,13 +25,14 @@ from forum.models import Forum, ForumMessage, ForumTopic @pytest.mark.django_db class TestTopicCreation: - def test_topic_creation_success(self, client: Client): + def test_topic_creation_ok(self, client: Client): user: User = User.objects.get(username="root") forum = Forum.objects.get(name="AE") client.force_login(user) payload = { "title": "Hello IT.", "message": "Have you tried turning it off and on again ?", + settings.HONEYPOT_FIELD_NAME_FORUM: settings.HONEYPOT_VALUE, } assert not ForumTopic.objects.filter(_title=payload["title"]).exists() response = client.post(reverse("forum:new_topic", args=str(forum.id)), payload) @@ -46,13 +48,28 @@ class TestTopicCreation: assert topic assert topic.last_message.message == payload["message"] - def test_topic_creation_failure(self, client: Client): + def test_topic_creation_honeypot_fail(self, client: Client): + user: User = User.objects.get(username="root") + forum = Forum.objects.get(name="AE") + client.force_login(user) + payload = { + "title": "You shall", + "message": "Not pass !", + settings.HONEYPOT_FIELD_NAME_FORUM: settings.HONEYPOT_VALUE + "random", + } + assert not ForumTopic.objects.filter(_title=payload["title"]).exists() + response = client.post(reverse("forum:new_topic", args=str(forum.id)), payload) + assert response.status_code == 200 + assert not ForumTopic.objects.filter(_title=payload["title"]).exists() + + def test_topic_creation_fail(self, client: Client): user: User = User.objects.get(username="krophil") forum = Forum.objects.get(name="AE") client.force_login(user) payload = { "title": "You shall", "message": "Not pass !", + settings.HONEYPOT_FIELD_NAME_FORUM: settings.HONEYPOT_VALUE, } assert not ForumTopic.objects.filter(_title=payload["title"]).exists() response = client.post(reverse("forum:new_topic", args=str(forum.id)), payload) diff --git a/forum/views.py b/forum/views.py index 93a19c49..8c9a8900 100644 --- a/forum/views.py +++ b/forum/views.py @@ -22,6 +22,7 @@ # # import math +from functools import partial from ajax_select import make_ajax_field from django import forms @@ -32,11 +33,13 @@ from django.db import IntegrityError from django.shortcuts import get_object_or_404 from django.urls import reverse_lazy from django.utils import html, timezone +from django.utils.decorators import method_decorator from django.utils.translation import gettext_lazy as _ from django.views.generic import DetailView, ListView, RedirectView from django.views.generic.detail import SingleObjectMixin from django.views.generic.edit import CreateView, DeleteView, UpdateView from haystack.query import RelatedSearchQuerySet +from honeypot.decorators import check_honeypot from core.views import ( CanCreateMixin, @@ -242,6 +245,9 @@ class TopicForm(forms.ModelForm): title = forms.CharField(required=True, label=_("Title")) +@method_decorator( + partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post" +) class ForumTopicCreateView(CanCreateMixin, CreateView): model = ForumMessage form_class = TopicForm @@ -331,6 +337,9 @@ class ForumMessageView(SingleObjectMixin, RedirectView): return self.object.get_url() +@method_decorator( + partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post" +) class ForumMessageEditView(CanEditMixin, UpdateView): model = ForumMessage form_class = forms.modelform_factory( @@ -381,6 +390,9 @@ class ForumMessageUndeleteView(SingleObjectMixin, RedirectView): return self.object.get_absolute_url() +@method_decorator( + partial(check_honeypot, field_name=settings.HONEYPOT_FIELD_NAME_FORUM), name="post" +) class ForumMessageCreateView(CanCreateMixin, CreateView): model = ForumMessage form_class = forms.modelform_factory( diff --git a/sith/honeypot.py b/sith/honeypot.py index 3659c0ea..f70e2874 100644 --- a/sith/honeypot.py +++ b/sith/honeypot.py @@ -1,12 +1,17 @@ import logging +from time import localtime, strftime from typing import Any -from django.http import HttpResponse -from django.test.client import WSGIRequest +from django.http import HttpRequest, HttpResponse + +from core.utils import get_client_ip def custom_honeypot_error( - request: WSGIRequest, context: dict[str, Any] + request: HttpRequest, context: dict[str, Any] ) -> HttpResponse: - logging.warning(f"HoneyPot blocked user with ip {request.META.get('REMOTE_ADDR')}") + logging.warning( + f"[{strftime('%c', localtime())}] " + f"HoneyPot blocked user with ip {get_client_ip(request)}" + ) return HttpResponse("Upon reading this, the http client was enlightened.") diff --git a/sith/settings.py b/sith/settings.py index 6424d039..e86076a3 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -287,6 +287,7 @@ REST_FRAMEWORK["UNAUTHENTICATED_USER"] = "core.models.AnonymousUser" HONEYPOT_FIELD_NAME = "body2" HONEYPOT_VALUE = "content" HONEYPOT_RESPONDER = custom_honeypot_error # Make honeypot errors less suspicious +HONEYPOT_FIELD_NAME_FORUM = "message2" # Only used on forum # Email