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 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():
|
||||||
|
|||||||
Reference in New Issue
Block a user