Close remaining 10-vs-11-digit gaps in PSTN number input

Full audit of every phone-number comparison/storage point across
pstn-trunk.sh, security-dashboard.sh, asterisk.sh, asterisk-digital-ocean.sh,
and the vendored easy-asterisk base script, prompted by the inbound
Caller-ID normalization fix — the same digit-count mismatch was also
possible on the admin-input side, just silent instead of loud:

- security-dashboard.sh's write_permission(): a 10-digit whitelist entry
  was silently DROPPED (NUMBER_RE required exactly 11 digits), with no
  warning unless every entry in the field was invalid — a mixed
  10-digit + 11-digit list saved "successfully" while quietly losing the
  10-digit one. Now normalizes any bare 10-digit token to 11-digit instead
  of discarding it (_normalize_nanp_number).
- write_personal_did(): required exactly 10 digits, rejecting an
  11-digit entry outright with a clear (but avoidable) error. Now accepts
  either and normalizes to the canonical 10-digit storage form
  (_normalize_personal_did_input).
- pstn-trunk.sh's TRUNK_DID install prompt: same fix, strips a leading
  "1" instead of aborting the install over it.

Everything else checked out clean: outbound dialed-number matching
already normalizes via the existing _NXXNXXXXXX pattern (adds "1" before
any tier check), the area-code/country-code international gates aren't
phone numbers so digit-count doesn't apply, and asterisk.sh/
asterisk-digital-ocean.sh/the vendored base script have no phone-number
comparison logic at all — this class of bug only lives in the PSTN
permission/whitelist layer this project added on top.
This commit is contained in:
Claude
2026-07-24 14:40:33 +00:00
parent 10576ea59c
commit 8a99544ee1
2 changed files with 50 additions and 9 deletions
+7
View File
@@ -1666,6 +1666,13 @@ install_pstn-trunk() {
local TRUNK_DID=""
prompt_text "DID (the 10-digit US phone number assigned to this trunk, digits only):" "" TRUNK_DID
# Accept an 11-digit entry (leading "1") too and strip it — every other
# DID/number field in this system (personal_did, allowed_numbers) has
# the same 10-vs-11-digit ambiguity, and rejecting a plainly-valid
# 11-digit number here just to force a re-prompt is needless friction.
if [[ "$TRUNK_DID" =~ ^1([0-9]{10})$ ]]; then
TRUNK_DID="${BASH_REMATCH[1]}"
fi
if [[ ! "$TRUNK_DID" =~ ^[0-9]{10}$ ]]; then
log_error "That doesn't look like a 10-digit US number — aborting."
return 1
+43 -9
View File
@@ -782,6 +782,23 @@ EXT_HEADER_RE = re.compile(r"^\[(\d+)\]")
EXTEN_RE = re.compile(r"^\d+$")
TIER_RE = re.compile(r"^(internal|restricted|full)$")
NUMBER_RE = re.compile(r"^\d{11}$")
NUMBER_RE_10 = re.compile(r"^\d{10}$")
def _normalize_nanp_number(token):
"""Accepts either a bare 10-digit NANP number (the natural way to type
a US number) or an already-11-digit one (leading "1" country code) and
returns the canonical 11-digit form allowed_numbers is always stored
in - REGEX() comparisons against CALLERID(num) require an exact
digit-count match, and this used to silently DROP a plain 10-digit
entry instead of normalizing it, the admin-input-side twin of the bug
that was also failing inbound calls whose Caller-ID itself arrived
without a leading "1" (see PSTN_CALLERID_NORM in pstn-trunk.sh)."""
if NUMBER_RE_10.match(token):
return "1" + token
if NUMBER_RE.match(token):
return token
return None
SECURITY_LOG_TAIL_BYTES = 2 * 1024 * 1024 # comfortably enough for 5000 lines
@@ -1149,11 +1166,13 @@ def write_permission(ext, tier, numbers_raw, messaging_enabled=False):
the calling tier (see pstn-trunk.sh's file-level comment: an extension
can be internal-tier for calling and still messaging-enabled, or vice
versa), so it's set/cleared regardless of which tier branch runs below.
Numbers are normalized to a pipe-separated list of 11-digit US numbers
pipe, not comma, because the dialplan uses this value directly as a
REGEX() alternation pattern (see services/pstn-trunk.sh's file-level
comment on why the untrusted call data is always the string being
tested, never interpolated into the pattern side)."""
Numbers are normalized to a pipe-separated list of 11-digit US numbers
(a bare 10-digit entry gets a leading "1" added, not dropped — see
_normalize_nanp_number) — pipe, not comma, because the dialplan uses
this value directly as a REGEX() alternation pattern (see
services/pstn-trunk.sh's file-level comment on why the untrusted call
data is always the string being tested, never interpolated into the
pattern side)."""
if not ASTERISK_CONFIG_DIR:
return False, "No Asterisk install detected on this box"
ext = str(ext).strip()
@@ -1163,7 +1182,7 @@ def write_permission(ext, tier, numbers_raw, messaging_enabled=False):
return False, "Invalid tier"
tokens = re.split(r"[,\s|]+", (numbers_raw or "").strip())
clean_numbers = [t for t in tokens if NUMBER_RE.match(t)]
clean_numbers = [n for n in (_normalize_nanp_number(t) for t in tokens if t) if n]
numbers = "|".join(clean_numbers)
cp = _read_permissions_cp()
@@ -1405,6 +1424,19 @@ def write_limits(max_outbound, max_inbound):
PERSONAL_DID_RE = re.compile(r"^\d{10}$")
PERSONAL_DID_RE_11 = re.compile(r"^1\d{10}$")
def _normalize_personal_did_input(did):
"""write_personal_did()'s DID field is hand-typed, same footgun as
allowed_numbers - accept either the canonical bare 10-digit form or an
11-digit one with the NANP "1" prefix, returning the canonical 10-digit
form either way instead of rejecting a plainly-valid 11-digit entry."""
if PERSONAL_DID_RE.match(did):
return did
if PERSONAL_DID_RE_11.match(did):
return did[1:]
return None
def _personal_dids_path():
@@ -1460,8 +1492,10 @@ def write_personal_did(did, owner):
return False, "No Asterisk install detected on this box"
did = str(did).strip()
owner = str(owner).strip()
if not PERSONAL_DID_RE.match(did):
return False, "DID must be a 10-digit US number"
norm_did = _normalize_personal_did_input(did)
if norm_did is None:
return False, "DID must be a 10-digit US number (11-digit with a leading 1 also accepted)"
did = norm_did
is_group = owner.startswith("@")
group_name = owner[1:] if is_group else ""
@@ -3154,7 +3188,7 @@ function renderPstnTable() {
<option value="full" ${e.tier === "full" ? "selected" : ""}>full</option>
</select>
</td>
<td><input type="text" class="pstn-numbers" value="${esc(e.allowed_numbers)}" placeholder="15551234567,15559876543" ${e.tier === "restricted" ? "" : "disabled"}></td>
<td><input type="text" class="pstn-numbers" value="${esc(e.allowed_numbers)}" placeholder="5551234567,5559876543 (leading 1 optional)" ${e.tier === "restricted" ? "" : "disabled"}></td>
<td style="text-align:center"><input type="checkbox" class="pstn-messaging" ${e.messaging ? "checked" : ""}></td>
<td><button class="action" onclick="savePstnPermission('${esc(e.ext)}')">Save</button></td>
</tr>`).join("");