join: honeypot field + server-side validation on POST /join
Three bot submissions landed 2026-08-30 with both selects carrying the placeholder label "Select one..." - required= is browser-only and a direct POST skips it. Adds a hidden honeypot (answers 303 so the bot sees success) and server-side checks on name, email, children, interested_in and source. Rejections are logged with the client IP and never touch any store.
This commit is contained in:
+57
-5
@@ -1,6 +1,6 @@
|
||||
import json, os, time, datetime, urllib.request, urllib.parse
|
||||
import json, os, re, time, datetime, urllib.request, urllib.parse
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, Form
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
@@ -634,7 +634,10 @@ def thanks_body():
|
||||
</div></section>"""
|
||||
|
||||
|
||||
def join_body():
|
||||
def join_body(error=""):
|
||||
err_html = (
|
||||
'<div style="background:#FDECEC;border:1px solid #E4A0A0;color:#8A2B2B;padding:12px 14px;border-radius:10px;font-size:14.5px;margin-bottom:4px">'
|
||||
+ error + "</div>") if error else ""
|
||||
faq_html = "".join(f'<div class="faq"><div class="q">{q}</div><div class="a">{a}</div></div>' for q, a in FAQ)
|
||||
return f"""
|
||||
<section style="padding:72px 0 30px"><div class="wrap" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:52px;align-items:start">
|
||||
@@ -649,7 +652,8 @@ def join_body():
|
||||
<img src="/static/img/circle-game.jpg" alt="Scouts cheering during a pack game" style="width:100%;height:280px;object-fit:cover;border-radius:16px"></div>
|
||||
<div>
|
||||
<form class="lead" method="post" action="/join">
|
||||
<div class="ft">Interest form</div>
|
||||
<div class="ft">Interest form</div>{err_html}
|
||||
<div aria-hidden="true" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden"><label>Company website</label><input name="website" type="text" tabindex="-1" autocomplete="off"></div>
|
||||
<div class="frow">
|
||||
<div><label>Parent / guardian name <span class="req">*</span></label><input name="parent_name" required></div>
|
||||
<div><label>Email <span class="req">*</span></label><input type="email" name="email" required></div>
|
||||
@@ -747,10 +751,58 @@ def pipeline_notify(rec, sheet_error=None):
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# --- form integrity --------------------------------------------------------
|
||||
# 2026-08-30: three bot submissions landed with both <select> fields carrying
|
||||
# the placeholder label "Select one...". `required` is browser-only, and a
|
||||
# direct POST skips it entirely. Validate server-side; honeypot the rest.
|
||||
VALID_INTERESTED = {
|
||||
"Pack 73 (Cub Scouts, K-5)",
|
||||
"Troop 73 (Scouts BSA, 11-17)",
|
||||
"Both / kids spanning ages",
|
||||
}
|
||||
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s.]+(\.[^@\s.]+)+$")
|
||||
|
||||
|
||||
def lead_problem(parent_name, email, children, interested_in, source):
|
||||
"""Return a short reason string if this submission cannot be genuine."""
|
||||
if len(parent_name.strip()) < 2:
|
||||
return "name"
|
||||
if not EMAIL_RE.match(email.strip()):
|
||||
return "email"
|
||||
if len(children.strip()) < 2:
|
||||
return "children"
|
||||
if interested_in.strip() not in VALID_INTERESTED:
|
||||
return "interested_in"
|
||||
if not source.strip():
|
||||
return "source"
|
||||
for v in (parent_name, children, interested_in, source):
|
||||
if "select one" in v.lower():
|
||||
return "placeholder"
|
||||
return ""
|
||||
|
||||
|
||||
@app.post("/join")
|
||||
def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str = Form(""),
|
||||
children: str = Form(""), interested_in: str = Form(""), source: str = Form(""),
|
||||
source_place: str = Form(""), source_other: str = Form(""), message: str = Form("")):
|
||||
source_place: str = Form(""), source_other: str = Form(""), message: str = Form(""),
|
||||
website: str = Form(""), request: Request = None):
|
||||
client_ip = "?"
|
||||
if request is not None:
|
||||
client_ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
or (request.client.host if request.client else "?"))
|
||||
# Honeypot. A browser never fills a field it cannot see. Answer 303 anyway
|
||||
# so the bot books a success and has no signal to tune against.
|
||||
if website.strip():
|
||||
print("LEAD REJECTED (honeypot) from %s: %r <%s>"
|
||||
% (client_ip, parent_name[:40], email[:60]), flush=True)
|
||||
return RedirectResponse(url="/join?sent=1", status_code=303)
|
||||
problem = lead_problem(parent_name, email, children, interested_in, source)
|
||||
if problem:
|
||||
print("LEAD REJECTED (%s) from %s: %r <%s>"
|
||||
% (problem, client_ip, parent_name[:40], email[:60]), flush=True)
|
||||
return HTMLResponse(page("Join us \u00b7 Pack & Troop 73", join_body(
|
||||
error="We could not read that submission. Please complete every field "
|
||||
"marked * and send it again.")), status_code=400)
|
||||
heard_from = source.strip()
|
||||
heard_from_detail = ""
|
||||
if source == "Other school or daycare" and source_place.strip():
|
||||
|
||||
Reference in New Issue
Block a user