Skip to content

Commit d160e30

Browse files
authored
Merge pull request #88 from acoruss/feat/fix-form-and-mail
feat: implement contact form enhancements with rate limiting and bot detection
2 parents 30bbc07 + 968c60e commit d160e30

2 files changed

Lines changed: 121 additions & 0 deletions

File tree

src/apps/core/views.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Core app views."""
22

33
import logging
4+
import re
5+
import time
46
import xml.etree.ElementTree as ET
57
from decimal import Decimal
68
from html import unescape
@@ -9,6 +11,7 @@
911

1012
from django.contrib import messages
1113
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
14+
from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
1215
from django.db import models
1316
from django.http import HttpRequest, HttpResponse, JsonResponse
1417
from django.shortcuts import redirect
@@ -171,12 +174,106 @@ class ContactView(TemplateView):
171174

172175
template_name = "contact.html"
173176

177+
def get_context_data(self, **kwargs):
178+
context = super().get_context_data(**kwargs)
179+
signer = TimestampSigner()
180+
context["form_token"] = signer.sign("contact-form")
181+
return context
182+
174183

175184
class ContactSubmitView(View):
176185
"""Handle contact form submissions."""
177186

187+
# In-memory rate limiter: {ip: [timestamp, ...]}
188+
_rate_limits: ClassVar[dict[str, list[float]]] = {}
189+
RATE_LIMIT_MAX = 5 # max submissions per window
190+
RATE_LIMIT_WINDOW = 3600 # 1 hour in seconds
191+
MIN_SUBMIT_TIME = 3 # minimum seconds between form load and submit
192+
193+
@staticmethod
194+
def _looks_like_gibberish(text: str) -> bool:
195+
"""Detect bot-generated gibberish strings."""
196+
if not text:
197+
return False
198+
# Mostly consonants with no vowels → gibberish
199+
letters = re.sub(r"[^a-zA-Z]", "", text)
200+
if len(letters) < 4:
201+
return False
202+
vowels = sum(1 for c in letters.lower() if c in "aeiou")
203+
vowel_ratio = vowels / len(letters)
204+
if vowel_ratio < 0.15:
205+
return True
206+
# Excessive uppercase in middle of word
207+
return len(letters) > 6 and sum(1 for c in letters[1:] if c.isupper()) > len(letters) * 0.5
208+
209+
def _get_client_ip(self, request: HttpRequest) -> str:
210+
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
211+
if forwarded:
212+
return forwarded.split(",")[0].strip()
213+
return request.META.get("REMOTE_ADDR", "")
214+
215+
def _is_rate_limited(self, ip: str) -> bool:
216+
now = time.monotonic()
217+
timestamps = self._rate_limits.get(ip, [])
218+
# Prune old entries
219+
timestamps = [t for t in timestamps if now - t < self.RATE_LIMIT_WINDOW]
220+
self._rate_limits[ip] = timestamps
221+
return len(timestamps) >= self.RATE_LIMIT_MAX
222+
223+
def _record_submission(self, ip: str) -> None:
224+
now = time.monotonic()
225+
self._rate_limits.setdefault(ip, []).append(now)
226+
178227
async def post(self, request: HttpRequest) -> HttpResponse:
179228
"""Process the contact form POST request."""
229+
# --- Honeypot check: bots fill hidden fields ---
230+
if request.POST.get("website", ""):
231+
logger.warning("Honeypot triggered on contact form")
232+
# Fake success to not tip off bots
233+
messages.success(
234+
request,
235+
"Thank you for reaching out! We'll get back to you within 24 hours.",
236+
)
237+
return redirect("core:contact")
238+
239+
# --- Time-based check: reject impossibly fast submissions ---
240+
form_token = request.POST.get("form_token", "")
241+
signer = TimestampSigner()
242+
try:
243+
signer.unsign(form_token, max_age=86400) # token valid for 24h
244+
except (BadSignature, SignatureExpired):
245+
logger.warning("Invalid or expired form token on contact form")
246+
messages.error(request, "Your session has expired. Please try again.")
247+
return redirect("core:contact")
248+
249+
# Check if form was submitted too quickly (bot speed)
250+
try:
251+
# unsign with a very short max_age to detect fast submissions
252+
signer.unsign(form_token, max_age=self.MIN_SUBMIT_TIME)
253+
except SignatureExpired:
254+
pass # Good — enough time has passed
255+
except BadSignature:
256+
messages.error(request, "Your session has expired. Please try again.")
257+
return redirect("core:contact")
258+
else:
259+
# Token is still valid with MIN_SUBMIT_TIME → submitted too fast
260+
logger.warning("Contact form submitted too quickly (bot suspected)")
261+
messages.success(
262+
request,
263+
"Thank you for reaching out! We'll get back to you within 24 hours.",
264+
)
265+
return redirect("core:contact")
266+
267+
# --- Rate limiting per IP ---
268+
client_ip = self._get_client_ip(request)
269+
if self._is_rate_limited(client_ip):
270+
logger.warning("Rate limit exceeded for IP %s on contact form", client_ip)
271+
messages.error(
272+
request,
273+
"Too many submissions. Please try again later.",
274+
)
275+
return redirect("core:contact")
276+
180277
name = request.POST.get("name", "").strip()
181278
email = request.POST.get("email", "").strip()
182279
company = request.POST.get("company", "").strip()
@@ -188,6 +285,25 @@ async def post(self, request: HttpRequest) -> HttpResponse:
188285
messages.error(request, "Please fill in all required fields.")
189286
return redirect("core:contact")
190287

288+
# --- Gibberish / bot content detection ---
289+
if self._looks_like_gibberish(name):
290+
logger.warning("Gibberish name detected: %s", name[:50])
291+
messages.success(
292+
request,
293+
"Thank you for reaching out! We'll get back to you within 24 hours.",
294+
)
295+
return redirect("core:contact")
296+
297+
# --- Valid project_type check ---
298+
valid_types = {choice[0] for choice in ContactSubmission.project_type.field.choices}
299+
valid_types.add("") # allow empty
300+
if project_type not in valid_types:
301+
logger.warning("Invalid project_type: %s", project_type[:50])
302+
messages.error(request, "Please select a valid project type.")
303+
return redirect("core:contact")
304+
305+
self._record_submission(client_ip)
306+
191307
submission = await ContactSubmission.objects.acreate(
192308
name=name,
193309
email=email,

src/templates/contact.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ <h1 class="text-4xl sm:text-5xl font-extrabold mb-4">Ready to Transform Your <sp
3232
<h2 class="text-2xl font-bold mb-6">Tell Us About Your Project</h2>
3333
<form action="{% url 'core:contact_submit' %}" method="POST" id="contact-form">
3434
{% csrf_token %}
35+
<input type="hidden" name="form_token" value="{{ form_token }}">
36+
<div style="position:absolute;left:-9999px;" aria-hidden="true">
37+
<label for="website">Fill</label>
38+
<input type="text" name="website" id="website" tabindex="-1" autocomplete="off">
39+
</div>
3540
<div class="grid md:grid-cols-2 gap-4">
3641
<div class="form-control">
3742
<label class="label"><span class="label-text font-medium">Full Name *</span></label>

0 commit comments

Comments
 (0)