feat(auth): signup bot-guard foundation - honeypot + submit-timing (BUNYIP-377) #374

Merged
nrupard merged 2 commits from feat/BUNYIP-377-signup-bot-prevention into main 2026-07-16 18:19:32 +02:00
Owner

Summary

Server-side foundation for the signup bot guard (BUNYIP-377), inert behind an opt-in flag. Honeypot + submit-timing was chosen over a third-party CAPTCHA to keep the self-hosted stack dependency-free.

Ships the enforcement + the token endpoint that feeds it, so the register forms can adopt the hidden fields next and the flag can be flipped once they do. Registration behaviour is unchanged until then.

What's in

  • SignupChallengeClaims + JwtService::create/verify_signup_challenge_token: a short-lived (30-min) HMAC-signed issued-at token, distinct purpose so it can't be crossed with the 2FA / login-approval challenges.
  • AuthService::create_signup_challenge + verify_signup_not_bot: rejects a filled honeypot, or a missing / forged / expired / too-fast (< SIGNUP_MIN_FILL_SECONDS) token, all via one uniform error so no individual check is an oracle a bot could tune against. The pure signup_submitted_too_fast decision is unit-tested.
  • GET /v1/auth/register-challenge issues the token; RegisterRequest gains optional contact_channel (honeypot) + signup_token fields; the register handler enforces the guard only when Config::signup_bot_guard_enabled (SIGNUP_BOT_GUARD_ENABLED, default off).

Staged rollout (PMS-289 lesson)

Off by default and no form change yet, so a real signup (which does not carry the fields) is only rejected once the flag is on. Remaining before enabling:

  1. bunyip-web register form: hidden honeypot input + fetch/embed the timing token at render, forward both through register_post to bunyip-api.
  2. mokosh-apps SPA register form: same fields (separate MAPPS follow-up).
  3. Set SIGNUP_BOT_GUARD_ENABLED=true.

Relates to BUNYIP-379 (real client IP) - without it the per-IP registration rate limit collapses to one container IP, so the per-IP layer of the defence is meaningless until that lands.

Testing

just check-container green (fmt + clippy -D warnings + cargo test --workspace --all-targets, 278 domain / 73 api / 104 web, 0 failed), including the new signup_rejects_submits_faster_than_min_fill unit test.

## Summary Server-side foundation for the signup bot guard (BUNYIP-377), **inert behind an opt-in flag**. Honeypot + submit-timing was chosen over a third-party CAPTCHA to keep the self-hosted stack dependency-free. Ships the enforcement + the token endpoint that feeds it, so the register forms can adopt the hidden fields next and the flag can be flipped once they do. Registration behaviour is unchanged until then. ## What's in - `SignupChallengeClaims` + `JwtService::create/verify_signup_challenge_token`: a short-lived (30-min) HMAC-signed issued-at token, distinct `purpose` so it can't be crossed with the 2FA / login-approval challenges. - `AuthService::create_signup_challenge` + `verify_signup_not_bot`: rejects a filled honeypot, or a missing / forged / expired / too-fast (`< SIGNUP_MIN_FILL_SECONDS`) token, all via one uniform error so no individual check is an oracle a bot could tune against. The pure `signup_submitted_too_fast` decision is unit-tested. - `GET /v1/auth/register-challenge` issues the token; `RegisterRequest` gains optional `contact_channel` (honeypot) + `signup_token` fields; the register handler enforces the guard only when `Config::signup_bot_guard_enabled` (`SIGNUP_BOT_GUARD_ENABLED`, default off). ## Staged rollout (PMS-289 lesson) Off by default and no form change yet, so a real signup (which does not carry the fields) is only rejected once the flag is on. Remaining before enabling: 1. bunyip-web register form: hidden honeypot input + fetch/embed the timing token at render, forward both through `register_post` to bunyip-api. 2. mokosh-apps SPA register form: same fields (separate MAPPS follow-up). 3. Set `SIGNUP_BOT_GUARD_ENABLED=true`. Relates to BUNYIP-379 (real client IP) - without it the per-IP registration rate limit collapses to one container IP, so the per-IP layer of the defence is meaningless until that lands. ## Testing `just check-container` green (fmt + clippy `-D warnings` + `cargo test --workspace --all-targets`, 278 domain / 73 api / 104 web, 0 failed), including the new `signup_rejects_submits_faster_than_min_fill` unit test.
feat(auth): signup bot-guard foundation - honeypot + submit-timing (BUNYIP-377)
Some checks failed
E2E / Playwright against deployment (pull_request) Successful in 37s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
c65ce8d55f
Server-side foundation for the signup bot guard, inert behind an opt-in flag. Adds honeypot + submit-timing enforcement at POST /v1/auth/register plus the token endpoint that feeds it, so the register forms can adopt the hidden fields next and the flag can be flipped once they do.

- SignupChallengeClaims + JwtService::create/verify_signup_challenge_token: a short-lived (30-min) HMAC-signed issued-at token with a distinct purpose so it can't be crossed with the 2FA / login-approval challenges.
- AuthService::create_signup_challenge + verify_signup_not_bot: rejects a filled honeypot, or a missing / forged / expired / too-fast (< SIGNUP_MIN_FILL_SECONDS) token, all via one uniform error so no individual check is an oracle. The pure signup_submitted_too_fast decision is unit-tested.
- GET /v1/auth/register-challenge issues the token; RegisterRequest carries the optional contact_channel (honeypot) + signup_token fields; the register handler enforces the guard only when Config::signup_bot_guard_enabled (SIGNUP_BOT_GUARD_ENABLED, default off).

Off by default and no form change yet, so registration is unchanged: a real signup (which does not carry the fields) is only rejected once the flag is on, which waits on the follow-up form work. Honeypot + timing was chosen over a third-party CAPTCHA to keep the stack dependency-free.

Follow-ups before enabling: the bunyip-web register form (hidden honeypot + fetch/embed the token) and the mokosh-apps SPA register form (MAPPS), then set SIGNUP_BOT_GUARD_ENABLED=true. Relates to BUNYIP-379 (real client IP), without which the per-IP layer is meaningless.

#BUNYIP-377

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat(web): wire the register form to the signup bot guard (BUNYIP-377)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 34s
Check / fmt + clippy + build + tests (pull_request) Successful in 9m21s
Create release / Create release from merged PR (pull_request) Has been skipped
616e6f9478
Same-PR follow-up: the bunyip-web register form now carries the honeypot + timing token the server foundation enforces, so bunyip's own signup keeps working when SIGNUP_BOT_GUARD_ENABLED is flipped on.

- register_card renders a hidden off-screen honeypot input (contact_channel) and a hidden signup_token field. register_get fetches a fresh timing token from bunyip-api (GET /auth/register-challenge, best-effort) and embeds it; a rejected submit re-embeds the in-flight token so the user does not need a new one.
- RegisterForm + register_post forward contact_channel + signup_token to auth_api::register, which includes them in the POST /auth/register body.
- api::auth::register_challenge fetches the token; register() gains the two params. The honeypot hides via inline style, permitted by bunyip-web's CSP (style-src 'unsafe-inline').

Still gated off by default (the flag lives server-side). The mokosh-apps SPA register form is the remaining surface (MAPPS) before enabling.

#BUNYIP-377

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch feat/BUNYIP-377-signup-bot-prevention 2026-07-16 18:19:32 +02: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
psa-systems/bunyip!374
No description provided.