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:
2026-09-01 13:17:08 -04:00
parent 17c4df796a
commit 77ad373885
+57 -5
View File
@@ -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 pathlib import Path
from fastapi import FastAPI, Form from fastapi import FastAPI, Form, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -634,7 +634,10 @@ def thanks_body():
</div></section>""" </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) 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""" 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"> <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> <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> <div>
<form class="lead" method="post" action="/join"> <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 class="frow">
<div><label>Parent / guardian name <span class="req">*</span></label><input name="parent_name" required></div> <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> <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: except Exception:
return False 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") @app.post("/join")
def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str = Form(""), def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str = Form(""),
children: str = Form(""), interested_in: str = Form(""), source: 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 = source.strip()
heard_from_detail = "" heard_from_detail = ""
if source == "Other school or daycare" and source_place.strip(): if source == "Other school or daycare" and source_place.strip():