Prevent surrender form spam via back+resubmit #30

Merged
cpressland merged 1 commit from fix/surrender-form-resubmit-spam into main 2026-09-11 13:58:25 +01:00
Collaborator

Problem

The public /surrender form could be spammed: submit, hit Back, hit Submit again on the cached page — repeatedly, even with every field blank — creating unlimited pending SurrenderSubmission rows.

Fix

  • Single-use form token: GET /surrender issues a token in a hidden field; the POST consumes it. Replaying a stale/cached page (already-used or unknown token) is treated like the existing honeypot case — pretend success, write nothing — so the resubmit loop can no longer create rows. Tokens expire after 2h and are swept on each issue.
  • Reject all-blank submissions with a normal validation error, so a plain repeated blank-submit is also rejected outright.
  • Post/Redirect/Get: success now redirects to GET /surrender/thank-you instead of rendering the confirmation directly from the POST, so the confirmation page is no longer itself a resubmittable state via Back/Refresh.

Testing

No local Postgres available in this environment, and app.main cannot be imported against SQLite because its startup runs Postgres-only migration SQL (DO $$ ... $$). So the surrender router was mounted standalone on a bare FastAPI app with a throwaway SQLite engine (via TestClient, exercising the real router/templates, not hand-called functions) to verify:

  • blank form + valid token -> 400, no row written
  • real data + fresh token -> 303 redirect, 1 row written
  • replaying the same (now-consumed) token -> no second row written
  • garbage/missing token -> no row written
  • honeypot field still blocks bots as before

This covers the router logic end-to-end but does not exercise it against real Postgres or a real browser session — worth a quick manual click-through on staging before/after deploy.

## Problem The public `/surrender` form could be spammed: submit, hit Back, hit Submit again on the cached page — repeatedly, even with every field blank — creating unlimited `pending` `SurrenderSubmission` rows. ## Fix - **Single-use form token**: `GET /surrender` issues a token in a hidden field; the `POST` consumes it. Replaying a stale/cached page (already-used or unknown token) is treated like the existing honeypot case — pretend success, write nothing — so the resubmit loop can no longer create rows. Tokens expire after 2h and are swept on each issue. - **Reject all-blank submissions** with a normal validation error, so a plain repeated blank-submit is also rejected outright. - **Post/Redirect/Get**: success now redirects to `GET /surrender/thank-you` instead of rendering the confirmation directly from the `POST`, so the confirmation page is no longer itself a resubmittable state via Back/Refresh. ## Testing No local Postgres available in this environment, and `app.main` cannot be imported against SQLite because its startup runs Postgres-only migration SQL (`DO $$ ... $$`). So the `surrender` router was mounted standalone on a bare FastAPI app with a throwaway SQLite engine (via `TestClient`, exercising the real router/templates, not hand-called functions) to verify: - blank form + valid token -> 400, no row written - real data + fresh token -> 303 redirect, 1 row written - replaying the same (now-consumed) token -> no second row written - garbage/missing token -> no row written - honeypot field still blocks bots as before This covers the router logic end-to-end but does not exercise it against real Postgres or a real browser session — worth a quick manual click-through on staging before/after deploy.
Prevent surrender form spam via back+resubmit
All checks were successful
ci / uv-buildah-build-push (push) Successful in 22s
ci / build (push) Successful in 0s
f987166b1d
The public /surrender form could be submitted, then resubmitted
repeatedly by hitting Back and Submit again on the same cached page,
including with every field left blank, creating unlimited pending
SurrenderSubmission rows for an admin to wade through.

Fixes:
- Single-use form token: GET /surrender issues a token embedded in a
  hidden field. POST consumes it; a second POST replaying the same
  cached page (a stale/already-used token) is silently treated like
  the honeypot case (pretend success, write nothing) so a resubmit
  loop can no longer create rows. Tokens expire after 2h and are
  swept on issue so the in-memory store can't grow unbounded.
- Reject a submission where every field (aside from the checkbox) is
  blank, with a normal validation error asking for at least some
  info — this alone doesn't stop back+resubmit (a bot could just
  fill something in) but does stop the trivial repeated-blank-submit
  case described.
- Post/Redirect/Get: success now redirects to GET /surrender/thank-you
  instead of rendering the confirmation directly from the POST
  response, so the confirmation page itself is no longer a
  resubmittable state via browser Back/Refresh.

Tested with a throwaway SQLite TestClient against the real router
(app.main can't be imported outside Postgres because of its startup
migration statements, so the surrender router was mounted standalone
on a bare FastAPI app for this test — this only exercises
app/routers/surrender.py, not the rest of the app):
- blank form + valid token -> 400, no row written
- real data + fresh token -> 303 redirect, 1 row written
- replaying the same (now-consumed) token -> no second row written
- garbage/missing token -> no row written
- honeypot field still blocks bots as before
cpressland deleted branch fix/surrender-form-resubmit-spam 2026-09-11 13:58:25 +01:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
cpressland/rabbitdb!30
No description provided.