fix(auth): reject unsupported OTP destinations before paying Twilio - #496
Conversation
The 2026-08-25 SMS-pumping attack cost $439.79 in one day (1,569 messages vs a ~$1/day baseline). Captcha and rate limits were both enforced; the attacker rotated IPs and solved captcha, staying under 16/hour/IP. The gap was ordering: the destination country is only checked by PhoneMetadataAuthorizer at login completion and rewards, i.e. after the message has already been sent and billed. Every fraudulent OTP went to a country Flash does not serve and was paid for regardless. - enforce the existing per-channel supported-country config in requestPhoneCodeWithCaptcha and requestPhoneCodeForAuthedUser, before the provider call. Previously these lists only filtered the client's country picker and were never checked server-side. - seed both lists with the attack's origin countries: every one sent auth-code traffic with zero conversions across the full Twilio retention window. Countries with even one real signup are excluded by design; this is a fraud control, not a market policy. - halve requestCodePerIp to 8/hour. 16/hour x ~100 rotated IPs matches the observed burst almost exactly, and a real user needs one or two. Unparseable numbers are rejected rather than forwarded. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018sCFQXDf4s6f1zsrpmaShL
…king
Review fixes on top of the destination gate:
- checkAuthCodeDestination now lowercases the channel itself. POST
/auth/phone/code forwards req.body.channel verbatim ("SMS"/"WHATSAPP")
while the GraphQL resolvers lowercase it, so a WhatsApp request on that
route was gated against the SMS list. Harmless only while the two lists
are identical, which is exactly what will stop being true.
- isAuthChannelSupportedForCountry and getSupportedCountries compare
case-insensitively. The lists are raw configmap strings with no
validation, so `- uz` silently disabled both the fraud control and the
picker filter.
- an unparseable number returns InvalidPhoneNumber, not
PhoneCountryNotAllowedError. The country is unknown, not disallowed;
the old error told the user to contact support and poisoned any later
count of which countries the gate rejects.
- PhoneCountryNotAllowedError gets its own case in error-map instead of
falling into the catch-all, which rendered a deliberate policy
rejection as "Unexpected error occurred, please try again or contact
support". Mirrors RestrictedRegionPhoneProviderError.
- every block emits baseLogger.warn plus a "destination-blocked" ops
event, so blocked-country counts land in the existing ops feed and the
list can be tuned from data rather than guesswork.
- the login/signup path keeps an existing-user carve-out: a phone already
in the users collection still gets its code even if its country is on
the list, so no established account is permanently locked out by a
control aimed at unregistered traffic. The lookup is local, costs no
Twilio spend, and fails closed. requestPhoneCodeForAuthedUser has no
carve-out — binding a phone there is always a new registration.
Tests: requestPhoneCodeForAuthedUser now has its own gate coverage
(blocked country, no otp-sent event, per-channel lists, no carve-out);
config/schema.spec asserts the shipped defaults against the real
configSchema with no @config mock, so reverting the seeded lists to []
fails; domain and app specs cover lowercase config entries; error-map
spec pins the dedicated mapping.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…atch-all The country-gate review assumed InvalidPhoneNumber "already maps to a clean client error". It did not — it sat in the same UnexpectedClientError bucket PhoneCountryNotAllowedError was just pulled out of, so a malformed number told the user to retry and contact support, and the catch-all message interpolates error.message, echoing the submitted number back. Both checkedToPhoneNumber and the new auth-code destination check produce this error, so both now render "Phone number is not a valid phone number" alongside the provider-side twin InvalidPhoneNumberPhoneProviderError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…out true and visible Four review findings on the destination country gate, all in the existing-user carve-out and its telemetry. Ask Kratos, not Mongo, whether a number exists. Whether a number can log in is decided by the identity store — login.ts resolves it with getUserIdFromIdentifier and skips onboarding when it exists — while the Mongo user doc is written afterwards by a /registration webhook that can fail with the identity already created. Asking findByPhone would refuse a login code forever to an account that logs in fine today: the exact lockout the carve-out exists to prevent. IdentifierNotFoundError blocks; any other Kratos fault blocks too, so the control still fails closed. Budget the carve-out per IP. For a blocked destination the response now differs by whether the number holds an account, and probing is free because nothing is sent — the economic brake that bounds every other enumeration attempt on this endpoint does not apply. A dedicated requestCodeBlockedCountryPerIp bucket (2/h, 24h block) is consumed before the lookup, so a sweep runs out after two tries and gets the same PhoneCountryNotAllowedError as any other blocked number. A confirmed account refunds its point, so a real customer abroad is never spent out of their own login code by asking twice. Coalesce the telemetry. notifyOpsEvent feeds one 50-slot FIFO shared with cashout/deposit/upgrade/transfer that drops its oldest entries: an embed per rejection would evict the rest of the ops feed during exactly the incident this telemetry is for. The first rejection of each (phase, channel, country) still pages immediately, everything after is counted and flushed as one summary per 5-minute window. The per-request log line is unchanged. Report the carve-out. The block list is meant to be tuned by watching this feed for real traffic, and the carve-out is precisely the real users — reporting it locally only guaranteed the feed could say nothing but "no real users here". Served users now emit destination-blocked-existing-user, and a burnt-out probe budget emits destination-blocked-probe-limit, so a sweep is visible rather than silent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…hour, unbreak spell check Round-3 review residuals on #496, plus the two spell-check failures. BLOCKING — the coalescing fix was bypassable by an unvalidated request field. `normalizedChannel` was `String(channel).toLowerCase()`, i.e. attacker-controlled: POST /auth/phone/code passes req.body.channel through verbatim. That string is baked into the coalescing key (`${phase}|${channel}|${countryCode}`), so every distinct value was a fresh "kind" — missed pagedBlockedKinds, paged immediately, never reached a bucket, and left a permanent Set entry in a long-lived process. Verified: 20 requests with channel sms0..sms19 produced 20 notifyOpsEvent calls, not 1 — the exact eviction of the 50-slot feed shared with cashout/deposit/upgrade/transfer that the round-2 fix existed to prevent. Collapsed to the ChannelType enum, which is the fallback isAuthChannelSupportedForCountry already applies, so no gating decision changes; only the key space is bounded to two values. The new test fails with 20 calls against the old line and passes with 1 — I checked both ways. blockDuration 24h -> 1h on requestCodeBlockedCountryPerIp. It is keyed on x-real-ip, and a large share of our users reach us from behind carrier-grade NAT, where one egress address covers many subscribers. Two sweep probes from that address cost every real customer behind it a full day of their own login codes. The bound that limits a sweep is 2 probes/IP/hour and is unchanged; this only sets how fast a shared-IP false positive heals. The probe-budget refund now logs its failure. limiter.reward RETURNS its error rather than throwing, so swallowing it made "a real customer abroad is never spent out of their own login code" true only while Redis is healthy — and the resulting lockout had nothing in the logs pointing here. Spell check: "unparseable" -> "unparsable" in a test name. And "BA" is pinned in typos.toml, NOT corrected — it is Bosnia and Herzegovina's ISO code in the blocked-country list, flagged as a typo of "BY"/"BE" while BY is already in that same list. Taking the suggestion would silently change which country the gate blocks and duplicate an entry. 66 auth tests pass, tsc + eslint + typos clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
…e paged kinds Review fixes on the OTP country gate. - libphonenumber cannot name a region for every number it parses: 340 of the 800 assigned NANP area codes are missing from the pinned 1.12.25 metadata, including in-service US overlays (+1 738, 924, 983, 472). The gate turned that `undefined` into a hard InvalidPhoneNumber, so signup AND login were dead for real US customers on those codes — in a market on no block list at all. Parse once, reject only a number that does not parse, and otherwise fall back to every region the calling code could denote: +1 passes because no NANP region is blocked, +7 still fails closed because RU is. - Split the picker filter from the fraud control. Seeding smsAuthUnsupportedCountries / whatsAppAuthUnsupportedCountries with the 25 attack-origin codes removed them from `globals.supportedCountries`, so no app user could select +998 at all and the server-side existing-user carve-out was unreachable from the client — "existing accounts are never locked out" was false for every app user. Enforcement now reads new smsAuthBlockedCountries / whatsAppAuthBlockedCountries keys; the picker lists go back to empty defaults. - Pin the two numbers this change exists to set: the requestCodePerIp schema default (8) and getRequestCodeBlockedCountryPerIpLimits (2 / 1h / 1h). Both were assertable nowhere, and the destination spec mocked the block duration to the 86400 that 75cfb19 removed. - Report an unparsable number under its own `destination-unparsable` phase, so client input noise stops inflating the counter the block list is tuned from and stops burning the blocked-country one-shot page. - Widen requestPhoneCodeForAuthedUser to `true | ApplicationError`. The body already returned RateLimiterExceededError, PhoneAlreadyExistsError and NotImplementedError, none of which were in the declared union. - Expire paged kinds. `pagedBlockedKinds` was a Set only tests ever cleared, so a kind paged once never paged again for the pod's lifetime; it is now a key -> last-seen map pruned on flush after six windows, which keeps a sustained flood to one page but makes a wave after a quiet period news again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
Merge-condition evidence1. Per-country account count against prod — done. Kratos identities on prod, grouped by dialing prefix (no individual numbers left the cluster), cross-referenced against session activity:
Top counts: TR 8, CM 7, RU 4, SN 4, IL 4, TZ 4, UA 3. So the gate's real-world lockout exposure is two accounts, and both are exactly what the existing-user carve-out was built for — now that the picker split ( Caveat worth recording: 2. |
…over the flush
Review fixes for the OTP country gate.
- Enforce the invariant the +1 path rests on. The unattributable-region
fallback gates a number against every region its calling code could denote,
so one NANP entry on the block list breaks signup for real US customers on
the ~340 assigned area codes the pinned metadata does not carry. Pinned in
schema.spec.ts, stated in blocked-countries.ts, and re-checked at startup
against the MERGED configmap by reportAmbiguousBlockedCountries — error level
for a NANP region, warn for other collateral (today: KZ behind RU on +7,
which is now pinned so the next one has to be argued for).
- Raise the blocked-country probe budget from 2 to 5 points/IP/h. At 2, a real
UZ account holder who mistypes twice — or the second person behind an office
NAT or CGNAT egress — is denied their own login code for an hour with no
attacker involved. A sweep is equally dead at 5/IP/h, and the attacker in the
2026-08-25 incident drove ~100 rotating IPs, so the per-IP ceiling was never
the binding constraint.
- Cover the window timer. Every test drained the buckets by hand, so deleting
the scheduleBlockedReportFlush() call left the suite green while the summary
embed never fired in production. Fake-timer tests now assert the summary
lands on its own, once per window.
- Flush pending counts on SIGTERM/SIGINT. Rolling deploys and OOM kills were
discarding up to five minutes of blocked-destination counts, and a pod under
attack load is the likeliest to be cycled. The hook is installed on first
coalesce and hands the signal back after a bounded wait, so it never changes
how a process dies.
- Give each blocked-country config key its own default array instance. Ajv's
useDefaults assigns by reference, so the two keys and the schema object were
one live array in any environment setting neither.
- POST /auth/phone/code sent `{"error":{}}` — an Error's name and message are
non-enumerable — hiding every message the error map produces. Send
mapError(result).message, as /phone/login already does.
- Move SMS_PUMPING_HIGH_RISK_COUNTRIES to its own file and scope the typos
suppression to that path, instead of disabling the BA -> BY/BE suggestion
repo-wide.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
Review fixes.
The SIGTERM flush hook re-armed itself after firing. onShutdownSignal
unhooks and then waits up to SHUTDOWN_FLUSH_TIMEOUT_MS before re-raising,
and Apollo's drain keeps existing keep-alive connections served while it
stops — so blocked requests keep arriving in exactly that window, and any
coalesce landing there ran scheduleBlockedReportFlush() ->
hookShutdownFlush() and put the listener straight back. The re-raised
signal was caught again, flushed again, waited another 2s and re-raised:
~15 loops inside a k8s 30s grace period, then SIGKILL with in-flight
requests dropped and the counts lost anyway. Make it a one-way latch via
`shuttingDown`, reset in resetBlockedDestinationReporting so tests stay
isolated, and add a test that emits SIGTERM, drives more blocked traffic
and asserts the listener count stays 0 and the signal is handed back once.
The "skips a code libphonenumber cannot resolve" test never entered the
branch it named: getCountryCallingCode("XK") returns "383" in the pinned
libphonenumber-js 1.12.25, so XK was silent because it is the sole region
on +383, not because the catch fired. Drive that test with ZZ, which does
throw, keep XK as its own case under an accurate name, and correct the
three comments that asserted XK was unknown to the metadata.
Also correct the opsEventsSettled docstring: it is the drain barrier for
graceful shutdown as well as for tests, and must keep returning the
in-flight `draining` promise.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
Review residual: the invariant comment cited `assertNoAmbiguousBlockedCountries`, which does not exist. The real function is `reportAmbiguousBlockedCountries`, and it LOGS rather than throws. An operator adding DO to the configmap would read "assert", believe a bad entry is caught hard at startup, and never look for the error line that is the control's only actual backstop — while a Ready pod quietly rejects every US number on an unattributed NANP overlay. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
Pre-existing failure on main, unrelated to this PR but blocking it: the ObjectId in src/scripts/setup-rewards-account.ts ends in "ba", which typos reads as a misspelling of "by"/"be". Pinned by shape (24 hex chars) rather than by allowing the token "ba" globally -- "ba" really can be a typo in prose, and the id is data whose "correction" would point at a different document. Same reasoning as the BA country-code pin added in #496. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEoz7nBtdtsHyuYG5wNPQV
What happened
On 2026-08-25 our Twilio Verify service was hit with an SMS-pumping attack: 1,569 messages / $439.79 in one day, against a baseline of 1–10 OTPs per day (~$1/day; the entire previous month was $18.70). Traffic was concentrated in countries where Flash has no users — Uzbekistan, Turkey, Russia, Belarus, Armenia, Bosnia, Zambia, Ecuador and others — in sequential number blocks, converting at roughly 1%.
Why our existing defenses didn't stop it
Both were working as designed:
requestPhoneCodeWithCaptcha, which validates Geetest before anything else, and Geetest does not fail open.The attacker rotated IPs and solved captcha (cheap at scale), staying under every limit. 16/hour × ~100 IPs ≈ 1,600 — almost exactly the observed burst.
The real gap is ordering. The destination country is only checked by
PhoneMetadataAuthorizer, which runs at login completion (src/app/authentication/login.ts) and in rewards — after the message has been sent and billed. Every fraudulent OTP went to a country we don't serve and we paid for it anyway.Separately,
smsAuthUnsupportedCountries/whatsAppAuthUnsupportedCountriesalready existed but were only used to filter the client's country picker (getSupportedCountries). The API never checked them, so the app hid unsupported countries while the endpoint still sent to them.Changes
requestPhoneCodeWithCaptchaandrequestPhoneCodeForAuthedUser, immediately before the provider call. New pure helperisAuthChannelSupportedForCountryin@domain/authentication.InvalidPhoneNumber(not as a blocked country) instead of forwarding them to Twilio.smsAuthBlockedCountries/whatsAppAuthBlockedCountries, seeded with 25 attack-origin countries. Every one sent auth-code traffic with zero conversions across the full Twilio retention window. Countries with even one real signup (JM, US, NG, IN, GB, CA, DE, GH, KY, BJ, RW, SD, CD, MV, BD, BE, UG, TT, ML, CO, SK) are excluded by design — this is a fraud control, not a market policy. Plausible future markets with no conversions (FR, ZA, KE, PH, MY, EG, SA) were deliberately left unblocked.smsAuthUnsupportedCountries/whatsAppAuthUnsupportedCountrieskeep their empty defaults and keep governingglobals.supportedCountriesonly. A country hidden from the picker cannot be selected in the app at all, so its numbers never reach the endpoint and the existing-user carve-out below would be unreachable for exactly the users it exists to protect. Enforcement and presentation are separate keys on purpose.requestPhoneCodeWithCaptchaserves login as well as signup, so a blocked country falls through to a Kratos identity lookup (IdentityRepository().getUserIdFromIdentifier, the same storelogin.tsresolves against): a phone that already belongs to an account still gets its login code. The lookup costs no Twilio spend, fails closed on any error, and is bounded by its own tiny per-IP probe budget (2/h, refunded on a confirmed account) so the free existence answer cannot be swept. The country stays selectable in the picker, so the carve-out is actually reachable from the app.requestPhoneCodeForAuthedUserhas no such carve-out — binding a phone to an authed account is always a new registration of that number.baseLogger.warnplus averification / destination-blockedops event (phone masked), so blocked-country counts land in the existing Discord ops feed and the list can be tuned from data instead of guesswork.src/config/yaml.tscasts toCountryCode[]without validating, so- uzwould previously have disabled both the gate and the picker filter silently.POST /auth/phone/codeforwardsreq.body.channelverbatim ("SMS"/"WHATSAPP") while the two GraphQL resolvers lowercase it, so that route would have been gated against the wrong list the moment the two lists diverge.PhoneCountryNotAllowedErrornow has its own case insrc/graphql/error-map.ts→ "Phone number is not from a valid region" (ValidationInternalError), mirroringRestrictedRegionPhoneProviderError. It previously fell into the catch-all and surfaced a deliberate policy rejection as "Unexpected error occurred, please try again or contact support".InvalidPhoneNumberwas in that same catch-all → "Phone number is not a valid phone number", alongside its provider-side twin. The catch-all message interpolateserror.message, which for this error is the submitted number, so a malformed number was echoed back to the client inside an "unexpected error" string. Also fixes the pre-existingcheckedToPhoneNumberpath.requestCodePerIpschema default to 8/hour. A real user needs one or two.destination-unparsabletelemetry phase.Deploy notes
Companion PR: lnflash/deployments#192 — required.
tf-modules/flash/flash-values.tmpl.yamlhard-sets the wholerateLimitsobject for dev/test/prod, which suppresses the schema default, so therequestCodePerIpchange here is inert in every deployed environment until that PR merges.The country lists are not set anywhere in
lnflash/deployments(verified by grep), so the seeded schema defaults do apply on deploy. Re-verify before rollout if that changes — a configmap value always wins over the default. The enforcement lands either way.Merge checklist
accountsjoined to kratosidentities) and paste the counts here. "No Twilio conversion inside the retention window" is not the same claim as "no account exists" — a diaspora user who registered before the window is not visible in the Twilio data. The carve-out plus the picker split means a non-zero count is not a lockout, but any country with real accounts should still come off the list, since those users would otherwise be gated on every new phone binding.*AuthBlockedCountriesfor enforcement — setting*AuthUnsupportedCountriesinstead hides the country from the picker and takes the carve-out away with it.Testing
test/flash/unit/app/authentication/request-code-destination.spec.ts: covers both entry points — provider never called for a blocked country, per-channel lists are independent, channel casing normalized, lowercase config entries still match, unparsable numbers rejected asInvalidPhoneNumberunder their own telemetry phase, NANP overlays libphonenumber cannot attribute still delivered while an unattributable +7 still fails closed, paged kinds expiring after a quiet period without re-paging mid-flood, rejection happens even when captcha passes, blocked requests emit the log + ops event, allowed requests emit neither, existing users in a blocked country still get a code, repository failure fails closed, andrequestPhoneCodeForAuthedUserhas no carve-out and fires nootp-sentevent when blocked.test/flash/unit/config/schema.spec.ts: asserts the shipped defaults against the realconfigSchemawith no@configmock — both block lists are 25 uppercase ISO codes containing UZ and TR and none of the 21 converting countries, both picker lists are empty, andrequestCodePerIpis 8/h.test/flash/unit/config/rate-limits.spec.ts: pinsgetRequestCodeBlockedCountryPerIpLimits()to 5 points / 1h / 1h block — the shared-CGNAT reason the block duration is not the 24h the other auth limiters use.test/flash/unit/app/authentication/supported-countries.spec.ts: runs against the real config and asserts UZ/TR/RU are still offered in the picker, i.e. the carve-out is reachable from the app.test/flash/unit/domain/authentication/index.spec.ts: allow, per-channel block, both-channel block, empty lists, and lowercase config entries for bothisAuthChannelSupportedForCountryandgetSupportedCountries.test/flash/unit/graphql/error-map.spec.ts: pinsPhoneCountryNotAllowedErrorandInvalidPhoneNumberto validation messages, not the catch-all, and asserts the submitted number is not echoed back.yarn tsc-check,yarn tsc-check-noimplicitany,yarn eslint-check,yarn build,yarn madge-check,yarn check-yamlandtyposall clean.Follow-ups (not in this PR)
destination-blockedops feed for a week and prune any country that shows real-looking traffic.🤖 Generated with Claude Code
https://claude.ai/code/session_018sCFQXDf4s6f1zsrpmaShL