diff --git a/coderedcms/migrations/0044_layoutsettings_recaptcha_public_key_and_more.py b/coderedcms/migrations/0044_layoutsettings_recaptcha_public_key_and_more.py new file mode 100644 index 00000000..e1339031 --- /dev/null +++ b/coderedcms/migrations/0044_layoutsettings_recaptcha_public_key_and_more.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.19 on 2025-02-11 23:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("coderedcms", "0043_remove_coderedpage_struct_org_actions_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="layoutsettings", + name="recaptcha_public_key", + field=models.CharField( + blank=True, + help_text="Create this key in the Google reCAPTCHA or Google Cloud dashboard.", + max_length=255, + verbose_name="reCAPTCHA Site Key (Public)", + ), + ), + migrations.AddField( + model_name="layoutsettings", + name="recaptcha_secret_key", + field=models.CharField( + blank=True, + help_text="Create this key in the Google reCAPTCHA or Google Cloud dashboard.", + max_length=255, + verbose_name="reCAPTCHA Secret Key (Private)", + ), + ), + migrations.AddField( + model_name="layoutsettings", + name="recaptcha_threshold", + field=models.DecimalField( + decimal_places=1, + default=0.5, + help_text="reCAPTCHA v3 returns a score (0.0 is very likely a bot, 1.0 is very likely a good interaction). Reject submissions below this score (recommended 0.5).", + max_digits=2, + verbose_name="reCAPTCHA Threshold", + ), + ), + migrations.AddField( + model_name="layoutsettings", + name="spam_service", + field=models.CharField( + blank=True, + choices=[ + ("", "None"), + ("honeypot", "Basic - honeypot technique"), + ("recaptcha3", "reCAPTCHA v3 - Invisible (requires API key)"), + ( + "recaptcha2", + "reCAPTCHA v2 - I am not a robot (requires API key)", + ), + ], + default="honeypot", + help_text="Choose a technique or 3rd party service to help block spam submissions.", + max_length=10, + verbose_name="Spam Protection", + ), + ), + ] diff --git a/coderedcms/models/page_models.py b/coderedcms/models/page_models.py index 435c20e6..31e83dd3 100755 --- a/coderedcms/models/page_models.py +++ b/coderedcms/models/page_models.py @@ -92,6 +92,7 @@ from coderedcms.forms import CoderedSubmissionsListView from coderedcms.models.snippet_models import ClassifierTerm from coderedcms.models.wagtailsettings_models import LayoutSettings +from coderedcms.recaptcha import verify_response from coderedcms.settings import crx_settings from coderedcms.widgets import ClassifierSelectWidget @@ -1373,6 +1374,15 @@ class Meta: FieldPanel("spam_protection"), ] + @property + def get_form_id(self): + """ + Returns a suitable HTML element ID. + """ + if self.form_id: + return self.form_id.strip() + return f"crx-form-{self.pk}" + @property def form_live(self): """ @@ -1613,12 +1623,31 @@ def get_form(self, request, *args, **kwargs): return form_class(request.POST, request.FILES, *args, **form_params) return form_class(*args, **form_params) - def contains_spam(self, request): - """ - Checks to see if the spam honeypot was filled out. - """ - if request.POST.get("cr-decoy-comments", None): - return True + def contains_spam(self, request) -> bool: + """ + Checks if the site's spam_service identifies spam. + """ + if not self.spam_protection: + return False + ls = LayoutSettings.for_site(self.get_site()) + if ls.spam_service == ls.SpamService.HONEYPOT: + if request.POST.get("cr-decoy-comments", None): + return True + elif ls.spam_service == ls.SpamService.RECAPTCHA_V3: + rr = verify_response( + request.POST.get("g-recaptcha-response", ""), + ls.recaptcha_secret_key, + utils.get_ip(request), + ) + # Score ranges from 0 (likely spam) to 1 (likely good). + return rr.score < ls.recaptcha_threshold + elif ls.spam_service == ls.SpamService.RECAPTCHA_V2: + rr = verify_response( + request.POST.get("g-recaptcha-response", ""), + ls.recaptcha_secret_key, + utils.get_ip(request), + ) + return not rr.success return False def process_spam_request(self, form, request): @@ -1664,7 +1693,7 @@ def process_form_get(self, form, request): def serve(self, request, *args, **kwargs): form = self.get_form(request, page=self, user=request.user) if request.method == "POST": - if self.spam_protection and self.contains_spam(request): + if self.contains_spam(request): return self.process_spam_request(form, request) return self.process_form_post(form, request) return self.process_form_get(form, request) diff --git a/coderedcms/models/wagtailsettings_models.py b/coderedcms/models/wagtailsettings_models.py index 74a7b69d..d45d181d 100755 --- a/coderedcms/models/wagtailsettings_models.py +++ b/coderedcms/models/wagtailsettings_models.py @@ -4,6 +4,7 @@ Global project or developer settings should be defined in coderedcms.settings.py . """ +from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ from modelcluster.fields import ParentalKey @@ -44,6 +45,18 @@ class LayoutSettings(ClusterableModel, BaseSiteSetting): class Meta: verbose_name = _("CRX Settings") + class SpamService(models.TextChoices): + NONE = ("", _("None")) + HONEYPOT = ("honeypot", _("Basic - honeypot technique")) + RECAPTCHA_V3 = ( + "recaptcha3", + _("reCAPTCHA v3 - Invisible (requires API key)"), + ) + RECAPTCHA_V2 = ( + "recaptcha2", + _("reCAPTCHA v2 - I am not a robot (requires API key)"), + ) + logo = models.ForeignKey( get_image_model_string(), null=True, @@ -132,6 +145,43 @@ class Meta: external_new_tab = models.BooleanField( default=False, verbose_name=_("Open all external links in new tab") ) + spam_service = models.CharField( + blank=True, + max_length=10, + choices=SpamService.choices, + default=SpamService.HONEYPOT, + verbose_name=_("Spam Protection"), + help_text=_( + "Choose a technique or 3rd party service to help block spam submissions." + ), + ) + recaptcha_threshold = models.DecimalField( + default=0.5, + max_digits=2, + decimal_places=1, + verbose_name=_("reCAPTCHA Threshold"), + help_text=_( + "reCAPTCHA v3 returns a score (0.0 is very likely a bot, " + "1.0 is very likely a good interaction). " + "Reject submissions below this score (recommended 0.5)." + ), + ) + recaptcha_public_key = models.CharField( + blank=True, + max_length=255, + verbose_name=_("reCAPTCHA Site Key (Public)"), + help_text=_( + "Create this key in the Google reCAPTCHA or Google Cloud dashboard." + ), + ) + recaptcha_secret_key = models.CharField( + blank=True, + max_length=255, + verbose_name=_("reCAPTCHA Secret Key (Private)"), + help_text=_( + "Create this key in the Google reCAPTCHA or Google Cloud dashboard." + ), + ) google_maps_api_key = models.CharField( blank=True, max_length=255, @@ -189,6 +239,15 @@ class Meta: ], heading=_("General"), ), + MultiFieldPanel( + [ + FieldPanel("spam_service"), + FieldPanel("recaptcha_threshold"), + FieldPanel("recaptcha_public_key"), + FieldPanel("recaptcha_secret_key"), + ], + heading=_("Form Settings"), + ), MultiFieldPanel( [ FieldPanel("google_maps_api_key"), @@ -229,6 +288,17 @@ def __init__(self, *args, **kwargs): ) self.navbar_format = crx_settings.CRX_FRONTEND_NAVBAR_FORMAT_DEFAULT + def clean(self): + """ + Make sure reCAPTCHA keys are set if selected. + """ + if self.spam_service in [ + self.SpamService.RECAPTCHA_V3, + self.SpamService.RECAPTCHA_V2, + ] and not (self.recaptcha_public_key and self.recaptcha_secret_key): + raise ValidationError(_("API keys are required to use reCAPTCHA.")) + return super().clean() + class NavbarOrderable(Orderable, models.Model): navbar_chooser = ParentalKey( diff --git a/coderedcms/recaptcha.py b/coderedcms/recaptcha.py new file mode 100644 index 00000000..7656eeef --- /dev/null +++ b/coderedcms/recaptcha.py @@ -0,0 +1,53 @@ +import json +import logging +import typing +from urllib.parse import urlencode +from urllib.request import Request +from urllib.request import urlopen + + +logger = logging.getLogger("coderedcms") + + +class RecaptchaResponse(typing.NamedTuple): + success: bool + score: float + error_codes: typing.List[str] + original_data: typing.Dict[str, typing.Any] + + +def verify_response(recaptcha_response: str, secret_key: str, remoteip: str): + """ + Verifies a response from reCAPTCHA front-end. + + * recaptcha_response = The token from the front-end, typically + ``g-recaptcha-response`` in POST parameters. + + * secret_key = reCAPTCHA secret/private API key. + + * remoteip = The form submitter's IP address. + """ + params = { + "secret": secret_key, + "response": recaptcha_response, + "remoteip": remoteip, + } + request = Request( + url="https://www.google.com/recaptcha/api/siteverify", + method="POST", + data=bytes(urlencode(params), encoding="utf8"), + headers={ + "Content-type": "application/x-www-form-urlencoded", + }, + ) + response = urlopen(request) + data = json.loads(response.read().decode("utf8")) + response.close() + logger.info(f"reCAPTCHA response: {data}") + # Default to good (likely not spam) values if they are not present. + return RecaptchaResponse( + success=data.get("success", True), + score=data.get("score", 1.0), + error_codes=data.get("error-codes", []), + original_data=data, + ) diff --git a/coderedcms/templates/coderedcms/includes/form_button.html b/coderedcms/templates/coderedcms/includes/form_button.html new file mode 100644 index 00000000..8c2b0e62 --- /dev/null +++ b/coderedcms/templates/coderedcms/includes/form_button.html @@ -0,0 +1,50 @@ +{% load i18n %} +{% with settings.coderedcms.LayoutSettings as ls %} + +{% if page.spam_protection and ls.spam_service == ls.SpamService.HONEYPOT %} + +{% elif page.spam_protection and ls.spam_service == ls.SpamService.RECAPTCHA_V2 %} +
+
+
+{% endif %} + +{% if page.spam_protection and ls.spam_service == ls.SpamService.RECAPTCHA_V3 %} + + + +{% else %} + +{% endif %} + +{% endwith %} diff --git a/coderedcms/templates/coderedcms/includes/form_honeypot.html b/coderedcms/templates/coderedcms/includes/form_honeypot.html deleted file mode 100755 index 2a718c6a..00000000 --- a/coderedcms/templates/coderedcms/includes/form_honeypot.html +++ /dev/null @@ -1,5 +0,0 @@ -{% load i18n %} - diff --git a/coderedcms/templates/coderedcms/pages/base.html b/coderedcms/templates/coderedcms/pages/base.html index f3cf7cb3..733a7c08 100755 --- a/coderedcms/templates/coderedcms/pages/base.html +++ b/coderedcms/templates/coderedcms/pages/base.html @@ -187,6 +187,16 @@

{% trans "Related" %}

{% block struct_seo_extra %}{% endblock %} {% endblock %} + {% block spam_service_scripts %} + {% with settings.coderedcms.LayoutSettings as ls %} + {% if ls.spam_service == ls.SpamService.RECAPTCHA_V3 %} + + {% elif ls.spam_service == ls.SpamService.RECAPTCHA_V2 %} + + {% endif %} + {% endwith %} + {% endblock %} + {% block body_tracking_scripts %} {% if not disable_analytics %} {% if settings.coderedcms.AnalyticsSettings.gtm_id %} diff --git a/coderedcms/templates/coderedcms/pages/form_page.html b/coderedcms/templates/coderedcms/pages/form_page.html index 61ea0dc8..4870587e 100755 --- a/coderedcms/templates/coderedcms/pages/form_page.html +++ b/coderedcms/templates/coderedcms/pages/form_page.html @@ -4,22 +4,13 @@ {{ block.super }} {% if page.form_live %}
-
+ {% csrf_token %} {% bootstrap_form form layout="horizontal" %} - - {% block captcha %} - {% if page.spam_protection %} - {% include "coderedcms/includes/form_honeypot.html" %} - {% endif %} - {% endblock %} -
- + {% include "coderedcms/includes/form_button.html" %}
diff --git a/coderedcms/templates/coderedcms/pages/form_page.mini.html b/coderedcms/templates/coderedcms/pages/form_page.mini.html index 5f21518f..dfd896b9 100644 --- a/coderedcms/templates/coderedcms/pages/form_page.mini.html +++ b/coderedcms/templates/coderedcms/pages/form_page.mini.html @@ -2,21 +2,14 @@ {% with page=self.page.specific %} {% if page.form_live %} {% get_pageform page request as form %} -
{% csrf_token %} {% bootstrap_form form layout="horizontal" %} - {% block captcha %} - {% if page.spam_protection %} - {% include "coderedcms/includes/form_honeypot.html" %} - {% endif %} - {% endblock %}
- + {% include "coderedcms/includes/form_button.html" %}
diff --git a/coderedcms/templates/coderedcms/pages/stream_form_page.html b/coderedcms/templates/coderedcms/pages/stream_form_page.html index c1a9b076..0596adbe 100755 --- a/coderedcms/templates/coderedcms/pages/stream_form_page.html +++ b/coderedcms/templates/coderedcms/pages/stream_form_page.html @@ -32,12 +32,6 @@ {% endfor %} {% endblock %} - {% block captcha %} - {% if page.spam_protection %} - {% include "coderedcms/includes/form_honeypot.html" %} - {% endif %} - {% endblock %} - {% block stream_form_actions %}
@@ -47,9 +41,11 @@ Previous {% endif %} - + {% if steps|last == step %} + {% include "coderedcms/includes/form_button.html" with button_text=page.button_text %} + {% else %} + {% include "coderedcms/includes/form_button.html" with button_text="Next" %} + {% endif %}
{% endblock %} diff --git a/coderedcms/utils.py b/coderedcms/utils.py index 1701da94..128e9950 100755 --- a/coderedcms/utils.py +++ b/coderedcms/utils.py @@ -1,5 +1,6 @@ from django.core.exceptions import ValidationError from django.core.validators import URLValidator +from django.http import HttpRequest from django.utils.html import mark_safe from coderedcms.settings import crx_settings @@ -45,3 +46,23 @@ def fix_ical_datetime_format(dt_str): dt_str = dt_str[:-3] + dt_str[-2:] return dt_str return dt_str + + +def get_ip(request: HttpRequest) -> str: + """ + Get the real IP address from a request. + """ + for header in [ + "X-Forwarded-For", + "X-Real-Ip", + "X-Client-Ip", + "Cf-Connecting-Ip", + "Remote-Addr", + ]: + ip = request.headers.get(header, "") + # Forwarded for headers can contain multiple IPs, the first being the + # real IP and subsequent being proxies along the way. + # We only need the first. + if ip: + return ip.split(",")[0] + return "" diff --git a/docs/features/index.rst b/docs/features/index.rst index ccfe8eb6..803791bd 100755 --- a/docs/features/index.rst +++ b/docs/features/index.rst @@ -6,9 +6,10 @@ Features contentblocks/index layoutblocks/index - import_export - mailchimp page_types/index + snippets/index related_pages searching - snippets/index + spam-protection + import_export + mailchimp diff --git a/docs/features/spam-protection.rst b/docs/features/spam-protection.rst new file mode 100644 index 00000000..111c0d8a --- /dev/null +++ b/docs/features/spam-protection.rst @@ -0,0 +1,56 @@ +Spam Protection +=============== + +CRX provides features to help block spam form submissions from your site. + +These can be toggled in **CRX Settings > Forms** + + +Basic (honeypot) +---------------- + +The default spam protection technique is a simple honeypot. This adds a hidden field to all forms, which some spammers might mistake for a real field. If the hidden field is filled out, the submission is rejected and a generic error message is shown to the user. + +While this technique is not the most effective, it can help stop some brute force spammers, and requires no additional setup or 3rd party services. + + +reCAPTCHA v3 (invisible) +------------------------ + +Google's reCAPTCHA v3 is invisible, meaning the visitor does not see anything and does not have to solve any challenges. This works by generating a score of how likely the submission is to be spam. + +By default, CRX will show a generic error message for scores lower than 0.5. This can be adjusted in **CRX Settings > Forms**. If your visitors are complaining that they are getting errors when submitting forms, you may want to lower this number. If you are still receiving a lot of spam submissions, you may want to raise it. + +**reCAPTCHA v3 requires API keys from Google.** When creating the API keys, you must select recAPTCHA v3, then enter those keys into **CRX Settings > Forms**. + +`Create reCAPTCHA API keys `_ + + +reCAPTCHA v2 ("I am not a robot") +--------------------------------- + +Google's reCAPTCHA v2 shows the famous "I am not a robot" checkbox on the form. This requires the visitor to click the box. In some cases, Google might require the visitor to solve a challenge, such as selecting images or solving a puzzle. + +**reCAPTCHA v2 requires API keys from Google.** When creating the API keys, you must select recAPTCHA v2 "I am not a robot", then enter those keys into **CRX Settings > Forms**. + +`Create reCAPTCHA API keys `_ + + +Customizing the spam error message +---------------------------------- + +The spam error message can be customized on a per-page basis by overriding the ``get_spam_message`` function as so: + +.. code-block:: python + + class FormPage(CoderedFormPage): + ... + + def get_spam_message(self) -> str: + return "Error submitting form. Please try again." + +| + +.. versionadded:: 5.0 + + reCAPTCHA v2 and v3 support was added in CRX 5.0.