DET-BETA-007 — join geo-block bypass attempt
Rule ID: DET-BETA-007
Title: Repeated 403 blocked_region responses on /claim from the same tester — geo-block bypass 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; POST /api/beta/join/<token>/claim emits beta.join.geoblock_rejected audit events to audit_log when the geo-block triggers (written in backend_v2/api/routes/beta_join.py at step 3 of the claim processing order). Audit events are queryable from Raptor Postgres directly.
Why this detection exists
Geo-block invariant I-6 applies to the join-token path: even NDA-acknowledged invited testers in blocked regions cannot create accounts until compliance is resolved (OQ-2). The geo-block check in join_claim() runs at processing step 3 — after token re-verify and NDA check, before the atomic token consumption. It uses CF-IPCountry and CF-IPRegion headers set by the Cloudflare edge (stripped from client-supplied headers by CF Access, so they cannot be spoofed at the application layer).
When blocked, the endpoint:
1. Logs beta_join.claim geo_blocked email_hash=... country=... region=... at INFO level.
2. Writes a beta.join.geoblock_rejected audit event to audit_log with tester_email_hash, jti, ip_prefix, country, and region in the context JSON.
3. Returns 403 {"error": "blocked_region"}.
Repeated 403 responses from the same tester email hash within a 30-minute window indicate the tester is retrying from multiple IP addresses or attempting to appear from different country-of-origin — a VPN bypass attempt against the CF geo-signal. The detection operationalizes this pattern by counting beta.join.geoblock_rejected audit events grouped by tester_email_hash over time.
Telemetry source
audit_logtable in Raptor Postgres:beta.join.geoblock_rejectedevent rows include:context->>'tester_email_hash'— truncated SHA-256 of tester email (first 16 hex chars, from_hash_email()).context->>'jti'— token identifier.context->>'country'—CF-IPCountryvalue at time of request.context->>'region'—CF-IPRegionvalue at time of request (e.g.,QCfor Quebec).ip_prefixcolumn — /24 (IPv4) or /48 (IPv6) prefix ofrequest.remote_addr.created_attimestamp.- Raptor app logs:
beta_join.claim geo_blocked email_hash=... country=... region=...INFO log on every blocked claim. - Raptor app logs:
beta_join.claim geoblock_audit_write_failed email_hash=...EXCEPTION log if the audit event write fails (indicatesaudit_logis unavailable — separate ops concern).
Telemetry availability: audit_log is Raptor Postgres, queryable directly (no Heroku drain dependency). This detection has the strongest telemetry of the three DET-BETA-005/006/007 cluster.
Statistical method + baseline window
Event-count detection on the beta.join.geoblock_rejected audit stream:
- Method: count of distinct
beta.join.geoblock_rejectedevents pertester_email_hashper rolling 30-minute window. - Baseline window: none required — any count >= 2 from the same tester email hash is anomalous. A legitimate tester who is geo-blocked gets one 403, understands they cannot proceed, and stops. A second attempt within 30 minutes from a different IP prefix or different reported country is the bypass-probe signal.
- Fire condition: >= 2
beta.join.geoblock_rejectedevents for the sametester_email_hashwithin a 30-minute window. This matches the AC trigger condition in issue #3552.
Threshold + expected FP rate
- Absolute threshold: >= 2
beta.join.geoblock_rejectedevents for the sametester_email_hashin any 30-minute window. - Escalation threshold: >= 3 events where the
countryfield changes between events for the sametester_email_hash(confirms VPN cycling, not a simple retry). - Expected FP rate: low. Legitimate FPs:
- Tester entering an incorrect country in the request body — but the geo-block reads CF headers, not the request body. The request body field is not used; the geo-signal is CF-authoritative. A legitimate tester in a non-blocked country will not receive a 403 from the geo-block step regardless of what they submit in the body. This FP vector does not exist in the as-built implementation.
- Tester using a commercial VPN that routes their traffic through a blocked country — one or two 403s while switching VPN exit nodes. This is the primary FP scenario. Cross-check IP against known VPN/CDN ranges (Datacamp/CDN77 for operator; general VPN ASN list for testers).
- Cloudflare miscategorizing the tester's country (rare CF geo-DB miss). If all 403s report the same country and the tester is genuinely outside that country, treat as CF geo-DB error and escalate to sre-agent.
Alert route
- HIGH (>= 3 events for same
tester_email_hashwithin 30 min, ANDcountryfield differs between at least 2 events):#raxx-ops-alert-sev2-5(ET hours, 13:00–20:00 UTC) /#raxx-ops-alert-sev2(off-hours). Per-event — active geo-block bypass during the beta join window warrants same-hour review. - MEDIUM (= 2 events for same
tester_email_hashwithin 30 min, same country on both): ops@ daily digest. This is likely a retry after a transient failure or a legitimate VPN mis-route. - LOW (single
beta.join.geoblock_rejectedevent): silent — expected behavior for a geo-blocked tester discovering the block.
Escalation owner
- operator — decision on whether to grant a tester exemption (OQ-2 path). Do not contact the tester directly until the operator decides.
- security-agent — if the
jtiin the geo-block event is also associated with abeta.join.claimedevent from a different IP /24 (token was shared cross-border; cross-reference with DET-BETA-006). This is the intersection of token sharing + geo-block bypass. - sre-agent — if the
countryfield on all events is the same country and the tester is known to be in a non-blocked region. Likely a CF geo-DB error; escalate for a WAF override or IP-allow rule.
CF geo-signal accuracy note
The geo-block reads CF-IPCountry and CF-IPRegion headers set by Cloudflare:
cf_country = (request.headers.get("CF-IPCountry") or "").strip().upper()
cf_region = (request.headers.get("CF-IPRegion") or "").strip().upper()
These headers are stripped and re-set by the CF edge on every request. They cannot be spoofed by the client when the application sits behind CF Access. The only bypass vectors are: (a) commercial VPN that routes through a non-blocked country's CF PoP, or (b) CF geo-DB miss for the tester's actual IP. Both produce the same observable: legitimate country header, not matching the tester's actual location. Detection distinguishes these by the ip_prefix change between events.
Test fixture / synthetic positive
See _fixtures/join_geoblock_bypass_positive.json — two synthetic beta.join.geoblock_rejected audit events for tester_email_hash=synth-tester-blocked-01 within 18 minutes: first event country=DE (Germany, EU) from ip_prefix=192.0.2.0/24, second event country=FR (France, EU) from ip_prefix=198.51.100.0/24 — representing a tester cycling VPN exit nodes to find a non-blocked EU country, unsuccessfully.
Postgres query (direct — no drain required)
-- Find tester email hashes with >= 2 geoblock_rejected events in any 30-minute window
SELECT
context->>'tester_email_hash' AS email_hash,
context->>'country' AS country,
context->>'region' AS region,
ip_prefix,
created_at
FROM audit_log
WHERE action = 'beta.join.geoblock_rejected'
AND created_at > now() - interval '24 hours'
ORDER BY context->>'tester_email_hash', created_at;
Group the results by email_hash. Any email_hash appearing 2+ times within a 30-minute span triggers the rule. Country changes between rows on the same email_hash confirm VPN cycling.
What NOT to do
- Do not grant a tester exemption from the geo-block based on this detection alone. OQ-2 is unresolved; operator makes the exception decision.
- Do not contact the tester directly about the block. They received a
403 {"error": "blocked_region"}response; follow-up is operator-initiated. - Do not disable the geo-block for the entire beta join path because of a single tester's bypass attempt. The block applies to all; individual exceptions go through the operator action path.
- Do not conflate with DET-BETA-005 (enumeration) or DET-BETA-006 (token sharing). DET-BETA-007 fires specifically on the
beta.join.geoblock_rejectedaudit event stream; the other detections fire on invalid-token volume and IP-mismatch claim patterns respectively.