Abuse Protection
Three independent layers protect public-facing forms and AI-cost-bearing endpoints from automated abuse, scripted floods, and account takeover. Each layer catches a different attack class — the layered design is the point. Skipping any one halves the protection.
Threat model
| Attack | Impact without protection | Caught by |
|---|---|---|
Script kiddie hammers /api/v1/pilot-applications 100k times from one IP | App Runner OOM, DB pollution, SES bounce-rate spike → SES suspension for 24-72h, real customers' emails silently fail | Layer 1 (rate limit per-IP) |
| Headless browser walks the form, fills every visible-to-DOM field | Same as above, but bypasses naive bot detection | Layer 2 (honeypot) |
| Sophisticated bot solves CAPTCHA via a paid solver service ($0.001/solve) | Real cost: $1 = 1,000 attempts. Fakes legitimate-looking traffic | Layer 3 (Turnstile) + Layer 1 still catches per-IP volume |
Compromised customer account scripts /api/v1/inspections/draft 10k times | Direct AI bill — ~$50-500 burn before noticed | Layer 4 (per-user rate limit on AI endpoints) |
| Distributed attack across 1000 IPs, each below per-IP limit | Layer 1 doesn't fire | Future: global daily SES send cap (alerted, not yet enforced) |
The first three layers ship today. The fourth (per-user limits on AI endpoints) ships in the same change but is half-active — Spring's filter ordering means we currently best-effort it; full coverage needs a follow-up split into two filter beans (see "Known limitation" below).
Layer 1 — Bucket4j rate limiting
In-memory token-bucket rate limit, keyed on client IP for public endpoints and on user id for auth-required endpoints.
Where the code lives
application/src/main/java/au/com/pmfriend/application/abuse/ports/
RateLimiter.java // Port: tryConsume(scope, identity) → Decision
infrastructure/src/main/java/au/com/pmfriend/infra/abuse/
Bucket4jRateLimiter.java // In-memory adapter
infrastructure/src/main/java/au/com/pmfriend/infra/config/
AbuseProtectionConfig.java // Wires the scopes (limits live here)
web/src/main/java/au/com/pmfriend/web/abuse/
RateLimitFilter.java // Servlet filter — runs at HIGHEST_PRECEDENCE+5
How a scope is configured
Scopes are configured in code (not yaml) — they're product policy, not infra config, so they should ship with the endpoint they protect:
// In AbuseProtectionConfig.rateLimiter()
scopes.put("pilot-applications-hourly",
new ScopeSpec(3, Duration.ofHours(1)));
scopes.put("pilot-applications-daily",
new ScopeSpec(20, Duration.ofDays(1)));
Multiple scopes per endpoint stack — for the pilot form, both the hourly AND daily check must pass. This catches both burst attacks (40 submits in 5 min) and slow grinds (one-per-hour for a day).
Path → scope mapping
Mapping lives in RateLimitFilter (also code, not yaml):
private static final Map<String, List<String>> PUBLIC_SCOPES = Map.of(
"/api/v1/pilot-applications", List.of("pilot-applications-hourly", "pilot-applications-daily"),
"/api/v1/auth/demo-login", List.of("demo-login-hourly")
// /api/v1/report/{token} matched with startsWith
);
The filter:
- Skips
OPTIONSrequests (CORS preflight). - Looks up applicable scopes for the request path.
- For each scope, calls
limiter.tryConsume(scope, clientIp). - If any scope denies, returns HTTP 429 with
Retry-Afterheader and a JSON body containing{error, message, scope, retryAfterSeconds, at}. - The SPA's
api.tshandles 429 by surfacing the message to the user.
Client IP extraction
App Runner sits behind Envoy which sets X-Forwarded-For with the
originating IP as the leftmost entry. Filter trusts this header because
nothing else talks to our container directly:
private static String clientIp(HttpServletRequest req) {
String xff = req.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
int comma = xff.indexOf(',');
return (comma > 0 ? xff.substring(0, comma) : xff).trim();
}
return req.getRemoteAddr();
}
What happens when a customer hits the limit
- HTTP 429 Too Many Requests with
Retry-After: <seconds> - JSON body:
{"error":"rate_limited", "message":"Too many requests — please try again in 12 seconds.", "scope":"pilot-applications-hourly", "retryAfterSeconds":12, "at":"..."} - Logged with last-octet-redacted IP at INFO level
- The bucket auto-refills smoothly across the window — they don't have to wait the full hour, just for the next token to land
Memory + restart behaviour
Buckets are stored in ConcurrentHashMap<String, Bucket> — one entry
per (scope, identity) combo. Approximately 200 bytes per entry, so 100k
unique IPs/day = 20MB. Container restart wipes all state, which is
deliberate: an attacker losing rate-limit state on every deploy is
fine; real users almost never hit the limits anyway.
Known limitation: per-user AI limits
The filter runs at HIGHEST_PRECEDENCE+5 so public-IP rate limits
fire before Spring Security spends cycles on auth. But that means the
SecurityContextHolder isn't populated yet — so per-user AI rate limits
in the same filter are best-effort.
Today: AI endpoint rate limits fall through if auth == null and
let the controller fire. Compromised-account abuse is bounded by
Spring Security's 401-on-bad-token, but a logged-in attacker isn't
rate-limited per-user yet.
Follow-up: split into two filter beans — one before Spring Security for public IP scopes, one after for auth user scopes. Tracked under the roadmap. Until then the public-endpoint exposure (the SES-suspension risk) is fully covered, which was the urgent half.
Layer 2 — Honeypot field
A hidden form input that real users never see. Bots that walk the DOM fill every input including the hidden one.
How it's hidden
CSS-only, no JavaScript:
<div aria-hidden="true" style="position:absolute;left:-9999px;top:-9999px;
width:1px;height:1px;opacity:0;
pointer-events:none;">
<label>Website (leave empty)
<input type="text" name="website" tabindex="-1" autocomplete="off" maxlength="200" />
</label>
</div>
Combination of techniques makes it invisible AND skipped-by-tab-key even for accidental keyboard navigators:
- Off-screen positioning (
left:-9999px) — hides from sighted users aria-hidden="true"— hidden from screen readerstabindex="-1"— skipped by tab keyautocomplete="off"— password managers don't fill itpointer-events:none— can't be focused by mouseopacity:0— final paranoia layer
Server-side handling
In PilotApplicationController.submit():
if (body.website != null && !body.website.isBlank()) {
log.info("honeypot tripped — discarding ...");
return ResponseEntity.accepted().body(new Response(
"00000000-0000-0000-0000-000000000000", // sentinel UUID
false,
Instant.now()));
}
We return a fake 202 success so the bot thinks it landed and doesn't escalate (rotate IPs faster, switch to a sophisticated solver, etc). Logged with redacted IP for visibility.
Field name choice
website is a deliberate choice:
- It's URL-shaped, which bots are trained to fill (URL fields are common in legitimate SaaS forms — "your company website?")
- Generic enough that field-name fingerprinting requires real effort
- Easy to swap if attackers fingerprint it. To rename: change BOTH
the form field name AND
Request.websiteinPilotApplicationController.
Layer 3 — Cloudflare Turnstile
Invisible-by-default CAPTCHA. Catches bots that pass layers 1 and 2 (real browsers running headless, paid CAPTCHA solver services) by scoring browser signals + behaviour patterns.
Architecture
- Client side — Turnstile widget loads from
challenges.cloudflare.com. Renders into#turnstile-widgeton the form. Outputs a token viawindow.turnstile.getResponse(). - Server side —
CloudflareTurnstileVerifiercallschallenges.cloudflare.com/turnstile/v0/siteverifywith the secret key + token. ReturnsResult.success()orResult.failed(errorCodes). - Disabled mode — when
app.turnstile.secret-keyis blank,NoOpTurnstileVerifieris registered instead, returnsResult.disabled()for everything. Form keeps working without Turnstile keys configured.
Where the code lives
application/src/main/java/au/com/pmfriend/application/abuse/ports/
TurnstileVerifier.java // Port
infrastructure/src/main/java/au/com/pmfriend/infra/abuse/
CloudflareTurnstileVerifier.java // Production
NoOpTurnstileVerifier.java // Dev / pre-key fallback
infrastructure/src/main/java/au/com/pmfriend/infra/config/
AbuseProtectionConfig.turnstileVerifier() // Conditional bean wiring
Fail-open philosophy
Turnstile's siteverify endpoint is rarely down, but when it is,
CloudflareTurnstileVerifier fails open:
} catch (RuntimeException | IOException | InterruptedException e) {
log.warn("Turnstile verify failed transport-level — failing open", e);
return Result.success();
}
Reasoning: a 5-minute Cloudflare hiccup shouldn't silently break lead capture. Layers 1 and 2 still defend during a Turnstile outage.
How to enable
- Sign up free at https://dash.cloudflare.com/sign-up
- Turnstile → Add site
- Domain: your domain (e.g.
pmfriend.com) - Widget mode: Managed
- Copy the Site key (public) and Secret key (server-side)
- Update the landing form: set
window.PMFRIEND_TURNSTILE_SITE_KEY = '<site-key>' - Set the env var on App Runner:
APP_TURNSTILE_SECRET_KEY=<secret-key> - Redeploy the backend (
gh workflow run "Build & deploy to App Runner") - Redeploy the landing (
bash deploy/landing.sh) - Submit the form once to verify; check App Runner logs for
turnstile=verified
To disable temporarily without removing keys: set
window.PMFRIEND_TURNSTILE_SITE_KEY = '' in the HTML, redeploy
landing. Backend will start receiving empty tokens and the verifier
will reject (because secret key still configured) — so disable
both keys together.
Putting it all together — request flow
For POST /api/v1/pilot-applications:
┌─────────────────┐
Form submit → │ RateLimitFilter │ Layer 1: per-IP, public scope
└────────┬────────┘ → 429 + Retry-After if exceeded
▼
┌─────────────────┐
│ Spring Security │ → 401 if endpoint requires auth
└────────┬────────┘ (this endpoint is public, passes through)
▼
┌─────────────────┐
│ Controller │ Layer 2: honeypot field check
│ │ → fake 202 success if tripped
│ │
│ │ Layer 3: Turnstile token verify
│ │ → 400 if failed
│ │
│ │ Layer 4: domain logic
│ │ → use case + DB + email
└─────────────────┘
The combination catches:
- 95% of script-kiddie scripts at Layer 1 (per-IP volume)
- 4% of dumber bots that pass Layer 1 at Layer 2 (DOM-walkers)
- 0.99% of sophisticated bots / paid solvers at Layer 3
- Resulting <0.01% pass-through rate is roughly noise
Configuration reference
| Config property | Default | Purpose |
|---|---|---|
app.turnstile.secret-key | empty (verifier disabled) | Cloudflare server-side secret key |
APP_TURNSTILE_SECRET_KEY env var | empty | Same as above, env-driven for prod |
Rate-limit specs live in AbuseProtectionConfig.java — see comments
there for what each limit catches and why those numbers.
Reusing this pattern
The protection stack is portable across PMFriend's sibling projects. For Pflege-Orga and Pay4Feedback:
What to copy verbatim
application/abuse/ports/{RateLimiter,TurnstileVerifier}.java— port interfacesinfrastructure/abuse/{Bucket4jRateLimiter,CloudflareTurnstileVerifier,NoOpTurnstileVerifier}.java— adaptersinfrastructure/config/AbuseProtectionConfig.java— minus the project-specific scope namesweb/abuse/RateLimitFilter.java— minus the project-specific path mappings
What to adapt per project
- Scope names + limits (in
AbuseProtectionConfig.rateLimiter()) — driven by the actual public + AI endpoints in that project - Path → scope mapping (in
RateLimitFilter.PUBLIC_SCOPES/AI_SCOPES) - Honeypot field: add
website(or your project-specific name) to the relevant request DTO + controller check + form HTML - Turnstile widget: add to whichever public form needs it; Cloudflare Turnstile lets you have multiple sites under one account at no extra cost
Suggested limits per project type
| Endpoint shape | Public-IP scope | Auth-user scope |
|---|---|---|
| Lead form (PMFriend pilot, Pflege-Orga signup, P4F application) | 3/h, 20/d | n/a |
| Auth login (any project) | 10/h | n/a |
| Report submission with token (PMFriend tenant report, P4F feedback) | 5/h, 30/d | n/a |
| AI drafter / scoring (PMFriend inspections, P4F summaries) | n/a | 30/h, 200/d |
| Heavy AI endpoint (case packs, full report generation) | n/a | 10/h, 50/d |
Dependency
// libs.versions.toml
bucket4j = "8.10.1"
bucket4j-core = { module = "com.bucket4j:bucket4j-core", version.ref = "bucket4j" }
// modules/infrastructure/build.gradle.kts
implementation(libs.bucket4j.core)
That's the only third-party lib needed. Cloudflare Turnstile is just HTTP calls + an external script tag — no SDK to import.
What's intentionally NOT here
| Decision | Why |
|---|---|
| No Cloudflare WAF / nginx in front | Premature complexity at our scale (≪1k req/sec). Add when traffic justifies. |
| No CAPTCHA on the SPA admin login | Different threat model — admin is auth-gated, password attacks are bounded by Spring Security's failed-login backoff. CAPTCHA would just annoy real PMs. |
| No Redis-backed rate limit store | Single App Runner instance today. In-memory works. Switch to Redis when we go multi-instance. |
| No global daily SES send cap (yet) | Catches distributed attacks but adds complexity for a low-likelihood threat. Worth adding once we have >100 paying customers. |
| No CAPTCHA UI on the form by default | Turnstile in interaction-only mode shows nothing for ~95% of users. The other 5% see a small "press and hold" widget. Friction-light. |
Operations
Tuning a rate limit
Edit AbuseProtectionConfig.rateLimiter(). Limits are baked into the
binary — change requires a deploy. This is on purpose: rate-limit
changes are product policy and should be reviewed in PR.
Adding a new endpoint to rate-limit
- Add scope spec in
AbuseProtectionConfig - Add path → scope mapping in
RateLimitFilter - Ship in same PR as the endpoint
Honeypot tripped — investigating
Logs include honeypot tripped at INFO level with redacted IP + UA.
Search CloudWatch for that message; pattern of UAs tells you what
class of bot is hitting you. If logs show >1k trips/day, consider
renaming the honeypot field (some bot has fingerprinted "website").
Turnstile rejecting real users
Check the App Runner logs for turnstile rejected lines with the
error codes. Common ones:
timeout-or-duplicate— user took >300s to submit, or token already usedinvalid-input-response— user submitted the form with widget not rendered (probably a script blocker like Privacy Badger)- Cloudflare error reference: https://developers.cloudflare.com/turnstile/troubleshooting/
If real users are getting rejected: switch widget mode from
interaction-only to managed (more permissive — auto-passes more
users without UI).