Registration Abuse Defense Plan
Status: Tier 1 approved 2026-05-18, in progress. Tier 2/3 awaiting prioritization.
Status: Tier 1 approved 2026-05-18, in progress. Tier 2/3 awaiting prioritization. Owner: Nissim Buchs Created: 2026-05-18 Drafted by: Claude (during email-forwarding incident investigation) Triggering incident: SES bounce-rate warning + organizer receiving fan-out of a calendar-acceptance reply on 2026-05-18
Reality check β the βattackerβ is us
The 1152 POSTs/5-min against /api/v1/events/BATbern25/registrations and the ~780 SES sends to zaproxy@example.com are not external β they are our own scheduled OWASP ZAP scan.
.github/workflows/security-scan.ymlruns every Monday at 03:00 UTC (cron: '0 3 * * 1') and onworkflow_dispatch.- The active API scan uses the OpenAPI specs in
docs/api/*.openapi.ymland posts test payloads (includingzaproxy@example.com) againsthttps://api.batbern.ch. - Today is Monday 2026-05-18. The scan ran, fuzzed every public POST, and consumed SES quota in the process.
That said β ZAP is not lying. The endpoints it abused really are unprotected, and a real attacker could do the same (or worse). The job is therefore: fix the underlying exposure, AND tune the scan so it does not consume our SES quota on every run.
The Cloudflare Turnstile fail-open is deliberate and must stay. From commit 4079d394 (2026-05-06):
Most BATbern attendees register from corporate networks. Flipping Turnstile to fail-closed would break primary signup. Defenses must work without it.
Threat model (after the realignment)
| Vector | Impact | Reachable today via |
|---|---|---|
Scripted POST flood on /registrations with throwaway emails |
SES quota burn, bounce reputation drop, possible SES sending-pause | Our own ZAP, any anon attacker |
Scripted POST flood on /newsletter/subscribe |
SES quota burn (smaller β confirm-email pattern) | Anon attacker |
Scripted POST flood on /registrations/deregister/by-email |
Email-spam abuse against arbitrary registered users | Anon attacker (DeregistrationController.java:32-34 has the literal TODO) |
| Scripted POST flood on speaker-invite / batch-import | Lower β those endpoints are hasRole('ORGANIZER') |
Insider only |
SES current state (2026-05-18 ~13:30 UTC):
- 24h send count: 836
- 24h bounce count: ~9
- 15-day rolling bounce rate: 4.0% (alarm at 3%)
- Suppression list: 406 entries (mostly stale corporate addresses from May 4 bulk send)
- SES throttles sending at 5% bounce-rate; pauses at 10%
We are one bad scan away from a sending outage on the public launch week.
Why the existing protections didnβt catch this
- Turnstile is fail-open by design (see commit
4079d394). ZAP doesnβt send the token, request passes through. RateLimitingFilterexists (api-gateway/.../RateLimitingFilter.java) but usesInMemoryRateLimitStorageβ counters are per-pod on multi-pod ECS, so a moderate flood spreads thin. Anonymous limit isnβt strict enough either.- Email-domain reserved-list validation exists but only on
/newsletter/subscribe(RegistrationService.java:195rejectsexample.com). Registration confirmation accepts it. - No AWS WAF on AWS API Gateway β only a coarse 1000 req/sec global throttle (
api-gateway-stack.ts:106-109). - No honeypot field on the registration form.
- No SES-suppression / bounce hygiene β the May 4 burst of 30 stale-corporate-address bounces is still inflating the rolling rate.
Plan
Three tiers, ordered by impact-per-effort.
Tier 1 β close the bleeding edge (Β½ day)
Goal: ZAPβs next Monday scan must not burn SES quota. Real low-effort attackers should hit a 422/429 wall before the SES call.
T1.1 β Apply the reserved-email-domain validator to all outbound mail in shared-kernel EmailService.
- File:
shared-kernel/src/main/java/ch/batbern/shared/service/EmailService.java - Before any
SESClient.sendEmail/sendRawEmailcall, throwIllegalArgumentException(or a newReservedEmailRecipientException) if the recipientβs domain matches RFC 2606 / RFC 6761 reserved domains:example.com,example.org,example.net,test,localhost,invalid,*.example,*.test,*.invalid,*.localhost. - Catches every email path uniformly (registration confirm, deregister, speaker invite, partner invite, task reminder, newsletter, etc.) β one place, full coverage.
- Returns to caller as a logged-but-swallowed exception in async paths so user flows donβt 500.
T1.2 β Per-IP rate-limit the three public anonymous POSTs.
- Files:
api-gateway/.../RateLimiter.java+RateLimitingFilter.java+InMemoryRateLimitStorage.java - Add path-specific anonymous limits (per remote IP, 5-minute window):
POST /api/v1/events/*/registrationsβ 5/windowPOST /api/v1/newsletter/subscribeβ 5/windowPOST /api/v1/registrations/deregister/by-emailβ 3/window
- Closes the
DeregistrationController.java:32-34TODO. Even withInMemoryRateLimitStorage, on 2 pods each pod would allow 5 β 10 total per IP β still tight enough.
T1.3 β Suppress noisy ZAP findings on these endpoints in .zap/rules.tsv so the next scheduled scan doesnβt keep re-raising the same active alerts (we know about them). Add IGNOREs for relevant alertIds on the registration / newsletter / deregister URL patterns β only after T1.1 + T1.2 land, so we donβt hide regressions.
Expected outcome after Tier 1:
- ZAPβs POST flood β 422 from T1.1 (no SES call) and/or 429 from T1.2 (request blocked at gateway).
- Bounce rate stops climbing.
- Real low-skill abusers blocked by the same layers.
Tier 2 β defense in depth (1β2 days)
Layering note β no new datastore needed. We deliberately do NOT introduce Redis, DynamoDB or any new cache. AWS WAFβs rate-based rules give us per-IP throttling with AWS-managed state β that is the distributed rate limiter for the public anonymous paths. The existing Spring RateLimitingFilter (Caffeine, per-pod) stays as it is for authenticated/role-based limits, where per-pod accounting is good enough at our QPS. PostgreSQL remains an in-our-back-pocket option if we ever need cross-pod Spring-side limits, but itβs overkill for todayβs threat.
| Layer | Purpose | State store |
|---|---|---|
| AWS WAF rate-based (T2.1, new) | per-IP throttling on anonymous POSTs | AWS-managed |
Spring RateLimitingFilter (existing, keep) |
per-authenticated-user / role-coarse limits | Caffeine, per-pod |
| AWS API Gateway global throttle (existing) | overload protection | AWS |
T2.1 β AWS WAF Web ACL in front of AWS API Gateway.
- File:
infrastructure/lib/stacks/api-gateway-stack.ts(add new WAF construct). - Rules:
AWS-AWSManagedRulesAmazonIpReputationListβ free, catches known scanners and Tor exit nodes.AWS-AWSManagedRulesCommonRuleSet(or itsNoUserAgent_HEADERrule isolated) β many bots send no UA.- Custom rate-based rule: 10 requests / 5 min per IP on the regex
^/api/v1/(events/.+/registrations|newsletter/subscribe|registrations/deregister/by-email)$. Block on excess. - Allow-list our own GitHub Actions runner egress IP range for the security-scan workflow (or use a header secret β see T2.4).
- Cost: ~$5/month per Web ACL + per-million-request charges (negligible at our volume).
- Logs to CloudWatch for tuning.
T2.2 β (dropped β no new datastore needed; WAF is the cross-pod rate limiter).
T2.3 β SES suppression-list hygiene job.
- Scheduled Lambda (daily at 04:00 UTC) that walks the SES suppression list, re-checks any entry older than 30 days, and removes those whose domain MX still resolves. For BOUNCE entries it can be conservative (only remove with manual review); for COMPLAINT entries leave alone.
- Bonus: report stale entries so we can fix our user list (people who left a company).
- Net effect: the 4% bounce rate drops once the May-04 stale-corporate-address burst ages out properly.
T2.4 β Tag legitimate scan traffic so itβs exempt from rate-limit + reserved-domain checks.
- In
security-scan.yml, add a secret headerX-BATbern-Scan: <secret>to ZAP via the actionβscmd_options, plus expose the secret to the Spring filter via SSM parameter. RateLimitingFilterand the reserved-email check skip when the header is present and valid (constant-time compare).- Lets us keep scanning aggressively without lighting up SES.
- Alternative: replace the production target with a staging-only scan environment that does not have SES production credentials (longer-term β see T3.3).
T2.5 β Monitoring + alarms.
- CloudWatch metric filter on
Email domain is reservedlog line in api-gateway / event-management β emitsRegistrationReservedDomainBlocksmetric. - CloudWatch alarm: if WAF blocks > 50 in 5 min, or if reserved-domain blocks > 10 in 5 min, ALARM β SNS (existing
batbern-staging-alarmstopic). Gives us a heads-up before SES alarms fire. - Add
EmailsRejectedandCalendarRepliesDropped(the metric we just added) to the staging dashboard.
Tier 3 β strategic improvements (backlog)
T3.1 β Delay the calendar invite until email-confirmation click.
- Today: registration writes
confirmedrow + sends the .ics in one step. - Proposed: registration writes
pending+ sends a short confirmation email with a token link (no .ics). On click, transition toconfirmed+ send the .ics + welcome content. - Effect: every spurious registration costs us at most a tiny plain-text email, not the heavy .ics + HTML template.
- Side benefits: cleaner data, GDPR-friendlier, classic double-opt-in pattern.
- Caveat: UX change requires PM/design alignment. Story.
T3.2 β Honeypot field on the registration form.
- File:
web-frontend/.../RegistrationWizard.tsxβ add astyle="position:absolute;left:-9999px"text input with a plausible name (website,phone2, β¦). Submit if filled β silently 200, drop server-side. - 5-minute change, kills the dumbest bots, harmless to humans.
T3.3 β Dedicated scan environment.
- The ZAP workflow already targets
api.batbern.ch(= production). Migrate it to a parallelscan.batbern.chor an isolated staging stack withSES_ENABLED=falseand a stubbed mailer. - Removes the conflict between security scanning and production protection.
- Bigger infra change β wait until traffic justifies.
T3.4 β (removed β handled by WAF; no Spring-side change required).
Out of scope on purpose
- Tightening Turnstile to fail-closed β blocks corporate users (commit
4079d394). - Replacing Turnstile with a different CAPTCHA β same blocker.
- Adding auth to currently-public registration endpoints β breaks the public form.
- Detection-evasion fingerprinting / device-fingerprinting heuristics.
Open questions for stakeholders
- Tier 1 first β small, reversible, kills todayβs incident loop. Approve to start?
- T2.1 (AWS WAF) β ~$5/month plus per-request. Acceptable?
- T2.4 β scan-bypass via header secret is operationally simpler than allow-listing GitHub runner IPs (which are dynamic). Any objection?
- T3.1 (double opt-in for registration) β UX/PM call. Worth scoping a story?
Verification plan after Tier 1 lands
- Manually re-run
security-scan.ymlviaworkflow_dispatchand confirm:- SES send count stays flat during the scan
- WAF/gateway logs show 422/429 for
zaproxy@example.comregistrations - ZAP report still lists the findings (information disclosure), but no longer triggers the active email-send finding
- Watch the
Reputation.BounceRatemetric for 15 days. Expected: gradual drop from 4% as the May-04 suppression burst ages out (it expires 2026-05-19 from the 15-day window). - Spot-check public registration on
www.batbern.ch(mobile, desktop, corporate VPN) β must still work end-to-end.
References
- Triggering commit (Turnstile fail-open rationale):
4079d394 fix(turnstile): fail-open when token is missing, block only on invalid token(2026-05-06) - Email forwarding hardening (same incident):
a3ac0a97 fix(email): stop iMIP calendar replies from fan-out to organizers(2026-05-18) - TODO acknowledged in code:
DeregistrationController.java:32-34 - ZAP workflow:
.github/workflows/security-scan.yml - ZAP rule overrides:
.zap/rules.tsv - Existing rate limiter:
api-gateway/src/main/java/ch/batbern/gateway/security/RateLimitingFilter.java - Reserved-domain validator (newsletter only today):
services/event-management-service/src/main/java/ch/batbern/events/service/RegistrationService.java:195