Skip to main content

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

AttackImpact without protectionCaught by
Script kiddie hammers /api/v1/pilot-applications 100k times from one IPApp Runner OOM, DB pollution, SES bounce-rate spike → SES suspension for 24-72h, real customers' emails silently failLayer 1 (rate limit per-IP)
Headless browser walks the form, fills every visible-to-DOM fieldSame as above, but bypasses naive bot detectionLayer 2 (honeypot)
Sophisticated bot solves CAPTCHA via a paid solver service ($0.001/solve)Real cost: $1 = 1,000 attempts. Fakes legitimate-looking trafficLayer 3 (Turnstile) + Layer 1 still catches per-IP volume
Compromised customer account scripts /api/v1/inspections/draft 10k timesDirect AI bill — ~$50-500 burn before noticedLayer 4 (per-user rate limit on AI endpoints)
Distributed attack across 1000 IPs, each below per-IP limitLayer 1 doesn't fireFuture: 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:

  1. Skips OPTIONS requests (CORS preflight).
  2. Looks up applicable scopes for the request path.
  3. For each scope, calls limiter.tryConsume(scope, clientIp).
  4. If any scope denies, returns HTTP 429 with Retry-After header and a JSON body containing {error, message, scope, retryAfterSeconds, at}.
  5. The SPA's api.ts handles 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 readers
  • tabindex="-1" — skipped by tab key
  • autocomplete="off" — password managers don't fill it
  • pointer-events:none — can't be focused by mouse
  • opacity: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.website in PilotApplicationController.

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-widget on the form. Outputs a token via window.turnstile.getResponse().
  • Server sideCloudflareTurnstileVerifier calls challenges.cloudflare.com/turnstile/v0/siteverify with the secret key + token. Returns Result.success() or Result.failed(errorCodes).
  • Disabled mode — when app.turnstile.secret-key is blank, NoOpTurnstileVerifier is registered instead, returns Result.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

  1. Sign up free at https://dash.cloudflare.com/sign-up
  2. TurnstileAdd site
  3. Domain: your domain (e.g. pmfriend.com)
  4. Widget mode: Managed
  5. Copy the Site key (public) and Secret key (server-side)
  6. Update the landing form: set window.PMFRIEND_TURNSTILE_SITE_KEY = '<site-key>'
  7. Set the env var on App Runner: APP_TURNSTILE_SECRET_KEY=<secret-key>
  8. Redeploy the backend (gh workflow run "Build & deploy to App Runner")
  9. Redeploy the landing (bash deploy/landing.sh)
  10. 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 propertyDefaultPurpose
app.turnstile.secret-keyempty (verifier disabled)Cloudflare server-side secret key
APP_TURNSTILE_SECRET_KEY env varemptySame 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 interfaces
  • infrastructure/abuse/{Bucket4jRateLimiter,CloudflareTurnstileVerifier,NoOpTurnstileVerifier}.java — adapters
  • infrastructure/config/AbuseProtectionConfig.java — minus the project-specific scope names
  • web/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 shapePublic-IP scopeAuth-user scope
Lead form (PMFriend pilot, Pflege-Orga signup, P4F application)3/h, 20/dn/a
Auth login (any project)10/hn/a
Report submission with token (PMFriend tenant report, P4F feedback)5/h, 30/dn/a
AI drafter / scoring (PMFriend inspections, P4F summaries)n/a30/h, 200/d
Heavy AI endpoint (case packs, full report generation)n/a10/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

DecisionWhy
No Cloudflare WAF / nginx in frontPremature complexity at our scale (≪1k req/sec). Add when traffic justifies.
No CAPTCHA on the SPA admin loginDifferent 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 storeSingle 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 defaultTurnstile 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

  1. Add scope spec in AbuseProtectionConfig
  2. Add path → scope mapping in RateLimitFilter
  3. 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:

If real users are getting rejected: switch widget mode from interaction-only to managed (more permissive — auto-passes more users without UI).