DET-BETA-005 — join token enumeration
Rule ID: DET-BETA-005
Title: High volume of invalid-token state-check requests from one IP — beta join endpoint probe
Category: beta
Last validated: 2026-06-18 (beta-phase2 catalog; grounded against as-built beta_join.py)
State: live — FLAG_BETA_PHASE2_ACCESS is ON in prod; GET /api/beta/join/<token>/state is internet-reachable (CF-Access-bypassed for NDA-gated invite flow). Rate-limit events log to Raptor stdout today. Monitoring starts immediately.
Why this detection exists
GET /api/beta/join/<token>/state is the token-validity probe endpoint. It is intentionally anti-enumerating: the route returns HTTP 200 for all token states (valid, invalid, expired, revoked) — the response body {"valid": false} is the only distinguishing signal. This design prevents an attacker from using HTTP status codes to confirm token existence, but the response-body signal is machine-readable. An actor querying this endpoint at volume will observe {"valid": false} responses and iterate tokens, even though guessing an HMAC-SHA256 token is computationally infeasible. The observable is the volume, not the success rate.
The endpoint is rate-limited at 10 requests/minute per IP (@rate_limit(requests_per_window=10, window_seconds=60) in backend_v2/api/routes/beta_join.py). A sustained campaign is detectable as a series of rate-limit events across the rolling window rather than individual large bursts.
Per the design in docs/architecture/beta-phase2-join-flow.md §12, HMAC tokens with sufficient entropy make brute force impractical, but a volume spike warrants investigation for: (a) HMAC key exposure followed by targeted token generation, or (b) token harvesting from email forwarding and then systematic state-probing to find unconsumed tokens.
Telemetry source
- Raptor app logs (stdout to Heroku Logplex):
GET /api/beta/join/<token>/stateresponse distribution via Heroku router log. backend_v2/api/routes/beta_join.py— thejoin_state()handler logs:beta_join.state HMAC misconfiguration— RuntimeError (HMAC key missing or short).beta_join.state transient Console failure — returning unavailable— TransientVerifyError, produces{"valid": false, "unavailable": true}body.- Successful verify produces no INFO log; response body contains
{"valid": true/false}. - Rate-limiter middleware logs:
rate_limit_exceededevents fromapi/middleware/rate_limiter.pywhen the 10/min ceiling is hit. - Console app logs:
/internal/api/verify-walkthrough-tokenlogs 403 (internal_auth_failed) and invalid-token 401 outcomes — each state-check triggers one Console verify call.
Telemetry gap — CRITICAL: Heroku Logplex drain to a queryable store is not yet wired (prerequisite P1 from the June 4 detection catalog). Until the drain exists, this detection runs as a manual heroku logs grep rather than an automated query. Rule is marked live but manual until the drain is wired.
Statistical method + baseline window
- Method: count of state-check requests from one source IP /24 prefix per rolling 5-minute window. Legitimate testers arrive with exactly one token (minted for them); they make 0 or 1 state-check calls, all returning
{"valid": true}. Sustained volume from a single IP is probe traffic. - Baseline window: rolling 7 days per source-IP /24 prefix. Pre-launch baseline is zero-seeded; the absolute-floor threshold governs until real traffic accumulates.
- Fire condition (primary): > 10 requests to
/api/beta/join/*/statefrom one source IP /24 in any rolling 5-minute window.
Note: distinguishing {"valid": false} from {"valid": true} requires response-body inspection, available in Raptor logs only if the token-verify outcome is logged. Until the drain is wired, total request count per IP to the state endpoint is the practical proxy — {"valid": true} responses are rare (one per tester per token), so sustained volume is almost certainly probe traffic.
Threshold + expected FP rate
- Absolute threshold: > 10 state-check requests from one source IP /24 in any 5-minute window. This matches the AC trigger condition in issue #3552.
- Soft threshold (informational): 6–10 requests per IP per 5 minutes — log to
docs/detections/_log/as LOW. - Expected FP rate: very low. Legitimate testers make 0 or 1 state checks. False positives arise from:
- Operator testing multiple invite tokens from the same development IP. Suppress from operator VPN IPs per
user_uses_vpn(Datacamp/CDN77 ASNs). - Link-preview bots from email security proxies fetching the state URL from an invite email — single requests from known provider ASNs, not sustained bursts.
- Antlers frontend retry logic on a transient Console failure (the
"unavailable": truepath). This produces a small controlled retry, not a > 10-request burst.
Alert route
- HIGH (> 10 state-check requests per IP per 5 min OR any IP hitting the rate-limit ceiling 3+ times within 15 min):
#raxx-ops-alert-sev2-5(ET hours, 13:00–20:00 UTC) /#raxx-ops-alert-sev2(off-hours). Per-event exception to pre-launch digest rule — active token probing during the beta join window warrants same-hour review. - MEDIUM (6–10 requests per IP per 5 min, single occurrence): ops@ daily digest.
- LOW (pattern not conclusive, sub-threshold):
docs/detections/_log/silent log entry.
Escalation owner
- security-agent — if token slug patterns in the URL path show structure (e.g., sequential or clustered suffixes) suggesting targeted generation from a known signing key. Potential HMAC key exposure scenario.
- sre-agent — if the spike is uniform across many IPs simultaneously. This indicates Console's verify endpoint is down and rejecting all tokens, producing
{"valid": false}responses that are a Console outage, not a probe. - operator — if source IP /24 overlaps with a known beta tester's ISP or the operator's VPN exit. Verify out-of-band before filing.
VPN operator cross-check
Per user_uses_vpn: Datacamp/CDN77 ASN ranges are operator VPN exits. Before filing any alert, verify source IP is not in those ranges.
Test fixture / synthetic positive
See _fixtures/join_token_enumeration_positive.json — 14 synthetic state-check requests from 192.0.2.20 (/24 prefix: 192.0.2.0/24) within 4 minutes, all returning {"valid": false}, token slugs are random HMAC-format values showing no sequential structure.
Manual query (until Heroku drain exists)
heroku logs --app raxx-api-prod --num 2000 \
| grep 'beta/join' \
| grep '/state'
Group by the source IP in the X-Forwarded-For field (leftmost IP, set by Cloudflare edge). Count per IP per 5-minute bucket. Any IP with > 10 entries in a bucket triggers the rule.
What NOT to do
- Do not adjust the always-200 anti-enumeration design of the state endpoint in response to this rule firing. The design is correct; this rule monitors volume.
- Do not auto-block the IP from this detection alone. Block decisions go to security-agent + operator.
- Do not conflate with DET-BETA-006 (token sharing). DET-BETA-005 fires on high volume from one IP; DET-BETA-006 fires on a valid token claimed from an IP inconsistent with the prior state-check history.