diff --git a/src/app/authentication/request-code.ts b/src/app/authentication/request-code.ts index 9a6d3d1b5..2f5c967fc 100644 --- a/src/app/authentication/request-code.ts +++ b/src/app/authentication/request-code.ts @@ -2,19 +2,32 @@ import { TWILIO_ACCOUNT_SID, UNSECURE_DEFAULT_LOGIN_CODE, getGeetestConfig, + getSmsAuthBlockedCountries, getTestAccounts, + getWhatsAppAuthBlockedCountries, } from "@config" import { TestAccountsChecker } from "@domain/accounts/test-accounts-checker" -import { PhoneAlreadyExistsError } from "@domain/authentication/errors" -import { NotImplementedError } from "@domain/errors" +import { isAuthChannelSupportedForCountry } from "@domain/authentication" +import { + IdentifierNotFoundError, + PhoneAlreadyExistsError, +} from "@domain/authentication/errors" +import { PhoneCountryNotAllowedError } from "@domain/users/errors" +import { InvalidPhoneNumber, NotImplementedError } from "@domain/errors" +import { ChannelType } from "@domain/phone-provider" import { RateLimitConfig } from "@domain/rate-limit" import { RateLimiterExceededError } from "@domain/rate-limit/errors" -import { notifyOpsEvent } from "@services/alerts/ops-events" +import { notifyOpsEvent, opsEventsSettled } from "@services/alerts/ops-events" import Geetest from "@services/geetest" -import { AuthWithEmailPasswordlessService } from "@services/kratos" +import { AuthWithEmailPasswordlessService, IdentityRepository } from "@services/kratos" import { baseLogger } from "@services/logger" -import { consumeLimiter } from "@services/rate-limit" +import { RedisRateLimitService, consumeLimiter } from "@services/rate-limit" import { TWILIO_ACCOUNT_TEST, TwilioClient } from "@services/twilio" +import { + getCountries, + getCountryCallingCode, + parsePhoneNumberFromString, +} from "libphonenumber-js" export const requestPhoneCodeWithCaptcha = async ({ phone, @@ -64,6 +77,16 @@ export const requestPhoneCodeWithCaptcha = async ({ return true } + // Login and signup share this entry point, so an existing account keeps its + // ability to receive a login code even if its country is on the block list. + const destinationOk = await checkAuthCodeDestination({ + phone, + channel, + ip, + allowExistingUser: true, + }) + if (destinationOk instanceof Error) return destinationOk + return TwilioClient().initiateVerify({ to: phone, channel }) } @@ -77,7 +100,11 @@ export const requestPhoneCodeForAuthedUser = async ({ ip: IpAddress channel: ChannelType user: User -}): Promise => { + // Rate limiting, the existence check and the country gate each reject with + // their own error type, so the union is the whole ApplicationError tree — + // same as requestPhoneCodeWithCaptcha. Narrowing it to the provider/country + // errors would lie to every caller that switches on the result. +}): Promise => { { const limitOk = await checkRequestCodeAttemptPerIpLimits(ip) if (limitOk instanceof Error) return limitOk @@ -105,6 +132,11 @@ export const requestPhoneCodeForAuthedUser = async ({ return true } + // Binding a phone to an already-authenticated account is always a new + // registration of that number, so there is no existing-user carve-out here. + const destinationOk = await checkAuthCodeDestination({ phone, channel }) + if (destinationOk instanceof Error) return destinationOk + const verifyResp = await TwilioClient().initiateVerify({ to: phone, channel }) if (!(verifyResp instanceof Error)) { notifyOpsEvent({ @@ -144,6 +176,441 @@ export const requestEmailCode = async ({ return flow } +// Blocked destinations are the whole point of the control, so they have to be +// observable: without this you cannot answer "is the gate firing", "what did it +// save", or "is it hitting real users in TR" without a redeploy. +// +// The trigger is attacker-controlled and free, though, and notifyOpsEvent feeds +// a single 50-slot FIFO shared with cashout/deposit/upgrade/transfer that drops +// its OLDEST entries on overflow. One embed per rejection would evict the rest +// of the ops feed during exactly the incident this telemetry exists to +// illuminate. So the first rejection of each kind pages immediately — a new +// attack origin is still news the moment it appears — and everything after it +// is counted and flushed as one summary per window. + +/** Exported so tests can advance exactly one window instead of pinning 300000. */ +export const BLOCKED_REPORT_WINDOW_MS = 5 * 60 * 1000 +const BLOCKED_REPORT_WINDOW_LABEL = `${BLOCKED_REPORT_WINDOW_MS / 60_000}m` + +type BlockedPhase = + | "destination-blocked" + | "destination-unparsable" + | "destination-blocked-existing-user" + | "destination-blocked-probe-limit" + +const BLOCKED_LOG_MESSAGE: Record = { + "destination-blocked": "auth code destination blocked", + // Ordinary client input noise, not a policy rejection. It gets its own phase + // so it neither pollutes the counter the block list is tuned from nor + // competes with a real attack origin for the one-shot page. + "destination-unparsable": "auth code destination could not be parsed", + "destination-blocked-existing-user": + "auth code destination in a blocked country allowed for an existing user", + "destination-blocked-probe-limit": + "auth code existing-user probe budget exhausted for a blocked country", +} + +type BlockedReport = { + phone: PhoneNumber + channel: ChannelType + countryCode?: string + phase?: BlockedPhase + status?: "pending" | "failed" + error?: string +} + +type BlockedBucket = { + phase: BlockedPhase + status: "pending" | "failed" + channel: ChannelType + countryCode?: string + error?: string + count: number +} + +// How long a kind has to go unseen before it is news again. A kind is refreshed +// on every rejection, so a sustained flood never re-pages; a wave that arrives +// after the origin has been quiet for this long does — which is the property +// the "a new attack origin is still news the moment it appears" rule above +// claims, and which a page-once-per-process-lifetime Set does not have. +const PAGED_KIND_QUIET_MS = 6 * BLOCKED_REPORT_WINDOW_MS + +const pendingBlockedReports: Map = new Map() +const pagedBlockedKinds: Map = new Map() +let blockedReportTimer: NodeJS.Timeout | undefined + +/** + * Emits one summary event per (phase, channel, country) seen since the last + * flush, then expires the kinds that have gone quiet. Exported so the interval + * is not the only way to drain it (tests). + */ +export const flushBlockedDestinationReports = (): void => { + for (const bucket of pendingBlockedReports.values()) { + notifyOpsEvent({ + flow: "verification", + phase: bucket.phase, + status: bucket.status, + error: bucket.error, + meta: { + channel: String(bucket.channel), + country: bucket.countryCode ?? "unknown", + count: String(bucket.count), + window: BLOCKED_REPORT_WINDOW_LABEL, + }, + }) + } + pendingBlockedReports.clear() + + // Unbounded growth is not the only cost of keeping every kind forever: a kind + // that is never dropped can never page again. + const quietBefore = Date.now() - PAGED_KIND_QUIET_MS + for (const [key, lastSeenAt] of pagedBlockedKinds) { + if (lastSeenAt <= quietBefore) pagedBlockedKinds.delete(key) + } +} + +/** Clears all coalescing state. Intended for tests. */ +export const resetBlockedDestinationReporting = (): void => { + pendingBlockedReports.clear() + pagedBlockedKinds.clear() + if (blockedReportTimer !== undefined) { + clearInterval(blockedReportTimer) + blockedReportTimer = undefined + } + unhookShutdownFlush() + shuttingDown = false +} + +// Counts that only exist in this map are lost on every rolling deploy, pod +// eviction and OOM kill — and a pod under attack load is the likeliest one to +// be cycled, so the data would be lossiest exactly when the block list is being +// tuned from it. Drain on the way out. +// +// Registering a signal listener suppresses Node's default terminate-on-SIGTERM, +// so the handler MUST hand the signal back: it removes itself, gives the +// fire-and-forget ops queue a bounded moment to land the summaries, and +// re-raises. A telemetry flush is never allowed to be the reason a pod misses +// its termination grace period. +// +// `shuttingDown` makes the hook a one-way latch, and that is load-bearing rather +// than tidiness. Apollo's drain keeps existing keep-alive connections served +// while it stops, so blocked requests keep arriving after SIGTERM — exactly the +// flood the flush exists for. Without the latch, any coalesce landing in the +// re-raise window calls scheduleBlockedReportFlush() → hookShutdownFlush() and +// reinstalls the listener; the re-raised signal is then caught again, waits +// another SHUTDOWN_FLUSH_TIMEOUT_MS, and re-raises again. Under a k8s 30s grace +// period that is ~15 loops and then SIGKILL: in-flight requests dropped and the +// counts lost anyway. +const SHUTDOWN_SIGNALS: NodeJS.Signals[] = ["SIGTERM", "SIGINT"] +const SHUTDOWN_FLUSH_TIMEOUT_MS = 2_000 + +let shuttingDown = false + +const onShutdownSignal = (signal: NodeJS.Signals): void => { + shuttingDown = true + unhookShutdownFlush() + flushBlockedDestinationReports() + + Promise.race([ + opsEventsSettled(), + new Promise((resolve) => setTimeout(resolve, SHUTDOWN_FLUSH_TIMEOUT_MS).unref?.()), + ]) + // Swallowed rather than `.finally`d: a rejection there would surface as an + // unhandled rejection at the exact moment the process is trying to die. + .catch(() => undefined) + .then(() => process.kill(process.pid, signal)) +} + +let shutdownFlushHooked = false + +const hookShutdownFlush = (): void => { + if (shuttingDown || shutdownFlushHooked) return + shutdownFlushHooked = true + for (const signal of SHUTDOWN_SIGNALS) process.on(signal, onShutdownSignal) +} + +function unhookShutdownFlush(): void { + if (!shutdownFlushHooked) return + shutdownFlushHooked = false + for (const signal of SHUTDOWN_SIGNALS) process.removeListener(signal, onShutdownSignal) +} + +const scheduleBlockedReportFlush = (): void => { + // Hooked here rather than at import: a process that never coalesces a + // rejection never installs a signal listener, and so never changes how it + // dies. + hookShutdownFlush() + + if (blockedReportTimer !== undefined) return + blockedReportTimer = setInterval( + flushBlockedDestinationReports, + BLOCKED_REPORT_WINDOW_MS, + ) + // A telemetry timer must never be the reason the process stays alive. + blockedReportTimer.unref?.() +} + +const reportBlockedDestination = ({ + phone, + channel, + countryCode, + phase = "destination-blocked", + status = "failed", + error, +}: BlockedReport): void => { + const logPayload = { countryCode, channel } + if (status === "failed") { + baseLogger.warn(logPayload, BLOCKED_LOG_MESSAGE[phase]) + } else { + baseLogger.info(logPayload, BLOCKED_LOG_MESSAGE[phase]) + } + + const key = `${phase}|${channel}|${countryCode ?? "unknown"}` + + // Seen-recently is what suppresses the page, so every rejection refreshes the + // stamp: a flood stays one page, a wave after PAGED_KIND_QUIET_MS of silence + // is news again. + const now = Date.now() + const lastSeenAt = pagedBlockedKinds.get(key) + const kindIsNews = lastSeenAt === undefined || now - lastSeenAt > PAGED_KIND_QUIET_MS + pagedBlockedKinds.set(key, now) + + if (kindIsNews) { + notifyOpsEvent({ + flow: "verification", + phase, + status, + phone, + error, + meta: { channel: String(channel), country: countryCode ?? "unknown" }, + }) + return + } + + const bucket = pendingBlockedReports.get(key) + if (bucket) { + bucket.count += 1 + return + } + + // Summaries aggregate many numbers, so they carry no phone. + pendingBlockedReports.set(key, { + phase, + status, + channel, + countryCode, + error, + count: 1, + }) + scheduleBlockedReportFlush() +} + +// Whether a number can actually log in is decided by Kratos, not Mongo: +// login.ts resolves the identity with getUserIdFromIdentifier and, when it +// resolves, skips onboarding entirely. The two stores are filled by a two-phase +// write with no reconciliation — the identity exists before the /registration +// webhook runs, and that webhook can fail — so asking Mongo would refuse a +// login code to accounts that log in fine today, which is the exact lockout +// this carve-out exists to prevent. +const phoneBelongsToExistingUser = async (phone: PhoneNumber): Promise => { + const userId = await IdentityRepository().getUserIdFromIdentifier(phone) + if (userId instanceof IdentifierNotFoundError) return false + if (userId instanceof Error) { + // A Kratos fault is not evidence of absence, but the fraud control fails + // closed, never open. + baseLogger.warn( + { error: userId.name }, + "kratos identity lookup failed for the auth code destination gate", + ) + return false + } + return true +} + +const rewardRequestCodeBlockedCountryPerIp = async (ip: IpAddress): Promise => { + const limiter = RedisRateLimitService({ + keyPrefix: RateLimitConfig.requestCodeBlockedCountryPerIp.key, + limitOptions: RateLimitConfig.requestCodeBlockedCountryPerIp.limits, + }) + const rewarded = await limiter.reward(ip) + // The refund is what keeps the carve-out honest: without it, a real customer + // abroad spends their own budget every time they ask. `reward` RETURNS its + // error rather than throwing, so swallowing it means a Redis fault silently + // turns "never spent out of their own login code" into a claim that is only + // true while Redis is healthy — and the resulting lockout has nothing in the + // logs tying it back here. + if (rewarded instanceof Error) { + baseLogger.warn( + { error: rewarded.name }, + "blocked-country probe budget refund failed", + ) + } +} + +type CarveOutResult = "allowed" | "no-such-user" | "probe-budget-exhausted" + +// The carve-out answers "does this number hold a Flash account" without sending +// anything, so probing it is free — the economic brake that bounds every other +// enumeration attempt on this endpoint does not exist here. A tiny per-IP budget +// is consumed BEFORE the lookup so a sweep runs out after a couple of tries; a +// confirmed account refunds its point, so a real customer abroad is never spent +// out of their own login code by asking twice. +const allowBlockedCountryForExistingUser = async ({ + phone, + ip, + channel, + countryCode, +}: { + phone: PhoneNumber + ip: IpAddress + channel: ChannelType + countryCode: string +}): Promise => { + const budgetOk = await consumeLimiter({ + rateLimitConfig: RateLimitConfig.requestCodeBlockedCountryPerIp, + keyToConsume: ip, + }) + // Exhausted budget and limiter faults alike fall through to the block: the + // control fails closed, never open. + if (budgetOk instanceof Error) return "probe-budget-exhausted" + + if (!(await phoneBelongsToExistingUser(phone))) return "no-such-user" + + await rewardRequestCodeBlockedCountryPerIp(ip) + // Real users served by the carve-out are the signal the block list is tuned + // on. Reporting them locally only would guarantee the feed reads "no real + // users here" no matter how many there are, and no country would ever be + // pruned on its evidence. + reportBlockedDestination({ + phone, + channel, + countryCode, + phase: "destination-blocked-existing-user", + status: "pending", + }) + return "allowed" +} + +// libphonenumber can parse a number without being able to name its region: +// 340 of the 800 assigned NANP area codes are absent from the pinned metadata, +// including in-service US overlays such as +1 738, +1 924, +1 983 and +1 472. +// Treating "no region" as a rejection would kill signup AND login for real +// customers on those codes — in a market that is deliberately on no block list +// at all. So fall back to every region the calling code could denote and gate +// on those: +1 passes because no NANP region is blocked, +7 still fails closed +// because RU is. +const regionsByCallingCode: Map = new Map() + +const countriesForCallingCode = (callingCode: string): CountryCode[] => { + const cached = regionsByCallingCode.get(callingCode) + if (cached !== undefined) return cached + + const regions = getCountries().filter( + (country) => getCountryCallingCode(country) === callingCode, + ) as CountryCode[] + regionsByCallingCode.set(callingCode, regions) + return regions +} + +// Rejects auth-code destinations before any Twilio spend. Countries are billed +// per message whether or not a human is behind the request, so an unsupported +// destination must never reach the provider. +const checkAuthCodeDestination = async ({ + phone, + channel, + ip, + allowExistingUser = false, +}: { + phone: PhoneNumber + channel: ChannelType + // Only needed for the existing-user carve-out, which is budgeted per IP. + ip?: IpAddress + allowExistingUser?: boolean +}): Promise => { + // Callers hand us the raw channel string in at least one path + // (POST /auth/phone/code passes `req.body.channel` through unvalidated), and + // the supported-country lookup branches on the exact value. Normalize once, + // here, so every caller is gated against the list it actually asked for. + // + // Collapsed to the ENUM, not merely lowercased. A lowercase cast leaves the + // value attacker-controlled, and it is baked into the coalescing key below + // (`${phase}|${channel}|${countryCode}`) — so every distinct string is a + // fresh "kind" that misses `pagedBlockedKinds`, pages the ops feed + // immediately, and adds another entry to it in a long-lived process. + // `isAuthChannelSupportedForCountry` already treats anything that is not + // whatsapp as SMS, so collapsing here changes no gating decision — it only + // bounds the key space to two values. + const normalizedChannel: ChannelType = + String(channel).toLowerCase() === ChannelType.Whatsapp + ? ChannelType.Whatsapp + : ChannelType.Sms + + const parsed = parsePhoneNumberFromString(phone) + if (!parsed) { + reportBlockedDestination({ + phone, + channel: normalizedChannel, + phase: "destination-unparsable", + error: InvalidPhoneNumber.name, + }) + // The country is unknown, not disallowed — say so, or the log line and the + // client-facing error both misattribute a malformed number to the gate. + return new InvalidPhoneNumber(phone) + } + + // Only an unattributable region falls back to the calling code; a named + // region is gated on itself. + const candidateCountries: CountryCode[] = + parsed.country !== undefined + ? [parsed.country as CountryCode] + : countriesForCallingCode(parsed.countryCallingCode) + + const blockedSmsCountries = getSmsAuthBlockedCountries() + const blockedWhatsAppCountries = getWhatsAppAuthBlockedCountries() + + // Blocked if ANY region the number could belong to is blocked. An unassigned + // calling code yields no candidates and is left to the provider to refuse, + // exactly as it was before this gate existed — the gate is a country + // blocklist, and there is no country here to block. + const supported = candidateCountries.every((countryCode) => + isAuthChannelSupportedForCountry({ + countryCode, + channel: normalizedChannel, + blockedSmsCountries, + blockedWhatsAppCountries, + }), + ) + if (supported) return true + + // Telemetry and the coalescing key need one label per destination. An + // unattributable region reports its calling code (`+7`), which is bounded and + // still tells ops which origin to tune. + const countryCode = parsed.country ?? `+${parsed.countryCallingCode}` + + let phase: BlockedPhase = "destination-blocked" + if (allowExistingUser && ip !== undefined) { + const carveOut = await allowBlockedCountryForExistingUser({ + phone, + ip, + channel: normalizedChannel, + countryCode, + }) + if (carveOut === "allowed") return true + // The caller still gets PhoneCountryNotAllowedError either way — only the + // feed learns that this rejection was a burnt-out probe budget. + if (carveOut === "probe-budget-exhausted") phase = "destination-blocked-probe-limit" + } + + reportBlockedDestination({ + phone, + channel: normalizedChannel, + countryCode, + phase, + error: PhoneCountryNotAllowedError.name, + }) + return new PhoneCountryNotAllowedError() +} + const checkRequestCodeAttemptPerIpLimits = async ( ip: IpAddress, ): Promise => diff --git a/src/config/blocked-countries.ts b/src/config/blocked-countries.ts new file mode 100644 index 000000000..4b1600f54 --- /dev/null +++ b/src/config/blocked-countries.ts @@ -0,0 +1,53 @@ +// Destinations whose auth-code traffic we refuse to pay for. +// +// Every country here sent us auth-code traffic with zero conversions over the +// full Twilio retention window, and each was a source of the 2026-08-25 +// SMS-pumping attack. 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 absent by +// design: this is a fraud control, not a market policy. +// +// INVARIANT — no entry may share a calling code with a region that is NOT +// blocked. `checkAuthCodeDestination` cannot always name the region of a number +// it parses (~340 assigned NANP area codes are missing from the pinned +// libphonenumber-js metadata), so it falls back to gating such a number against +// EVERY region its calling code could denote, and blocks if any of them is +// blocked. Adding DO (+1 809/829/849) or any other NANP region to this list +// would therefore reject ordinary US numbers on those overlays — silently, from +// a routine configmap edit. `reportAmbiguousBlockedCountries` in +// src/config/yaml.ts re-checks the merged configmap at startup and LOGS AT +// ERROR LEVEL for a NANP entry — it does not throw, so a bad configmap entry +// ships and a Ready pod quietly rejects those numbers; the log line is the only +// backstop. Only the defaults below are pinned hard, by +// test/flash/unit/config/schema.spec.ts. +// +// This list lives in its own file so typos.toml can exclude the ISO 3166-1 +// alpha-2 codes ("BA" is read as a misspelling of "BY"/"BE", and BY is itself +// an entry here) without either opening a repo-global spell-check hole or +// dropping the live config file src/config/schema.ts from spell checking. +export const SMS_PUMPING_HIGH_RISK_COUNTRIES = [ + "TR", + "UZ", + "RU", + "IL", + "AM", + "UA", + "TZ", + "EC", + "BY", + "ZM", + "MR", + "BA", + "TN", + "CI", + "BI", + "TG", + "VE", + "XK", + "GN", + "SL", + "SN", + "CM", + "MZ", + "CF", + "LB", +] diff --git a/src/config/schema.ts b/src/config/schema.ts index c6513dae5..2df6a84f9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -1,6 +1,8 @@ import { AccountRoles, AccountStatus } from "@domain/accounts/primitives" import { WalletCurrency } from "@domain/shared" +import { SMS_PUMPING_HIGH_RISK_COUNTRIES } from "./blocked-countries" + const displayCurrencyConfigSchema = { type: "object", properties: { @@ -330,7 +332,7 @@ export const configSchema = { blockDuration: 10800, }, requestCodePerIp: { - points: 16, + points: 8, duration: 3600, blockDuration: 86400, }, @@ -658,6 +660,13 @@ export const configSchema = { chanId: [], }, }, + // Countries hidden from the client's country picker (`globals + // .supportedCountries`). This is a MARKET/PRESENTATION list, and it is + // deliberately NOT the fraud control: a country hidden here cannot be + // selected in the app at all, so a number in it never reaches the server + // and the existing-user carve-out in requestPhoneCode* is unreachable for + // it. Seeding this with the block list below would therefore lock every + // existing account in those countries out of its own login code. smsAuthUnsupportedCountries: { type: "array", items: { type: "string" }, @@ -668,6 +677,32 @@ export const configSchema = { items: { type: "string" }, default: [], }, + // Destinations that produced auth-code traffic but never a single + // conversion, and are the origin of the 2026-08-25 SMS-pumping attack. + // This is the FRAUD CONTROL: enforced server-side in requestPhoneCode* + // before any Twilio spend, while the picker still offers the country so an + // existing account there can ask for a login code and be served by the + // carve-out. Drop a country from this list when Flash opens that market. + // + // Each key gets its OWN copy of the seed. Ajv's `useDefaults` assigns + // defaults by reference, so sharing one array instance would make + // `yamlConfig.smsAuthBlockedCountries`, + // `yamlConfig.whatsAppAuthBlockedCountries` and this schema object the same + // live array in every environment whose configmap sets neither key — and + // these two keys exist precisely so they can diverge. + // + // See src/config/blocked-countries.ts for the no-shared-calling-code + // invariant the gate depends on. + smsAuthBlockedCountries: { + type: "array", + items: { type: "string" }, + default: [...SMS_PUMPING_HIGH_RISK_COUNTRIES], + }, + whatsAppAuthBlockedCountries: { + type: "array", + items: { type: "string" }, + default: [...SMS_PUMPING_HIGH_RISK_COUNTRIES], + }, ibex: { type: "object", properties: { diff --git a/src/config/schema.types.d.ts b/src/config/schema.types.d.ts index e9e134dad..223cf4b7c 100644 --- a/src/config/schema.types.d.ts +++ b/src/config/schema.types.d.ts @@ -238,6 +238,8 @@ type YamlSchema = { skipFeeProbeConfig: { pubkey: string[]; chanId: string[] } smsAuthUnsupportedCountries: string[] whatsAppAuthUnsupportedCountries: string[] + smsAuthBlockedCountries: string[] + whatsAppAuthBlockedCountries: string[] ibex: IbexConfig bridge: BridgeConfig exchangeRates: StaticRates diff --git a/src/config/yaml.ts b/src/config/yaml.ts index d8eb1efa4..5fb234fe9 100644 --- a/src/config/yaml.ts +++ b/src/config/yaml.ts @@ -5,6 +5,7 @@ import path from "path" import Ajv from "ajv" import { load as loadYaml } from "js-yaml" import { I18n } from "i18n" +import { getCountries, getCountryCallingCode } from "libphonenumber-js" import { baseLogger } from "@services/logger" import { checkedToScanDepth } from "@domain/bitcoin/onchain" @@ -203,6 +204,40 @@ export const getRequestCodePerLoginIdentifierLimits = () => export const getRequestCodePerIpLimits = () => getRateLimits(yamlConfig.rateLimits.requestCodePerIp) +/** + * Auth-code requests for a country whose destinations we refuse to pay for, + * per IP. + * + * The country gate rejects these before any provider spend, which is the point + * — but it also means probing costs the attacker nothing, and the existing-user + * carve-out makes the response differ by whether the number holds an account. + * That is an account-existence oracle, and the per-IP request-code budget (8/h) + * is far too generous to bound it. Tighter than that budget: a confirmed + * account refunds its point, so only sweeps over numbers that do NOT exist burn + * it. + * + * Not tighter still. This bucket is keyed on `req.originalIp`, so it is spent + * by mistyped numbers and shared by everyone behind one office NAT or CGNAT + * egress. At 2 points a real UZ account holder who fat-fingers their number + * twice is denied their own login code for an hour, and so is the second person + * behind a shared address — no attacker involved. The bound 2 bought over 5 is + * negligible anyway: a sweep is equally dead at 5/IP/h, and the 2026-08-25 + * attacker drove ~100 rotating IPs, so the per-IP ceiling was never the binding + * constraint on enumeration. + */ +export const getRequestCodeBlockedCountryPerIpLimits = () => ({ + points: 5, + duration: toSeconds(3600), // 1 hour + // One hour, NOT the 24 used by the other auth limiters. This one is keyed on + // `req.originalIp` (the `x-real-ip` header), and a large share of Flash's + // users reach us from behind carrier-grade NAT — one mobile egress address + // covers many subscribers. A 24h block means two sweep probes from that + // address cost every real customer behind it a full day of their own login + // codes. The bound that actually limits a sweep is `points` probes/IP/hour; + // the shorter block only decides how fast a shared-IP false positive heals. + blockDuration: toSeconds(3600), // 1 hour +}) + export const getFailedLoginAttemptPerLoginIdentifierLimits = () => getRateLimits(yamlConfig.rateLimits.failedLoginAttemptPerLoginIdentifier) @@ -408,6 +443,9 @@ export const getSwapConfig = (): SwapConfig => { } } +// Countries hidden from the client's country picker. Presentation only — a +// country listed here can never be selected in the app, so nothing in it ever +// reaches the auth-code endpoint. export const getSmsAuthUnsupportedCountries = (): CountryCode[] => { return yamlConfig.smsAuthUnsupportedCountries as CountryCode[] } @@ -416,6 +454,102 @@ export const getWhatsAppAuthUnsupportedCountries = (): CountryCode[] => { return yamlConfig.whatsAppAuthUnsupportedCountries as CountryCode[] } +// Countries whose auth-code destinations are refused server-side before any +// provider spend. Deliberately separate from the picker lists above: the +// existing-user carve-out only works if the country can still be selected. +export const getSmsAuthBlockedCountries = (): CountryCode[] => { + return yamlConfig.smsAuthBlockedCountries as CountryCode[] +} + +export const getWhatsAppAuthBlockedCountries = (): CountryCode[] => { + return yamlConfig.whatsAppAuthBlockedCountries as CountryCode[] +} + +const NANP_CALLING_CODE = "1" + +/** + * Reports blocked countries that share a calling code with a region that is NOT + * blocked. + * + * `checkAuthCodeDestination` cannot always name the region of a number it + * parses — ~340 assigned NANP area codes are absent from the pinned + * libphonenumber-js metadata — so it falls back to gating such a number against + * EVERY region its calling code could denote, and fails closed if any of them + * is blocked. Every entry on the list therefore blocks its unattributable + * siblings too. + * + * `+1` is a different order of severity from the rest, so it gets its own + * level. Blocking any NANP region (DO's 809/829/849, say) rejects ordinary US + * numbers on +1 983 / +1 738 / +1 924 / +1 472 — a core market, broken silently + * by a one-line configmap edit. Any other shared calling code costs a market we + * did not choose to block (today: KZ, behind RU on +7), which is worth a + * warning but is a deliberate trade. + * + * Checked here, against the MERGED config, because the list is operator-tunable + * from the ops feed: a configmap can break this without touching the schema + * default that test/flash/unit/config/schema.spec.ts pins. Logged, not thrown — + * a bad entry must be loud, but must not wedge every pod in a crash loop at 3am. + */ +export const reportAmbiguousBlockedCountries = ( + key: string, + blocked: readonly string[], +): void => { + // libphonenumber's own region type, not the branded domain `CountryCode`. + type Region = ReturnType[number] + + const normalized = new Set(blocked.map((code) => code.toUpperCase())) + + for (const code of normalized) { + let callingCode: string + try { + callingCode = getCountryCallingCode(code as Region) + } catch { + // Not a region libphonenumber knows. The list is operator-editable via + // the configmap, so a typo or a non-region code (ZZ, say) must be skipped + // rather than thrown: such a code can never be a parsed number's region, + // so it cannot widen a candidate set either. Note XK does NOT land here — + // libphonenumber resolves it to +383. + continue + } + + const unblockedSiblings = getCountries().filter( + (country) => + country !== code && + getCountryCallingCode(country) === callingCode && + !normalized.has(country), + ) + if (unblockedSiblings.length === 0) continue + + const payload = { key, blockedCountry: code, callingCode, unblockedSiblings } + + if (callingCode === NANP_CALLING_CODE) { + baseLogger.error( + payload, + `${key} blocks the NANP region ${code}: every +1 number whose region ` + + `libphonenumber cannot identify (~340 assigned US area codes) will now ` + + `be refused an auth code. Remove it.`, + ) + continue + } + + baseLogger.warn( + payload, + `${key} blocks ${code} (+${callingCode}), which shares that calling code ` + + `with ${unblockedSiblings.join(", ")}: numbers on it whose region cannot ` + + `be identified are refused for those regions too.`, + ) + } +} + +reportAmbiguousBlockedCountries( + "smsAuthBlockedCountries", + yamlConfig.smsAuthBlockedCountries as string[], +) +reportAmbiguousBlockedCountries( + "whatsAppAuthBlockedCountries", + yamlConfig.whatsAppAuthBlockedCountries as string[], +) + const { ask } = yamlConfig.exchangeRates["USD"]["JMD"] const sellRate = JMDAmount.dollars(ask) if (sellRate instanceof BigIntConversionError) throw sellRate diff --git a/src/domain/authentication/index.ts b/src/domain/authentication/index.ts index 3442d6c43..1c4527220 100644 --- a/src/domain/authentication/index.ts +++ b/src/domain/authentication/index.ts @@ -2,6 +2,14 @@ import { ChannelType, PhoneCodeInvalidError } from "@domain/phone-provider" import { EmailCodeInvalidError } from "./errors" +// The unsupported-country lists come straight from the configmap +// (src/config/yaml.ts casts them to CountryCode[] without validating), so an +// operator can write `uz` where `UZ` was meant. Comparing raw would make the +// fraud control silently do nothing and stop filtering the picker at the same +// time — both failures invisible. Normalize on every comparison instead. +const normalizeCountryCodes = (countries: CountryCode[]): string[] => + countries.map((country) => String(country).toUpperCase()) + export const getSupportedCountries = ({ allCountries, unsupportedSmsCountries, @@ -12,15 +20,18 @@ export const getSupportedCountries = ({ unsupportedWhatsAppCountries: CountryCode[] }): Country[] => { const countries: Country[] = [] + const unsupportedSms = normalizeCountryCodes(unsupportedSmsCountries) + const unsupportedWhatsApp = normalizeCountryCodes(unsupportedWhatsAppCountries) for (const country of allCountries) { const supportedAuthMethods: ChannelType[] = [] + const normalizedCountry = String(country).toUpperCase() - if (!unsupportedSmsCountries.includes(country)) { + if (!unsupportedSms.includes(normalizedCountry)) { supportedAuthMethods.push(ChannelType.Sms) } - if (!unsupportedWhatsAppCountries.includes(country)) { + if (!unsupportedWhatsApp.includes(normalizedCountry)) { supportedAuthMethods.push(ChannelType.Whatsapp) } @@ -35,6 +46,29 @@ export const getSupportedCountries = ({ return countries } +// The server-side fraud control, gated on the BLOCKED lists — not on the +// picker's unsupported lists. The two are separate config keys on purpose: a +// country hidden from the picker can never be selected, so the existing-user +// carve-out in requestPhoneCode* would be unreachable for it. +export const isAuthChannelSupportedForCountry = ({ + countryCode, + channel, + blockedSmsCountries, + blockedWhatsAppCountries, +}: { + countryCode: CountryCode + channel: ChannelType + blockedSmsCountries: CountryCode[] + blockedWhatsAppCountries: CountryCode[] +}): boolean => { + const blockedCountries = + channel === ChannelType.Whatsapp ? blockedWhatsAppCountries : blockedSmsCountries + + return !normalizeCountryCodes(blockedCountries).includes( + String(countryCode).toUpperCase(), + ) +} + export const checkedToEmailCode = (code: string): EmailCode | ApplicationError => { if (!/^[0-9]{6}$/.test(code)) return new EmailCodeInvalidError() return code as EmailCode diff --git a/src/domain/rate-limit/errors.ts b/src/domain/rate-limit/errors.ts index 0d7c3039c..8397d0896 100644 --- a/src/domain/rate-limit/errors.ts +++ b/src/domain/rate-limit/errors.ts @@ -10,6 +10,7 @@ export class UnknownRateLimitServiceError extends RateLimitServiceError { export class RateLimiterExceededError extends RateLimitServiceError {} export class UserCodeAttemptIdentifierRateLimiterExceededError extends RateLimiterExceededError {} export class UserCodeAttemptIpRateLimiterExceededError extends RateLimiterExceededError {} +export class UserCodeAttemptBlockedCountryIpRateLimiterExceededError extends RateLimiterExceededError {} export class CreateDeviceAccountIpRateLimiterExceededError extends RateLimiterExceededError {} export class UserLoginIpRateLimiterExceededError extends RateLimiterExceededError {} export class UserLoginIdentifierRateLimiterExceededError extends RateLimiterExceededError {} diff --git a/src/domain/rate-limit/index.ts b/src/domain/rate-limit/index.ts index 2a3965af2..7ed633050 100644 --- a/src/domain/rate-limit/index.ts +++ b/src/domain/rate-limit/index.ts @@ -8,6 +8,7 @@ import { getInvoiceCreateAttemptLimits, getInvoiceCreateForRecipientAttemptLimits, getOnChainAddressCreateAttemptLimits, + getRequestCodeBlockedCountryPerIpLimits, getRequestCodePerIpLimits, getRequestCodePerLoginIdentifierLimits, } from "@config" @@ -24,11 +25,13 @@ import { UserLoginIdentifierRateLimiterExceededError, UserCodeAttemptIpRateLimiterExceededError, UserCodeAttemptIdentifierRateLimiterExceededError, + UserCodeAttemptBlockedCountryIpRateLimiterExceededError, } from "./errors" export const RateLimitPrefix = { requestCodeAttemptPerLoginIdentifier: "request_code_attempt_id", requestCodeAttemptPerIp: "request_code_attempt_ip", + requestCodeBlockedCountryPerIp: "request_code_blocked_country_ip", failedLoginAttemptPerLoginIdentifier: "login_attempt_id", failedLoginAttemptPerIp: "login_attempt_ip", invoiceCreate: "invoice_create", @@ -51,6 +54,15 @@ export const RateLimitConfig: { [key: string]: RateLimitConfig } = { limits: getRequestCodePerIpLimits(), error: UserCodeAttemptIpRateLimiterExceededError, }, + // Requests for a destination country we refuse to pay for. The existing-user + // carve-out answers "does this number hold a Flash account" for free — no + // provider spend, so none of the economics that bound every other + // enumeration attempt apply. This bucket is what bounds it. + requestCodeBlockedCountryPerIp: { + key: RateLimitPrefix.requestCodeBlockedCountryPerIp, + limits: getRequestCodeBlockedCountryPerIpLimits(), + error: UserCodeAttemptBlockedCountryIpRateLimiterExceededError, + }, failedLoginAttemptPerLoginIdentifier: { key: RateLimitPrefix.failedLoginAttemptPerLoginIdentifier, limits: getFailedLoginAttemptPerLoginIdentifierLimits(), diff --git a/src/graphql/error-map.ts b/src/graphql/error-map.ts index 8c291ef1e..ca283245d 100644 --- a/src/graphql/error-map.ts +++ b/src/graphql/error-map.ts @@ -218,7 +218,11 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message = "Invoice must be a zero-amount invoice" return new ValidationInternalError({ message, logger: baseLogger }) + // The domain-side twin of the provider error above. Left in the catch-all + // it rendered a malformed number as "unexpected error, contact support", + // and echoed the submitted number back inside that message. case "InvalidPhoneNumberPhoneProviderError": + case "InvalidPhoneNumber": message = "Phone number is not a valid phone number" return new ValidationInternalError({ message, logger: baseLogger }) @@ -226,6 +230,12 @@ export const mapError = (error: ApplicationError): CustomApolloError => { message = "Phone number is not from a valid region" return new ValidationInternalError({ message, logger: baseLogger }) + // A deliberate policy rejection, not a bug: it must not fall into the + // catch-all below, which would tell the user to retry and open a ticket. + case "PhoneCountryNotAllowedError": + message = "Phone number is not from a valid region" + return new ValidationInternalError({ message, logger: baseLogger }) + case "PhoneProviderConnectionError": case "PhoneProviderUnavailableError": message = "Phone provider temporarily unreachable" @@ -769,7 +779,6 @@ export const mapError = (error: ApplicationError): CustomApolloError => { case "InvalidDeviceId": case "InvalidIdentityPassword": case "InvalidIdentityUsername": - case "InvalidPhoneNumber": case "InvalidTotpCode": case "InvalidEmailAddress": case "NoContactForUsernameError": @@ -833,7 +842,6 @@ export const mapError = (error: ApplicationError): CustomApolloError => { case "PhoneProviderServiceError": case "ExpectedPhoneMetadataMissingError": case "PhoneCarrierTypeNotAllowedError": - case "PhoneCountryNotAllowedError": case "MissingIPMetadataError": case "UnauthorizedIPMetadataASNError": case "InvalidAccountStatusError": diff --git a/src/servers/authorization/index.ts b/src/servers/authorization/index.ts index 4d1426f35..4c3e656fc 100644 --- a/src/servers/authorization/index.ts +++ b/src/servers/authorization/index.ts @@ -480,7 +480,11 @@ authRouter.post("/phone/code", async (req: Request, res: Response) => { channel, }) - if (result instanceof Error) return res.status(400).json({ error: result }) + // `json({ error: result })` serializes an Error to `{}` — `name` and + // `message` are non-enumerable — so the client learned nothing about why the + // request failed. Send the mapped message, as /phone/login does below. + if (result instanceof Error) + return res.status(400).json({ error: mapError(result).message }) return res.json({ success: true, diff --git a/src/services/alerts/ops-events.ts b/src/services/alerts/ops-events.ts index f483c2310..c25dd7ef2 100644 --- a/src/services/alerts/ops-events.ts +++ b/src/services/alerts/ops-events.ts @@ -185,5 +185,13 @@ export const notifyOpsEvent = (event: OpsEvent): void => { } } -/** Resolves once the delivery queue is idle. Intended for tests. */ +/** + * Resolves once the delivery queue is idle. + * + * This is the drain barrier for graceful shutdown as well as for tests: the + * SIGTERM handler in src/app/authentication/request-code.ts races it against a + * bounded timeout so the coalesced blocked-destination summaries actually reach + * Discord before the pod exits. It must keep returning the in-flight `draining` + * promise — resolving eagerly would silently turn that flush into a no-op. + */ export const opsEventsSettled = (): Promise => draining ?? Promise.resolve() diff --git a/test/flash/unit/app/authentication/ops-events-hooks.spec.ts b/test/flash/unit/app/authentication/ops-events-hooks.spec.ts index b97736194..16bd6e9a8 100644 --- a/test/flash/unit/app/authentication/ops-events-hooks.spec.ts +++ b/test/flash/unit/app/authentication/ops-events-hooks.spec.ts @@ -16,6 +16,7 @@ const mockUpgradeAccountFromDeviceToPhone = jest.fn() jest.mock("@services/alerts/ops-events", () => ({ notifyOpsEvent: jest.fn().mockResolvedValue(undefined), + opsEventsSettled: jest.fn().mockResolvedValue(undefined), })) jest.mock("@config", () => { @@ -36,6 +37,11 @@ jest.mock("@config", () => { getOnChainAddressCreateAttemptLimits: jest.fn(() => limits), getRequestCodePerIpLimits: jest.fn(() => limits), getRequestCodePerLoginIdentifierLimits: jest.fn(() => limits), + getRequestCodeBlockedCountryPerIpLimits: jest.fn(() => limits), + getSmsAuthUnsupportedCountries: jest.fn(() => []), + getWhatsAppAuthUnsupportedCountries: jest.fn(() => []), + getSmsAuthBlockedCountries: jest.fn(() => []), + getWhatsAppAuthBlockedCountries: jest.fn(() => []), getAccountsOnboardConfig: jest.fn(() => ({ phoneMetadataValidationSettings: { enabled: false }, ipMetadataValidationSettings: { enabled: false }, diff --git a/test/flash/unit/app/authentication/request-code-destination.spec.ts b/test/flash/unit/app/authentication/request-code-destination.spec.ts new file mode 100644 index 000000000..b2325b592 --- /dev/null +++ b/test/flash/unit/app/authentication/request-code-destination.spec.ts @@ -0,0 +1,858 @@ +const mockInitiateVerify = jest.fn() +const mockGeetestValidate = jest.fn() +const mockSmsBlocked = jest.fn(() => [] as string[]) +const mockWhatsAppBlocked = jest.fn(() => [] as string[]) +const mockGetUserIdFromIdentifier = jest.fn() +const mockConsumeLimiter = jest.fn() +const mockRewardLimiter = jest.fn() + +jest.mock("@config", () => { + const limits = { points: 100, duration: 60, blockDuration: 60 } + return { + TWILIO_ACCOUNT_SID: "AC-live", + UNSECURE_DEFAULT_LOGIN_CODE: undefined, + getGeetestConfig: jest.fn(() => ({})), + getTestAccounts: jest.fn(() => []), + getFailedLoginAttemptPerIpLimits: jest.fn(() => limits), + getFailedLoginAttemptPerLoginIdentifierLimits: jest.fn(() => limits), + getInvoiceCreateAttemptLimits: jest.fn(() => limits), + getInvoiceCreateForRecipientAttemptLimits: jest.fn(() => limits), + getInviteCreateAttemptLimits: jest.fn(() => limits), + getInviteTargetAttemptLimits: jest.fn(() => limits), + getFygaroCheckoutCreateAttemptLimits: jest.fn(() => limits), + getFygaroTopupAllowanceAttemptLimits: jest.fn(() => limits), + getOnChainAddressCreateAttemptLimits: jest.fn(() => limits), + getRequestCodePerIpLimits: jest.fn(() => limits), + getRequestCodePerLoginIdentifierLimits: jest.fn(() => limits), + // Mirrors the real getter exactly; test/flash/unit/config/rate-limits.spec.ts + // is what pins those values. + getRequestCodeBlockedCountryPerIpLimits: jest.fn(() => ({ + points: 5, + duration: 3600, + blockDuration: 3600, + })), + getSmsAuthBlockedCountries: () => mockSmsBlocked(), + getWhatsAppAuthBlockedCountries: () => mockWhatsAppBlocked(), + } +}) + +jest.mock("@services/geetest", () => ({ + __esModule: true, + default: jest.fn(() => ({ + validate: (...args: unknown[]) => mockGeetestValidate(...args), + })), +})) + +jest.mock("@services/rate-limit", () => ({ + consumeLimiter: (...args: unknown[]) => mockConsumeLimiter(...args), + RedisRateLimitService: jest.fn(() => ({ + consume: jest.fn(async () => true), + reset: jest.fn(async () => true), + reward: (...args: unknown[]) => mockRewardLimiter(...args), + })), +})) + +jest.mock("@services/twilio", () => ({ + TWILIO_ACCOUNT_TEST: "AC-test", + TwilioClient: jest.fn(() => ({ + initiateVerify: (...args: unknown[]) => mockInitiateVerify(...args), + })), +})) + +jest.mock("@services/alerts/ops-events", () => ({ + notifyOpsEvent: jest.fn().mockResolvedValue(undefined), + opsEventsSettled: jest.fn().mockResolvedValue(undefined), +})) + +jest.mock("@services/kratos", () => ({ + AuthWithEmailPasswordlessService: jest.fn(() => ({ + sendEmailWithCode: jest.fn(), + })), + IdentityRepository: jest.fn(() => ({ + getUserIdFromIdentifier: (...args: unknown[]) => mockGetUserIdFromIdentifier(...args), + })), +})) + +jest.mock("@services/logger", () => ({ + baseLogger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) + +import { + BLOCKED_REPORT_WINDOW_MS, + flushBlockedDestinationReports, + requestPhoneCodeForAuthedUser, + requestPhoneCodeWithCaptcha, + resetBlockedDestinationReporting, +} from "@app/authentication/request-code" +import { IdentifierNotFoundError } from "@domain/authentication/errors" +import { PhoneCountryNotAllowedError } from "@domain/users/errors" +import { InvalidPhoneNumber } from "@domain/errors" +import { RateLimitPrefix } from "@domain/rate-limit" +import { UserCodeAttemptBlockedCountryIpRateLimiterExceededError } from "@domain/rate-limit/errors" +import { notifyOpsEvent } from "@services/alerts/ops-events" +import { baseLogger } from "@services/logger" + +const captcha = { + geetestChallenge: "challenge", + geetestValidate: "validate", + geetestSeccode: "seccode", +} + +const requestCode = (phone: string, channel: string) => + requestPhoneCodeWithCaptcha({ + phone: phone as PhoneNumber, + ...captcha, + ip: "1.2.3.4" as IpAddress, + channel: channel as ChannelType, + }) + +const JAMAICA = "+18761234567" +const UZBEKISTAN = "+998901234567" +const TURKEY = "+905321234567" +// In-service US overlay (+1 983). 340 of the 800 assigned NANP area codes are +// absent from the pinned libphonenumber-js metadata, so `.country` is undefined +// for it even though the number parses fine. +const US_UNATTRIBUTED_OVERLAY = "+19835551234" +// +7 is shared by RU and KZ, and this one attributes to neither. +const PLUS_SEVEN_UNATTRIBUTED = "+70001234567" + +class UnknownKratosError extends Error {} + +// The blocked-country probe budget is a distinct bucket from the per-IP +// request-code budget; only the former is exhausted here. +const exhaustProbeBudget = () => + mockConsumeLimiter.mockImplementation( + async ({ rateLimitConfig }: { rateLimitConfig: { key: string } }) => + rateLimitConfig.key === RateLimitPrefix.requestCodeBlockedCountryPerIp + ? new UserCodeAttemptBlockedCountryIpRateLimiterExceededError() + : true, + ) + +const resetMocks = () => { + jest.clearAllMocks() + resetBlockedDestinationReporting() + mockInitiateVerify.mockResolvedValue(true) + mockSmsBlocked.mockReturnValue([]) + mockWhatsAppBlocked.mockReturnValue([]) + mockConsumeLimiter.mockImplementation(async () => true) + mockRewardLimiter.mockResolvedValue(true) + mockGetUserIdFromIdentifier.mockResolvedValue(new IdentifierNotFoundError()) +} + +describe("requestPhoneCodeWithCaptcha — destination country gate", () => { + beforeEach(() => { + resetMocks() + mockGeetestValidate.mockResolvedValue(true) + }) + + afterAll(resetBlockedDestinationReporting) + + it("sends to a supported country", async () => { + const result = await requestCode(JAMAICA, "sms") + + expect(result).toBe(true) + expect(mockInitiateVerify).toHaveBeenCalledWith({ to: JAMAICA, channel: "sms" }) + }) + + it("never reaches the provider for an unsupported country", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + const result = await requestCode(UZBEKISTAN, "sms") + + expect(result).toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) + + it("gates each channel against its own list", async () => { + mockSmsBlocked.mockReturnValue([]) + mockWhatsAppBlocked.mockReturnValue(["UZ"]) + + const viaWhatsApp = await requestCode(UZBEKISTAN, "whatsapp") + expect(viaWhatsApp).toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockInitiateVerify).not.toHaveBeenCalled() + + const viaSms = await requestCode(UZBEKISTAN, "sms") + expect(viaSms).toBe(true) + expect(mockInitiateVerify).toHaveBeenCalledTimes(1) + }) + + // libphonenumber cannot name a region for every number it parses. Rejecting + // on that would have killed signup AND login for real US customers on the + // ~340 NANP area codes the pinned metadata does not carry — for a market that + // is on no block list at all. + describe("numbers whose region libphonenumber cannot name", () => { + it("sends to a NANP overlay the metadata cannot attribute", async () => { + mockSmsBlocked.mockReturnValue(["UZ", "RU"]) + + const result = await requestCode(US_UNATTRIBUTED_OVERLAY, "sms") + + expect(result).toBe(true) + expect(mockInitiateVerify).toHaveBeenCalledWith({ + to: US_UNATTRIBUTED_OVERLAY, + channel: "sms", + }) + }) + + // +7 could be RU or KZ. RU is blocked, so the gate must still fail closed. + it("blocks when any region the calling code could denote is blocked", async () => { + mockSmsBlocked.mockReturnValue(["RU"]) + + const result = await requestCode(PLUS_SEVEN_UNATTRIBUTED, "sms") + + expect(result).toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) + + it("sends when none of those regions is blocked", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + const result = await requestCode(PLUS_SEVEN_UNATTRIBUTED, "sms") + + expect(result).toBe(true) + expect(mockInitiateVerify).toHaveBeenCalled() + }) + + it("reports the calling code, not `unknown`, so ops can tune on it", async () => { + mockSmsBlocked.mockReturnValue(["RU"]) + + await requestCode(PLUS_SEVEN_UNATTRIBUTED, "sms") + + expect(notifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "destination-blocked", + meta: expect.objectContaining({ country: "+7" }), + }), + ) + }) + }) + + it("never reaches the provider for an unparsable number", async () => { + const result = await requestCode("+000", "sms") + + expect(result).toBeInstanceOf(InvalidPhoneNumber) + expect(result).not.toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) + + it("rejects before the provider even when the captcha passes", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + await requestCode(UZBEKISTAN, "sms") + + expect(mockGeetestValidate).toHaveBeenCalled() + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) + + // POST /auth/phone/code forwards req.body.channel verbatim ("SMS"/"WHATSAPP"), + // unlike the GraphQL resolvers. Without normalization a WhatsApp request on + // that route would be gated against the SMS list. + it("normalizes the channel casing before picking a list", async () => { + mockSmsBlocked.mockReturnValue([]) + mockWhatsAppBlocked.mockReturnValue(["UZ"]) + + const result = await requestCode(UZBEKISTAN, "WHATSAPP") + + expect(result).toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) + + it("matches a lowercase configmap entry", async () => { + mockSmsBlocked.mockReturnValue(["uz"]) + + const result = await requestCode(UZBEKISTAN, "sms") + + expect(result).toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) + + // A fraud control aimed at unregistered traffic must not permanently lock an + // existing account out of its own login code. + it("still sends to an existing user in a blocked country", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + mockGetUserIdFromIdentifier.mockResolvedValue("user-id") + + const result = await requestCode(UZBEKISTAN, "sms") + + expect(result).toBe(true) + expect(mockInitiateVerify).toHaveBeenCalledWith({ + to: UZBEKISTAN, + channel: "sms", + }) + }) + + // Whether a number can log in is decided by Kratos, and the Mongo user doc is + // written afterwards by a webhook that can fail. Asking Mongo would refuse a + // login code to an account that logs in fine today. + it("still sends when Kratos knows the number and Mongo does not", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + // No Mongo user record exists at all — the identity is the only evidence. + mockGetUserIdFromIdentifier.mockResolvedValue("kratos-only-user-id") + + const result = await requestCode(UZBEKISTAN, "sms") + + expect(mockGetUserIdFromIdentifier).toHaveBeenCalledWith(UZBEKISTAN) + expect(result).toBe(true) + expect(mockInitiateVerify).toHaveBeenCalledWith({ + to: UZBEKISTAN, + channel: "sms", + }) + }) + + it("blocks a number Kratos has never seen", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + mockGetUserIdFromIdentifier.mockResolvedValue(new IdentifierNotFoundError()) + + const result = await requestCode(UZBEKISTAN, "sms") + + expect(result).toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) + + it("fails closed when the identity lookup errors", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + mockGetUserIdFromIdentifier.mockResolvedValue(new UnknownKratosError("kratos down")) + + const result = await requestCode(UZBEKISTAN, "sms") + + expect(result).toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) + + // The carve-out answers "does this number hold an account" for free, so it + // needs a budget of its own — the per-IP request-code budget is far too + // generous to bound an enumeration sweep that costs the attacker nothing. + describe("existence-probe budget", () => { + it("blocks an existing user's number once the probe budget is spent", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + mockGetUserIdFromIdentifier.mockResolvedValue("user-id") + exhaustProbeBudget() + + const result = await requestCode(UZBEKISTAN, "sms") + + // Same response as any other blocked number: no oracle. + expect(result).toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) + + it("spends the budget before the lookup, so a sweep cannot probe past it", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + exhaustProbeBudget() + + await requestCode(UZBEKISTAN, "sms") + + expect(mockConsumeLimiter).toHaveBeenCalledWith( + expect.objectContaining({ + rateLimitConfig: expect.objectContaining({ + key: RateLimitPrefix.requestCodeBlockedCountryPerIp, + }), + keyToConsume: "1.2.3.4", + }), + ) + expect(mockGetUserIdFromIdentifier).not.toHaveBeenCalled() + }) + + it("reports a burnt-out probe budget as its own phase", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + exhaustProbeBudget() + + await requestCode(UZBEKISTAN, "sms") + + expect(notifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ phase: "destination-blocked-probe-limit" }), + ) + }) + + it("refunds the point for a confirmed account, so a real user is never spent out", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + mockGetUserIdFromIdentifier.mockResolvedValue("user-id") + + await requestCode(UZBEKISTAN, "sms") + + expect(mockRewardLimiter).toHaveBeenCalledWith("1.2.3.4") + }) + + it("does not refund a number that holds no account", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + await requestCode(UZBEKISTAN, "sms") + + expect(mockRewardLimiter).not.toHaveBeenCalled() + }) + + it("never touches the probe budget for a supported country", async () => { + await requestCode(JAMAICA, "sms") + + expect(mockConsumeLimiter).not.toHaveBeenCalledWith( + expect.objectContaining({ + rateLimitConfig: expect.objectContaining({ + key: RateLimitPrefix.requestCodeBlockedCountryPerIp, + }), + }), + ) + }) + }) + + describe("telemetry", () => { + it("logs and reports a blocked country", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + await requestCode(UZBEKISTAN, "sms") + + expect(baseLogger.warn).toHaveBeenCalledWith( + { countryCode: "UZ", channel: "sms" }, + "auth code destination blocked", + ) + expect(notifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + flow: "verification", + phase: "destination-blocked", + status: "failed", + meta: expect.objectContaining({ country: "UZ", channel: "sms" }), + }), + ) + }) + + // Client input noise is not a policy rejection. Sharing the + // `destination-blocked` phase would inflate the very counter the block list + // is tuned from, and burn that phase's one-shot page on a typo. + it("reports an unparsable number under its own phase", async () => { + await requestCode("+000", "sms") + + expect(notifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "destination-unparsable", + error: "InvalidPhoneNumber", + meta: expect.objectContaining({ country: "unknown" }), + }), + ) + expect(notifyOpsEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ phase: "destination-blocked" }), + ) + }) + + it("does not spend the blocked-country page on client input noise", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + await requestCode("+000", "sms") + ;(notifyOpsEvent as jest.Mock).mockClear() + + await requestCode(UZBEKISTAN, "sms") + + expect(notifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "destination-blocked", + meta: expect.objectContaining({ country: "UZ" }), + }), + ) + }) + + it("stays silent when the destination is allowed", async () => { + await requestCode(JAMAICA, "sms") + + expect(notifyOpsEvent).not.toHaveBeenCalled() + expect(baseLogger.warn).not.toHaveBeenCalled() + }) + + // The block list is meant to be tuned by watching this feed for real + // traffic. If the carve-out — which is exactly the real users — reported + // nothing, the feed could only ever say "no real users here". + it("reports a carve-out so served real users are visible in the feed", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + mockGetUserIdFromIdentifier.mockResolvedValue("user-id") + + await requestCode(UZBEKISTAN, "sms") + + expect(notifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + flow: "verification", + phase: "destination-blocked-existing-user", + status: "pending", + phone: UZBEKISTAN, + meta: expect.objectContaining({ country: "UZ", channel: "sms" }), + }), + ) + }) + + // notifyOpsEvent feeds one 50-slot FIFO shared with cashout/deposit/etc. + // that drops its OLDEST entries: an embed per rejection would evict the + // rest of the ops feed during the very incident this telemetry is for. + describe("coalescing", () => { + it("pages immediately on the first rejection of a country", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + await requestCode(UZBEKISTAN, "sms") + + expect(notifyOpsEvent).toHaveBeenCalledTimes(1) + }) + + it("emits nothing more for the rest of the window", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + for (let i = 0; i < 20; i++) await requestCode(UZBEKISTAN, "sms") + + expect(baseLogger.warn).toHaveBeenCalledTimes(20) + expect(notifyOpsEvent).toHaveBeenCalledTimes(1) + }) + + it("does not let an attacker-chosen channel string reopen the pager", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + // POST /auth/phone/code passes req.body.channel through unvalidated, and + // the channel is part of the coalescing key. Before the channel was + // collapsed to the enum, each distinct string was a fresh "kind": it + // missed the paged set, paged immediately, and left a permanent entry + // behind — 20 requests produced 20 pages into the 50-slot ops feed + // shared with cashout, deposit, upgrade and transfer. + for (let i = 0; i < 20; i++) await requestCode(UZBEKISTAN, `sms${i}`) + + expect(notifyOpsEvent).toHaveBeenCalledTimes(1) + }) + + it("flushes the rest as one counted summary", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + for (let i = 0; i < 20; i++) await requestCode(UZBEKISTAN, "sms") + flushBlockedDestinationReports() + + expect(notifyOpsEvent).toHaveBeenCalledTimes(2) + expect(notifyOpsEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: "destination-blocked", + meta: expect.objectContaining({ country: "UZ", count: "19" }), + }), + ) + }) + + // Every other test here drains the buckets by calling + // flushBlockedDestinationReports() by hand. Nothing exercised the + // interval that drains it in production, so deleting the + // scheduleBlockedReportFlush() call left the suite green while the + // summary embed never fired — ops would page once per country during a + // flood and never learn the volume. + describe("the window timer", () => { + beforeEach(() => jest.useFakeTimers()) + + afterEach(() => { + resetBlockedDestinationReporting() + jest.useRealTimers() + }) + + it("emits the summary on its own, with no manual flush", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + for (let i = 0; i < 3; i++) await requestCode(UZBEKISTAN, "sms") + expect(notifyOpsEvent).toHaveBeenCalledTimes(1) + + jest.advanceTimersByTime(BLOCKED_REPORT_WINDOW_MS) + + expect(notifyOpsEvent).toHaveBeenCalledTimes(2) + expect(notifyOpsEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: "destination-blocked", + meta: expect.objectContaining({ country: "UZ", count: "2" }), + }), + ) + }) + + it("keeps emitting one summary per window", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + for (let i = 0; i < 3; i++) await requestCode(UZBEKISTAN, "sms") + jest.advanceTimersByTime(BLOCKED_REPORT_WINDOW_MS) + ;(notifyOpsEvent as jest.Mock).mockClear() + + for (let i = 0; i < 5; i++) await requestCode(UZBEKISTAN, "sms") + jest.advanceTimersByTime(BLOCKED_REPORT_WINDOW_MS) + + expect(notifyOpsEvent).toHaveBeenCalledTimes(1) + expect(notifyOpsEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + meta: expect.objectContaining({ country: "UZ", count: "5" }), + }), + ) + }) + + it("stays quiet while there is nothing pending", async () => { + await requestCode(JAMAICA, "sms") + + jest.advanceTimersByTime(BLOCKED_REPORT_WINDOW_MS * 4) + + expect(notifyOpsEvent).not.toHaveBeenCalled() + }) + }) + + // Counts that live only in this map are lost on every rolling deploy, pod + // eviction and OOM kill — and a pod under attack load is the likeliest + // one to be cycled, so the block list would be tuned from data that is + // lossiest exactly when it matters. + describe("shutdown", () => { + it("drains the pending summaries on SIGTERM", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + const killSpy = jest.spyOn(process, "kill").mockImplementation(() => true) + + try { + for (let i = 0; i < 3; i++) await requestCode(UZBEKISTAN, "sms") + ;(notifyOpsEvent as jest.Mock).mockClear() + + process.emit("SIGTERM", "SIGTERM") + + expect(notifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "destination-blocked", + meta: expect.objectContaining({ country: "UZ", count: "2" }), + }), + ) + + // A listener on SIGTERM suppresses Node's default terminate, so the + // handler must hand the signal back or the pod would never exit. + await new Promise((resolve) => setImmediate(resolve)) + expect(killSpy).toHaveBeenCalledWith(process.pid, "SIGTERM") + expect(process.listenerCount("SIGTERM")).toBe(0) + } finally { + killSpy.mockRestore() + } + }) + + // The hook is installed on first coalesce, not at import, so a process + // that never rejects a destination never changes how it dies. + it("installs no signal listener until something is pending", async () => { + const before = process.listenerCount("SIGTERM") + + await requestCode(JAMAICA, "sms") + + expect(process.listenerCount("SIGTERM")).toBe(before) + }) + + // Apollo's drain keeps existing keep-alive connections served while it + // stops, so blocked requests keep arriving after SIGTERM — a flood is + // the only time this flush is worth having. Re-arming the hook there + // would make the re-raised signal be caught again, wait the flush + // timeout again and re-raise again: ~15 loops inside a 30s k8s grace + // period, then SIGKILL with in-flight requests dropped and the counts + // lost anyway. The hook is a one-way latch. + it("does not re-arm when a block lands after the signal", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + const killSpy = jest.spyOn(process, "kill").mockImplementation(() => true) + + try { + for (let i = 0; i < 3; i++) await requestCode(UZBEKISTAN, "sms") + + process.emit("SIGTERM", "SIGTERM") + await new Promise((resolve) => setImmediate(resolve)) + expect(process.listenerCount("SIGTERM")).toBe(0) + + // The drain window: more blocked traffic, still coalescing. + for (let i = 0; i < 5; i++) await requestCode(UZBEKISTAN, "sms") + + expect(process.listenerCount("SIGTERM")).toBe(0) + + // And so the signal is handed back exactly once. + process.emit("SIGTERM", "SIGTERM") + await new Promise((resolve) => setImmediate(resolve)) + expect(killSpy).toHaveBeenCalledTimes(1) + } finally { + killSpy.mockRestore() + } + }) + }) + + it("pages a new attack origin immediately even mid-flood", async () => { + mockSmsBlocked.mockReturnValue(["UZ", "TR"]) + + for (let i = 0; i < 20; i++) await requestCode(UZBEKISTAN, "sms") + ;(notifyOpsEvent as jest.Mock).mockClear() + + await requestCode(TURKEY, "sms") + + expect(notifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "destination-blocked", + meta: expect.objectContaining({ country: "TR" }), + }), + ) + }) + + it("counts each country separately", async () => { + mockSmsBlocked.mockReturnValue(["UZ", "TR"]) + + for (let i = 0; i < 3; i++) await requestCode(UZBEKISTAN, "sms") + for (let i = 0; i < 5; i++) await requestCode(TURKEY, "sms") + ;(notifyOpsEvent as jest.Mock).mockClear() + flushBlockedDestinationReports() + + const counts = (notifyOpsEvent as jest.Mock).mock.calls.map( + ([event]) => `${event.meta.country}:${event.meta.count}`, + ) + expect(counts.sort()).toEqual(["TR:4", "UZ:2"]) + }) + + it("drains its pending summaries, so a flush emits nothing twice", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + for (let i = 0; i < 3; i++) await requestCode(UZBEKISTAN, "sms") + flushBlockedDestinationReports() + ;(notifyOpsEvent as jest.Mock).mockClear() + flushBlockedDestinationReports() + + expect(notifyOpsEvent).not.toHaveBeenCalled() + }) + + // "A new attack origin is still news the moment it appears" only holds if + // a kind can stop being current. Paged kinds used to live for the pod's + // lifetime, so the second wave from a country — next week, after a month + // of silence — arrived as a delayed 5-minute summary and nothing else. + describe("a kind stops being current once its origin goes quiet", () => { + const THIRTY_ONE_MINUTES_MS = 31 * 60 * 1000 + const FOUR_MINUTES_MS = 4 * 60 * 1000 + + let clock: number + let nowSpy: jest.SpyInstance + + beforeEach(() => { + clock = Date.now() + nowSpy = jest.spyOn(Date, "now").mockImplementation(() => clock) + }) + + afterEach(() => nowSpy.mockRestore()) + + const advance = (ms: number) => { + clock += ms + } + + it("pages again for a wave that arrives after a quiet period", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + for (let i = 0; i < 5; i++) await requestCode(UZBEKISTAN, "sms") + flushBlockedDestinationReports() + + advance(THIRTY_ONE_MINUTES_MS) + flushBlockedDestinationReports() + ;(notifyOpsEvent as jest.Mock).mockClear() + + await requestCode(UZBEKISTAN, "sms") + + expect(notifyOpsEvent).toHaveBeenCalledTimes(1) + expect(notifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ + phase: "destination-blocked", + phone: UZBEKISTAN, + meta: expect.objectContaining({ country: "UZ" }), + }), + ) + }) + + it("does not re-page during a sustained flood", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + // 40 minutes of continuous traffic — well past the quiet threshold, + // but never quiet. + for (let i = 0; i < 10; i++) { + await requestCode(UZBEKISTAN, "sms") + advance(FOUR_MINUTES_MS) + flushBlockedDestinationReports() + } + ;(notifyOpsEvent as jest.Mock).mockClear() + + await requestCode(UZBEKISTAN, "sms") + + expect(notifyOpsEvent).not.toHaveBeenCalled() + }) + }) + + it("coalesces the carve-out on the same terms", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + mockGetUserIdFromIdentifier.mockResolvedValue("user-id") + + for (let i = 0; i < 4; i++) await requestCode(UZBEKISTAN, "sms") + expect(notifyOpsEvent).toHaveBeenCalledTimes(1) + + flushBlockedDestinationReports() + + expect(notifyOpsEvent).toHaveBeenLastCalledWith( + expect.objectContaining({ + phase: "destination-blocked-existing-user", + status: "pending", + meta: expect.objectContaining({ country: "UZ", count: "3" }), + }), + ) + }) + }) + }) +}) + +describe("requestPhoneCodeForAuthedUser — destination country gate", () => { + const user = { id: "user-id" as UserId, phone: undefined } as unknown as User + + const requestForAuthedUser = (phone: string, channel: string) => + requestPhoneCodeForAuthedUser({ + phone: phone as PhoneNumber, + ip: "1.2.3.4" as IpAddress, + channel: channel as ChannelType, + user, + }) + + beforeEach(resetMocks) + + afterAll(resetBlockedDestinationReporting) + + it("sends to a supported country", async () => { + const result = await requestForAuthedUser(JAMAICA, "sms") + + expect(result).toBe(true) + expect(mockInitiateVerify).toHaveBeenCalledWith({ to: JAMAICA, channel: "sms" }) + }) + + it("never reaches the provider for an unsupported country", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + const result = await requestForAuthedUser(UZBEKISTAN, "sms") + + expect(result).toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) + + it("does not fire the otp-sent ops event for a blocked country", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + + await requestForAuthedUser(UZBEKISTAN, "sms") + + expect(notifyOpsEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ phase: "otp-sent" }), + ) + expect(notifyOpsEvent).toHaveBeenCalledWith( + expect.objectContaining({ phase: "destination-blocked" }), + ) + }) + + it("gates each channel against its own list", async () => { + mockWhatsAppBlocked.mockReturnValue(["UZ"]) + + const viaWhatsApp = await requestForAuthedUser(UZBEKISTAN, "whatsapp") + expect(viaWhatsApp).toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockInitiateVerify).not.toHaveBeenCalled() + + const viaSms = await requestForAuthedUser(UZBEKISTAN, "sms") + expect(viaSms).toBe(true) + expect(mockInitiateVerify).toHaveBeenCalledTimes(1) + }) + + it("rejects an unparsable number as invalid", async () => { + const result = await requestForAuthedUser("+000", "sms") + + expect(result).toBeInstanceOf(InvalidPhoneNumber) + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) + + // Binding a phone to an authed account registers that number, so an existing + // record for it must not open a hole in the gate. + it("has no existing-user carve-out", async () => { + mockSmsBlocked.mockReturnValue(["UZ"]) + mockGetUserIdFromIdentifier.mockResolvedValue("someone-else") + + const result = await requestForAuthedUser(UZBEKISTAN, "sms") + + expect(result).toBeInstanceOf(PhoneCountryNotAllowedError) + expect(mockGetUserIdFromIdentifier).not.toHaveBeenCalled() + expect(mockInitiateVerify).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/app/authentication/supported-countries.spec.ts b/test/flash/unit/app/authentication/supported-countries.spec.ts new file mode 100644 index 000000000..2dbd6d6dd --- /dev/null +++ b/test/flash/unit/app/authentication/supported-countries.spec.ts @@ -0,0 +1,27 @@ +import { getSupportedCountries } from "@app/authentication/get-supported-countries" + +// Runs against the REAL config on purpose: this is the only place the two +// country lists meet, and the failure it guards against is invisible in any +// spec that mocks @config. +describe("getSupportedCountries", () => { + const supported = getSupportedCountries() + const ids = supported.map((country) => String(country.id)) + + // The server-side gate carves out existing accounts in a blocked country so + // they keep receiving login codes. That carve-out only ever runs if the app + // lets the user pick the country in the first place: `globals + // .supportedCountries` IS the picker. Seeding the picker's unsupported lists + // with the block list would make every UZ/TR account unable to select its own + // dialling code, and the carve-out unreachable from the app. + it("still offers the blocked countries, so their existing accounts can reach the carve-out", () => { + expect(ids).toContain("UZ") + expect(ids).toContain("TR") + expect(ids).toContain("RU") + }) + + it("offers a supported market on both channels", () => { + const jamaica = supported.find((country) => String(country.id) === "JM") + + expect(jamaica?.supportedAuthChannels).toEqual(["sms", "whatsapp"]) + }) +}) diff --git a/test/flash/unit/config/blocked-countries-guard.spec.ts b/test/flash/unit/config/blocked-countries-guard.spec.ts new file mode 100644 index 000000000..9df47d4a3 --- /dev/null +++ b/test/flash/unit/config/blocked-countries-guard.spec.ts @@ -0,0 +1,105 @@ +jest.mock("@services/logger", () => ({ + baseLogger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + child: jest.fn(), + }, +})) + +import { reportAmbiguousBlockedCountries } from "@config" +import { baseLogger } from "@services/logger" + +// checkAuthCodeDestination gates a number whose region libphonenumber cannot +// name against EVERY region its calling code could denote. The block list is +// operator-tunable from the ops feed, so a configmap can widen that candidate +// set — and break signup for a market nobody meant to block — without touching +// the schema default that schema.spec.ts pins. +describe("reportAmbiguousBlockedCountries", () => { + beforeEach(jest.clearAllMocks) + + it("stays silent when no blocked country shares a calling code", () => { + reportAmbiguousBlockedCountries("smsAuthBlockedCountries", ["TR", "UZ", "LB"]) + + expect(baseLogger.error).not.toHaveBeenCalled() + expect(baseLogger.warn).not.toHaveBeenCalled() + }) + + // The failure this exists for: DO is +1 809/829/849, so blocking it rejects + // every US number on the ~340 assigned area codes the pinned metadata does + // not carry (+1 983, +1 738, +1 924, +1 472…). + it("logs at error level when a NANP region is blocked", () => { + reportAmbiguousBlockedCountries("smsAuthBlockedCountries", ["UZ", "DO"]) + + expect(baseLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + key: "smsAuthBlockedCountries", + blockedCountry: "DO", + callingCode: "1", + unblockedSiblings: expect.arrayContaining(["US"]), + }), + expect.stringContaining("DO"), + ) + }) + + it("names the key that carries the bad entry", () => { + reportAmbiguousBlockedCountries("whatsAppAuthBlockedCountries", ["CA"]) + + expect(baseLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ key: "whatsAppAuthBlockedCountries" }), + expect.stringContaining("whatsAppAuthBlockedCountries"), + ) + }) + + // Collateral on any other calling code costs a market we did not choose to + // block, which is a deliberate trade rather than a broken core market. + it("warns, not errors, for collateral outside the NANP", () => { + reportAmbiguousBlockedCountries("smsAuthBlockedCountries", ["RU"]) + + expect(baseLogger.error).not.toHaveBeenCalled() + expect(baseLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ blockedCountry: "RU", unblockedSiblings: ["KZ"] }), + expect.stringContaining("KZ"), + ) + }) + + it("says nothing once every region on the calling code is blocked", () => { + reportAmbiguousBlockedCountries("smsAuthBlockedCountries", ["RU", "KZ"]) + + expect(baseLogger.error).not.toHaveBeenCalled() + expect(baseLogger.warn).not.toHaveBeenCalled() + }) + + it("matches a lowercase configmap entry", () => { + reportAmbiguousBlockedCountries("smsAuthBlockedCountries", ["do"]) + + expect(baseLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ blockedCountry: "DO", callingCode: "1" }), + expect.any(String), + ) + }) + + // The seeded list is operator-editable via the configmap, so an entry that is + // not a region at all has to be survivable: getCountryCallingCode throws on + // it, and an unhandled throw here would wedge every pod in a crash loop. + // ZZ is the CLDR "unknown region" code and libphonenumber has no metadata for + // it, so it is what actually reaches the catch. + it("skips a code libphonenumber cannot resolve instead of throwing", () => { + expect(() => + reportAmbiguousBlockedCountries("smsAuthBlockedCountries", ["ZZ"]), + ).not.toThrow() + expect(baseLogger.error).not.toHaveBeenCalled() + expect(baseLogger.warn).not.toHaveBeenCalled() + }) + + // XK (Kosovo) is on the seeded list and, contrary to what one might assume, is + // a region libphonenumber knows: it resolves to +383 and is the sole region on + // that calling code. It is therefore silent by the no-unblocked-sibling check, + // not by the catch above — blocking it costs no collateral. + it("says nothing for XK, the sole region on +383", () => { + reportAmbiguousBlockedCountries("smsAuthBlockedCountries", ["XK"]) + + expect(baseLogger.error).not.toHaveBeenCalled() + expect(baseLogger.warn).not.toHaveBeenCalled() + }) +}) diff --git a/test/flash/unit/config/rate-limits.spec.ts b/test/flash/unit/config/rate-limits.spec.ts new file mode 100644 index 000000000..ebb81abc4 --- /dev/null +++ b/test/flash/unit/config/rate-limits.spec.ts @@ -0,0 +1,35 @@ +import { getRequestCodeBlockedCountryPerIpLimits } from "@config" + +describe("getRequestCodeBlockedCountryPerIpLimits", () => { + // Both numbers are load-bearing and neither comes from the yaml, so nothing + // else in the suite pins them: the blocked-country spec mocks this getter, + // and a revert of either value would otherwise leave the suite green. + // + // points: 5 — the bound on an account-existence sweep, which costs the + // attacker nothing because the gate rejects before any spend. + // Not lower: this bucket is keyed on the IP, so it is also + // spent by a real customer's mistyped digits and shared by + // everyone behind one office NAT or CGNAT egress. At 2 a UZ + // account holder who fat-fingers their number twice loses an + // hour of their own login codes with no attacker involved, + // and a sweep is equally dead at 5/IP/h. + // blockDuration: 1h, NOT the 24h the other auth limiters use — the key is the + // `x-real-ip` header and a large share of Flash's users share a + // carrier-grade NAT egress address, so a 24h block would cost + // every real customer behind a probed address a full day of + // their own login codes. + it("bounds the existence probe at 5/h and heals a shared-IP block in an hour", () => { + expect(getRequestCodeBlockedCountryPerIpLimits()).toEqual({ + points: 5, + duration: 3600, + blockDuration: 3600, + }) + }) + + // The carve-out exists so a real account in a blocked country is never locked + // out of its own login code. A budget at or below the number of typos a + // person makes would defeat it without an attacker in the picture. + it("leaves room for a real customer's mistyped digits and a shared egress IP", () => { + expect(getRequestCodeBlockedCountryPerIpLimits().points).toBeGreaterThanOrEqual(5) + }) +}) diff --git a/test/flash/unit/config/schema.spec.ts b/test/flash/unit/config/schema.spec.ts index 2da3f71ae..8853c8c9c 100644 --- a/test/flash/unit/config/schema.spec.ts +++ b/test/flash/unit/config/schema.spec.ts @@ -1,3 +1,5 @@ +import { getCountries, getCountryCallingCode } from "libphonenumber-js" + import { configSchema } from "../../../../src/config/schema" describe("config schema", () => { @@ -7,4 +9,145 @@ describe("config schema", () => { expect(bridgeSchema.properties.developerFeePercent).toEqual({ type: "number" }) expect(bridgeSchema.required).toContain("developerFeePercent") }) + + // The per-IP request-code budget is one of the two numbers this control is + // made of, and no other test in the suite asserts it — every other spec mocks + // @config, so it could be reverted to its old 16 and stay green. + it("caps request-code attempts per IP at 8 an hour", () => { + expect(configSchema.properties.rateLimits.default.requestCodePerIp).toEqual({ + points: 8, + duration: 3600, + blockDuration: 86400, + }) + }) + + // These defaults ARE the SMS-pumping control for any environment whose + // configmap omits the key. Every other test in the suite mocks @config, so + // without this the seeded lists could be reverted to [] and stay green. + describe("SMS-pumping blocklist defaults", () => { + const smsDefault = configSchema.properties.smsAuthBlockedCountries.default as string[] + const whatsAppDefault = configSchema.properties.whatsAppAuthBlockedCountries + .default as string[] + + // The block list is enforced server-side, where the existing-user carve-out + // can still serve a real account. The picker list hides a country from the + // client entirely — seeding it with the same codes would mean no UZ account + // could even select +998, and the carve-out would never run for anyone. + it("keeps the picker filter empty by default, so the carve-out stays reachable", () => { + expect(configSchema.properties.smsAuthUnsupportedCountries.default).toEqual([]) + expect(configSchema.properties.whatsAppAuthUnsupportedCountries.default).toEqual([]) + }) + + it("seeds the sms auth blocklist with the attack-origin countries", () => { + expect(smsDefault).toHaveLength(25) + expect(smsDefault).toEqual(expect.arrayContaining(["UZ", "TR"])) + }) + + it("seeds the whatsapp auth blocklist with the attack-origin countries", () => { + expect(whatsAppDefault).toHaveLength(25) + expect(whatsAppDefault).toEqual(expect.arrayContaining(["UZ", "TR"])) + }) + + it("keeps every entry an uppercase ISO-3166 alpha-2 code", () => { + for (const code of [...smsDefault, ...whatsAppDefault]) { + expect(code).toMatch(/^[A-Z]{2}$/) + } + }) + + // checkAuthCodeDestination cannot name a region for every number it parses + // — ~340 assigned NANP area codes are absent from the pinned + // libphonenumber-js metadata — so it gates such a number against EVERY + // region its calling code could denote, and fails closed if any is blocked. + // The safety of the +1 path therefore rests on this invariant. Block DO + // (+1 809/829/849) and every US number on +1 983 / +1 738 / +1 924 / + // +1 472 starts getting PhoneCountryNotAllowedError. + describe("no entry shares a calling code with an unblocked region", () => { + type Region = ReturnType[number] + + const callingCodeOf = (code: string): string | undefined => { + try { + return getCountryCallingCode(code as Region) + } catch { + // Not a region libphonenumber knows. It can never be a parsed + // number's region, so it cannot widen a candidate set either. (XK is + // not such a code — it resolves to +383 — but a future list entry + // could be, and this assertion must not turn into a throw.) + return undefined + } + } + + it("blocks no NANP region, so +1 numbers the metadata cannot attribute still send", () => { + for (const code of [...smsDefault, ...whatsAppDefault]) { + expect(callingCodeOf(code)).not.toBe("1") + } + }) + + // Blocking a country also blocks any unattributable number on its calling + // code, which is collateral against a market we have not decided to + // block. RU/KZ (+7) is the one accepted instance; pinning the whole set + // means the next one has to be argued for here rather than shipped by + // appending a line to a configmap. + it("has exactly one accepted collateral region, and it is KZ behind RU", () => { + for (const list of [smsDefault, whatsAppDefault]) { + const blocked = new Set(list) + const collateral: Record = {} + + for (const code of list) { + const callingCode = callingCodeOf(code) + if (callingCode === undefined) continue + + const unblockedSiblings = getCountries().filter( + (country) => + country !== code && + getCountryCallingCode(country) === callingCode && + !blocked.has(country), + ) + if (unblockedSiblings.length > 0) collateral[code] = unblockedSiblings + } + + expect(collateral).toEqual({ RU: ["KZ"] }) + } + }) + }) + + // Ajv's `useDefaults` assigns by reference. One shared array instance would + // make both config keys — and this schema object — the same live array in + // every environment whose configmap sets neither key, so the first push or + // splice against one would silently change the other. + it("gives each key its own array instance", () => { + expect(smsDefault).not.toBe(whatsAppDefault) + expect(smsDefault).toEqual(whatsAppDefault) + }) + + it("never blocks a country that has produced a real signup", () => { + const convertedCountries = [ + "JM", + "US", + "NG", + "IN", + "GB", + "CA", + "DE", + "GH", + "KY", + "BJ", + "RW", + "SD", + "CD", + "MV", + "BD", + "BE", + "UG", + "TT", + "ML", + "CO", + "SK", + ] + + for (const code of convertedCountries) { + expect(smsDefault).not.toContain(code) + expect(whatsAppDefault).not.toContain(code) + } + }) + }) }) diff --git a/test/flash/unit/domain/authentication/index.spec.ts b/test/flash/unit/domain/authentication/index.spec.ts index 02c86866c..b8a7e9b00 100644 --- a/test/flash/unit/domain/authentication/index.spec.ts +++ b/test/flash/unit/domain/authentication/index.spec.ts @@ -1,4 +1,8 @@ -import { getSupportedCountries } from "@domain/authentication" +import { + getSupportedCountries, + isAuthChannelSupportedForCountry, +} from "@domain/authentication" +import { ChannelType } from "@domain/phone-provider" describe("getSupportedCountries", () => { it("returns supported countries", () => { @@ -19,4 +23,85 @@ describe("getSupportedCountries", () => { }, ]) }) + + // The lists are raw configmap strings, never validated. A lowercase entry + // must not silently stop filtering the picker. + it("filters a lowercase configmap entry", () => { + const countries = getSupportedCountries({ + allCountries: ["CA", "US"] as CountryCode[], + unsupportedSmsCountries: ["ca"] as CountryCode[], + unsupportedWhatsAppCountries: ["ca"] as CountryCode[], + }) + + expect(countries).toEqual([ + { + id: "US", + supportedAuthChannels: ["sms", "whatsapp"], + }, + ]) + }) +}) + +describe("isAuthChannelSupportedForCountry", () => { + const blockedSmsCountries = ["UZ", "RU"] as CountryCode[] + const blockedWhatsAppCountries = ["UZ", "BR"] as CountryCode[] + + const check = (countryCode: string, channel: ChannelType) => + isAuthChannelSupportedForCountry({ + countryCode: countryCode as CountryCode, + channel, + blockedSmsCountries, + blockedWhatsAppCountries, + }) + + it("allows a country on neither list", () => { + expect(check("JM", ChannelType.Sms)).toBe(true) + expect(check("JM", ChannelType.Whatsapp)).toBe(true) + }) + + it("blocks a country on the list for that channel only", () => { + expect(check("RU", ChannelType.Sms)).toBe(false) + expect(check("RU", ChannelType.Whatsapp)).toBe(true) + + expect(check("BR", ChannelType.Whatsapp)).toBe(false) + expect(check("BR", ChannelType.Sms)).toBe(true) + }) + + it("blocks a country listed for both channels", () => { + expect(check("UZ", ChannelType.Sms)).toBe(false) + expect(check("UZ", ChannelType.Whatsapp)).toBe(false) + }) + + // An operator writing `- uz` in the Helm values must still get a working + // fraud control; a silently-inert blocklist is the worst failure mode here. + it("blocks a lowercase configmap entry", () => { + expect( + isAuthChannelSupportedForCountry({ + countryCode: "UZ" as CountryCode, + channel: ChannelType.Sms, + blockedSmsCountries: ["uz"] as CountryCode[], + blockedWhatsAppCountries: [], + }), + ).toBe(false) + + expect( + isAuthChannelSupportedForCountry({ + countryCode: "uz" as CountryCode, + channel: ChannelType.Whatsapp, + blockedSmsCountries: [], + blockedWhatsAppCountries: ["UZ"] as CountryCode[], + }), + ).toBe(false) + }) + + it("allows every country when the lists are empty", () => { + expect( + isAuthChannelSupportedForCountry({ + countryCode: "UZ" as CountryCode, + channel: ChannelType.Sms, + blockedSmsCountries: [], + blockedWhatsAppCountries: [], + }), + ).toBe(true) + }) }) diff --git a/test/flash/unit/graphql/error-map.spec.ts b/test/flash/unit/graphql/error-map.spec.ts index 988dd6aa5..85e8d37b2 100644 --- a/test/flash/unit/graphql/error-map.spec.ts +++ b/test/flash/unit/graphql/error-map.spec.ts @@ -7,6 +7,8 @@ import { BridgeDepositInstructionsMissingError, } from "@services/bridge/errors" import { IbexError, InsufficientIbexBalance } from "@services/ibex/errors" +import { PhoneCountryNotAllowedError } from "@domain/users/errors" +import { InvalidPhoneNumber } from "@domain/errors" describe("error-map", () => { it("maps BridgeWithdrawalNotFoundError to BRIDGE_WITHDRAWAL_NOT_FOUND", () => { @@ -30,6 +32,26 @@ describe("error-map", () => { expect(result.message).toContain("deposit instructions") }) + // A blocked auth-code destination is a policy decision, not a transient bug: + // it must never surface as "unexpected error, please try again". + it("maps PhoneCountryNotAllowedError to a validation error, not the catch-all", () => { + const result = mapError(new PhoneCountryNotAllowedError()) + + expect(result.message).toBe("Phone number is not from a valid region") + expect(result.message).not.toContain("Unexpected error") + expect(result.extensions.code).not.toBe("UNEXPECTED_CLIENT_ERROR") + }) + + // A malformed number is a client input error, and the number itself must not + // come back inside the message. + it("maps InvalidPhoneNumber to a validation error without echoing the number", () => { + const result = mapError(new InvalidPhoneNumber("+000123")) + + expect(result.message).toBe("Phone number is not a valid phone number") + expect(result.message).not.toContain("+000123") + expect(result.extensions.code).not.toBe("UNEXPECTED_CLIENT_ERROR") + }) + it("maps PhoneAccountAlreadyExistsCannotUpgradeError to correct GQL error", () => { const input = new PhoneAccountAlreadyExistsCannotUpgradeError() const result = mapError(input) diff --git a/test/flash/unit/servers/authorization/phone-code-route.spec.ts b/test/flash/unit/servers/authorization/phone-code-route.spec.ts new file mode 100644 index 000000000..cb2569545 --- /dev/null +++ b/test/flash/unit/servers/authorization/phone-code-route.spec.ts @@ -0,0 +1,118 @@ +import { Request, Response } from "express" + +import { Authentication } from "@app" +import { PhoneCountryNotAllowedError } from "@domain/users/errors" +import { InvalidPhoneNumber } from "@domain/errors" +import authRouter from "@servers/authorization" + +jest.mock("@app", () => ({ + Authentication: { + requestPhoneCodeWithCaptcha: jest.fn(), + }, +})) + +// The router is only reachable in a unit test if its transitive service imports +// (kratos clients, redis-backed rate limiters, the captcha SDK) never open a +// socket at import time. +jest.mock("@app/authentication", () => ({ + elevatingSessionWithTotp: jest.fn(), + loginWithEmailCookie: jest.fn(), + loginWithEmailToken: jest.fn(), + logoutCookie: jest.fn(), + requestEmailCode: jest.fn(), +})) + +jest.mock("@app/captcha", () => ({ registerCaptchaGeetest: jest.fn() })) + +jest.mock("@services/kratos", () => ({ + checkedToAuthToken: jest.fn(), + checkedToEmailLoginId: jest.fn(), + checkedToTotpCode: jest.fn(), + validateKratosCookie: jest.fn(), +})) + +jest.mock("@services/kratos/cookie", () => ({ parseKratosCookies: jest.fn() })) + +const mockedRequestPhoneCode = + Authentication.requestPhoneCodeWithCaptcha as jest.MockedFunction< + typeof Authentication.requestPhoneCodeWithCaptcha + > + +type RouteLayer = { + route?: { path: string; stack: { handle: (req: Request, res: Response) => unknown }[] } +} + +const handlerFor = (path: string) => { + const layer = (authRouter as unknown as { stack: RouteLayer[] }).stack.find( + (l) => l.route?.path === path, + ) + if (!layer?.route) throw new Error(`no route registered at ${path}`) + return layer.route.stack[0].handle +} + +const makeRes = () => { + const res = { status: jest.fn(), json: jest.fn(), send: jest.fn() } + res.status.mockReturnValue(res) + return res as unknown as Response & { + status: jest.Mock + json: jest.Mock + send: jest.Mock + } +} + +const makeReq = () => + ({ + originalIp: "1.2.3.4", + body: { + phone: "+18761234567", + challengeCode: "challenge", + validationCode: "validate", + secCode: "seccode", + channel: "SMS", + }, + }) as unknown as Request + +// `res.json({ error: someError })` serializes to `{}` — an Error's `name` and +// `message` are non-enumerable — so every message the error map produces was +// invisible on this route, and the client got `{"error":{}}`. /phone/login two +// functions down already sends the mapped message. +describe("POST /auth/phone/code error responses", () => { + beforeEach(() => mockedRequestPhoneCode.mockReset()) + + it("returns the mapped message for a blocked country, not an empty object", async () => { + mockedRequestPhoneCode.mockResolvedValue(new PhoneCountryNotAllowedError()) + const res = makeRes() + + await handlerFor("/phone/code")(makeReq(), res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledWith({ + error: "Phone number is not from a valid region", + }) + }) + + it("distinguishes an unparsable number from a blocked country", async () => { + mockedRequestPhoneCode.mockResolvedValue( + new InvalidPhoneNumber("+000" as PhoneNumber), + ) + const res = makeRes() + + await handlerFor("/phone/code")(makeReq(), res) + + expect(res.status).toHaveBeenCalledWith(400) + const [[body]] = res.json.mock.calls + expect(typeof body.error).toBe("string") + expect(body.error).not.toBe("Phone number is not from a valid region") + expect(body.error.length).toBeGreaterThan(0) + }) + + it("still reports success without an error body", async () => { + mockedRequestPhoneCode.mockResolvedValue(true) + const res = makeRes() + + await handlerFor("/phone/code")(makeReq(), res) + + expect(res.status).not.toHaveBeenCalled() + expect(res.json).toHaveBeenCalledWith({ success: true }) + }) +}) diff --git a/typos.toml b/typos.toml index 693861dc2..75777368f 100644 --- a/typos.toml +++ b/typos.toml @@ -6,6 +6,14 @@ files.extend-exclude = [ # global, so it would also stop flagging those words where they ARE typos, in # the English files. en.json stays checked. "src/config/locales/es.json", + # Nothing but a list of ISO 3166-1 alpha-2 codes. "BA" (Bosnia and + # Herzegovina) is read as a misspelling of "BY"/"BE" — and BY is an entry in + # that same list, so accepting the suggestion would silently change which + # country the SMS-pumping gate blocks and duplicate an existing entry. The + # list was split out of src/config/schema.ts so this suppression covers the + # data only: schema.ts keeps full spell checking, and no repo-global + # extend-words entry is needed (which would stop flagging "BA" everywhere). + "src/config/blocked-countries.ts", "src/domain/users/languages.ts", "src/services/loopd/protos", "docs/postman-collection/galoy_graphql_main_api.postman_collection.json",