diff --git a/docs/pstn-calling-voipms-plan.md b/docs/pstn-calling-voipms-plan.md index c391020..c93c304 100644 --- a/docs/pstn-calling-voipms-plan.md +++ b/docs/pstn-calling-voipms-plan.md @@ -130,9 +130,20 @@ anyone notices. Two independent layers matter more than either alone: trunk's own capabilities. **Important nuance: these two layers bound different things, and neither -alone bounds both.** NANP-only restriction bounds *cost-per-minute* (a -compromised box can only ever reach $0.01/min US numbers, never $2–5/min -international/premium destinations) — that risk is fully closed. It does +alone bounds both.** NANP-only restriction bounds *cost-per-minute* — but +**NANP-only alone was not actually sufficient**, and this design carried +that gap for a while before it was caught. NANP includes Caribbean/Atlantic +nations and several US territories (Bahamas, Puerto Rico, Dominican +Republic, and 24 others) that dial exactly like a normal 10-digit US number +but bill at international/premium rates — a well-known toll-fraud/ +"one-ring scam" vector specifically because the format looks domestic. The +NANP pattern match alone would have let a compromised extension reach every +one of these. **Fixed**: `services/pstn-trunk.sh` now blocks those 27 area +codes explicitly, checked before permission tier, for every extension +regardless of tier — see the dialplan file's own comment and +"Non-US NANP area codes are blocked" in its generated README for the full +list. With that fix in place, cost-per-minute risk actually is closed (a +compromised box can only ever reach genuine US-rate destinations). It does **not** bound *how fast* the prepaid balance gets burned: nothing stops a compromised box from opening many concurrent US-destination calls in parallel and draining the whole balance (e.g. $15 balance ÷ $0.01/min = @@ -165,7 +176,11 @@ separately from that hourly check. - An outbound dialplan route matching US numbers only — **implemented**: `_1NXXNXXXXX` (11-digit NANP with leading 1) and `_NXXNXXXXX` (10-digit, auto-prefixed with 1), both routed to the trunk. No catch-all `_X.` - pattern. + pattern. **Also implemented**: an explicit block on the 27 NANP area + codes that aren't actually US (Caribbean/Atlantic nations + US + territories — see the toll-fraud nuance above for why this matters), + checked via `REGEX()` against the extracted area code before permission + tier, ahead of every other check. - **Three-tier permission model — implemented**, superseding an earlier flat allow-list design. `internal` / `restricted` / `full` per extension, read live from `pstn-permissions.conf` via `AST_CONFIG()` rather than baked @@ -309,7 +324,10 @@ generator output. Fixed by quoting every value in that heredoc. 1. ~~Decide: new `services/pstn-trunk.sh`...~~ Done — separate service file, generalized to any IP-auth SIP provider (VoIP.ms is just the default). 2. ~~IP auth vs. registration~~ Done — IP authentication, no password stored. -3. ~~Exact NANP dial pattern(s)~~ Done — `_1NXXNXXXXX` / `_NXXNXXXXX`. +3. ~~Exact NANP dial pattern(s)~~ Done — `_1NXXNXXXXX` / `_NXXNXXXXX`. ~~NANP + ≠ US gap~~ Done — 27 Caribbean/territory area codes explicitly blocked + (see toll-fraud nuance above); this was a real, live gap in the design + for a while, not a hypothetical. 4. ~~Inbound~~ Done — rings a configurable list of extensions (ring-group supported), each checked live per-call against its own tier. ~~Permission model~~ Done — superseded the original flat allow-list with a 3-tier @@ -338,3 +356,9 @@ generator output. Fixed by quoting every value in that heredoc. (VoIP.ms's docs mention some redundancy/failover between servers — if inbound calls ever stop matching the `identify` section, this is the first thing to check). +7. Anveo Direct specifically has a provider-side "Call Security" setting + (account portal, not exposed through SIP config) — a per-minute rate + cap ("block calls with rate more than $X/min, 0 = block all") and a + maximum call duration. Worth setting on any Anveo Direct account as a + provider-side backstop independent of this repo's own dialplan code — + not automatable from here since it's their web UI, not a config file. diff --git a/services/pstn-trunk.sh b/services/pstn-trunk.sh index e3ab521..ea63d9d 100644 --- a/services/pstn-trunk.sh +++ b/services/pstn-trunk.sh @@ -221,8 +221,19 @@ _pstn_write_dialplan_include() { ; to the trunk, so an unauthorized or compromised extension can't reach ; anything else even if the trunk itself would technically allow more. See ; docs/pstn-calling-voipms-plan.md for the toll-fraud reasoning. +; +; NANP is NOT the same thing as "US" — it also covers Caribbean/Atlantic +; nations and several US territories, all of which dial exactly like a +; normal 10-digit US number but get billed by most providers at +; international/premium rates (a well-known toll-fraud/"one-ring scam" +; vector precisely because the number format looks domestic). Blocked by +; area code below, checked before tier permission — this is a hard "never +; reachable" rule, not something even a "full" tier extension can override, +; since "full" means "any US number," not "any NANP-shaped number." exten => _1NXXNXXXXX,1,NoOp(PSTN outbound call attempt from ${CHANNEL(peername)} to ${EXTEN}) + same => n,Set(PSTN_AREA_CODE=${EXTEN:1:3}) + same => n,GotoIf($[${REGEX("^(242|246|264|268|284|340|345|441|473|649|658|664|670|671|684|721|758|767|784|787|809|829|849|868|869|876|939)$" ${PSTN_AREA_CODE})} = 1]?pstn_intl_blocked,1) same => n,Set(PSTN_CALLER=${CHANNEL(peername)}) same => n,Set(PSTN_TIER=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},tier)}) same => n,GotoIf($["${PSTN_TIER}" = "full"]?pstn_check_busy,1) @@ -235,6 +246,11 @@ __ALERT_DENY_TIER_LINE__ exten => _NXXNXXXXX,1,NoOp(Assuming NANP - adding leading 1) same => n,Goto(1${EXTEN},1) +exten => pstn_intl_blocked,1,NoOp(PSTN outbound call to ${EXTEN} blocked - non-US/premium NANP area code ${PSTN_AREA_CODE}) +__ALERT_DENY_INTL_LINE__ + same => n,Busy(15) + same => n,Hangup() + exten => pstn_check_allow_out,1,Set(PSTN_ALLOWED=${AST_CONFIG(pstn-permissions.conf,${PSTN_CALLER},allowed_numbers)}) same => n,GotoIf($[${REGEX("^(${PSTN_ALLOWED})$" ${EXTEN})} = 1]?pstn_check_busy,1) same => n,NoOp(Denied - ${EXTEN} not on ${PSTN_CALLER}'s approved number list) @@ -263,10 +279,11 @@ EOF if [[ -n "$NTFY_URL" ]]; then local _esc_url="${NTFY_URL//&/\\&}" sed -i "s#__ALERT_DENY_TIER_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call denied - no PSTN permission.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" + sed -i "s#__ALERT_DENY_INTL_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call blocked - non-US/premium NANP area code.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" sed -i "s#__ALERT_DENY_NUMBER_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound call denied - number not pre-approved.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" sed -i "s#__ALERT_BUSY_LINE__# same => n,System(curl -m 5 -s -d 'PSTN trunk: outbound concurrent-call cap reached - a call was rejected.' '${_esc_url}' >/dev/null 2>\\&1 \\&)#" "$FILE" else - sed -i "/__ALERT_DENY_TIER_LINE__/d; /__ALERT_DENY_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE" + sed -i "/__ALERT_DENY_TIER_LINE__/d; /__ALERT_DENY_INTL_LINE__/d; /__ALERT_DENY_NUMBER_LINE__/d; /__ALERT_BUSY_LINE__/d" "$FILE" fi # ── Inbound: [from-pstn-trunk], one unrolled block per ring-group member. @@ -761,7 +778,7 @@ background, cost estimate, and toll-fraud reasoning. | Provider | ${PROVIDER_NAME} | | Server/POP | ${TRUNK_SERVER} (inbound match IPs: ${TRUNK_SERVER_IPS}) | | DID | ${TRUNK_DID} | -| Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all | +| Outbound scope | US/NANP only — \`_1NXXNXXXXX\` / \`_NXXNXXXXX\` patterns, no catch-all, minus 27 non-US/premium NANP area codes (see below) | | Full-PSTN extensions | ${FULL_EXTS:-none} | | Restricted-PSTN extensions | ${RESTRICTED_EXTS:-none} | | Concurrency caps | ${MAX_OUTBOUND} outbound / ${MAX_INBOUND} inbound simultaneous calls (live — see \`pstn-limits.conf\` below) | @@ -771,6 +788,31 @@ background, cost estimate, and toll-fraud reasoning. | Monthly spend alert threshold | \$${MONTH_THRESHOLD} | | Hourly burst alert threshold | ${BURST_THRESHOLD} calls/hour | +## Non-US NANP area codes are blocked, not just "anything outside NANP" + +NANP (the North American Numbering Plan) isn't the same thing as "US" — it +also covers several Caribbean/Atlantic nations and US territories, all of +which dial exactly like a normal 10-digit US number but get billed by most +providers at international/premium rates. This is a well-known toll-fraud/ +"one-ring scam" vector specifically because the number *looks* domestic. +27 area codes are blocked explicitly, checked before permission tier — this +applies to **every** extension regardless of tier, since "full" means "any +US number," not "any NANP-shaped number": + +Bahamas (242), Barbados (246), Anguilla (264), Antigua & Barbuda (268), +British Virgin Islands (284), US Virgin Islands (340), Cayman Islands +(345), Bermuda (441), Grenada (473), Turks & Caicos (649), Jamaica +(658/876), Montserrat (664), Northern Mariana Islands (670), Guam (671), +American Samoa (684), Sint Maarten (721), Saint Lucia (758), Dominica +(767), Saint Vincent (784), Puerto Rico (787/939), Dominican Republic +(809/829/849), Trinidad & Tobago (868), Saint Kitts & Nevis (869). + +If you have a legitimate reason to call one of these (e.g. family in Puerto +Rico), remove that entry from the `REGEX()` pattern in +`pstn-trunk-dialplan.conf`'s `_1NXXNXXXXX` extension — it'll be +regenerated exactly the same way on the next reinstall/update, so note the +change somewhere you'll remember it, or keep a local diff. + ## Permission tiers Every extension can always call and receive calls from other Asterisk