security: headers, phone ACL, PIN rules for PINs set from the phone

Headers (08 §2.4) on every response: CSP (connect-src also allows
WEBRTC_WS_URL's origin when set), nosniff, Referrer-Policy,
Permissions-Policy. The dark-mode bootstrap moved from an inline
<script> to /theme.js so script-src stays 'self'.

Phone ACL (08 §2.2): with sip.restrictPhonesToLocalNets, every desk
extension endpoint gets deny 0.0.0.0/0 and permit for each local and
extra net. The spec's snippet was wrong: contact_acl names acl.conf
sections, a pjsip type=acl is the global ACL (it would filter trunks),
and Contact-header rules let a NATed remote phone through. Browser
endpoints come through the reverse proxy and aren't filtered. Golden
case phone-acl. Settings → Security holds it, with the provisioning
switches.

PIN rules for PINs changed from the phone's voicemail menu, which went
around the api: externpasscheck = sp-vm-pincheck (in the Asterisk
image, same rules as isTrivialPin, a unit test keeps them in step) and
minpassword = 4. Accepted PINs reach the api through AMI
VoicemailPasswordChange and are stored; before, the next config write
silently put the old PIN back.

e2e m11-t1: no CSP violations across admin pages; Asterisk refuses a
REGISTER ("Not match Endpoint ACL") until the network is listed; from
the phone, 1111 is refused by the check script and 7391 is stored and
survives a later config write.

Task: M11-T1
This commit is contained in:
iamdoubz committed 2026-10-19 14:47:04 -05:00
1 parent 62939f0138
commit 65e777ab41
38 files changed
+950 -19

No files matched your search

+21
View File
@@ -89,6 +89,27 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
return sendError(reply, forbidden('Missing X-Requested-With header', 'csrf'));
});
// Security headers on every response (08 §2.4). The softphone's WebSocket may live elsewhere
// (WEBRTC_WS_URL, e.g. in dev), so its origin is allowed too. HSTS is the reverse proxy's job.
const wsOrigins = [
`wss://${config.PUBLIC_HOSTNAME}`,
...(config.WEBRTC_WS_URL ? [new URL(config.WEBRTC_WS_URL).origin] : []),
];
const csp = [
"default-src 'self'",
`connect-src 'self' ${[...new Set(wsOrigins)].join(' ')}`,
"media-src 'self' blob:",
"img-src 'self' data:",
"style-src 'self' 'unsafe-inline'",
"frame-ancestors 'none'",
].join('; ');
app.addHook('onSend', async (_req, reply) => {
reply.header('Content-Security-Policy', csp);
reply.header('X-Content-Type-Options', 'nosniff');
reply.header('Referrer-Policy', 'same-origin');
reply.header('Permissions-Policy', 'microphone=(self), camera=()');
});
app.get('/healthz', async (_req, reply) => {
try {
await db.execute(sql`select 1`);
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -0,0 +1,6 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[sp_admin_user]
type = user
admin = yes
marked = yes
music_on_hold_when_empty = yes
@@ -0,0 +1,5 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
SP_DEFAULT_CID=
SP_DEFAULT_E911_CID=
SP_REC_ANNOUNCE=
SP_CLASS_101=2
@@ -0,0 +1,239 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
; ─── Normalize a number. ARG1 = raw. Returns normalized in GOSUB_RETVAL. (matches 00 §8)
[sp-sub-normalize]
exten => s,1,Set(N=${FILTER(0123456789+,${ARG1})})
same => n,ExecIf($["${N:0:2}" = "+1"]?Set(N=${N:1}))
same => n,ExecIf($["${N:0:1}" = "+"]?Set(N=011${N:1}))
same => n,ExecIf($[${LEN(${N})} = 10]?Set(N=1${N}))
same => n,Return(${N})
; ─── Ring every registered device of a user. ARG1 = ext, ARG2 = seconds.
; Sets DIALSTATUS. Rings desk/softphone contacts (PJSIP/101) and browser (PJSIP/101-web).
[sp-sub-dial-user]
exten => s,1,Set(DS=${PJSIP_DIAL_CONTACTS(${ARG1})})
same => n,Set(WS=${PJSIP_DIAL_CONTACTS(${ARG1}-web)})
same => n,ExecIf($["${DS}" != "" & "${WS}" != ""]?Set(DS=${DS}&${WS}))
same => n,ExecIf($["${DS}" = ""]?Set(DS=${WS}))
same => n,GotoIf($["${DS}" = ""]?unavail)
same => n,Dial(${DS},${ARG2},tx)
same => n,Return()
same => n(unavail),Set(DIALSTATUS=CHANUNAVAIL)
same => n,Return()
; ─── Call a user with all their personal rules.
; ARG1 = ext, ARG2 = ring seconds, ARG3 = voicemail enabled (1/0), ARG4 = record mode
[sp-sub-user]
exten => s,1,Set(__SP_TARGET=${ARG1})
same => n,ExecIf($["${SP_DIRECTION}" = "inbound" & ("${ARG4}" = "inbound" | "${ARG4}" = "all")]?Gosub(sp-sub-record,s,1))
same => n,GotoIf($["${DB(SP/DND/${ARG1})}" = "1"]?busy)
same => n,Set(FWD=${DB(SP/CFU/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,Gosub(sp-sub-dial-user,s,1(${ARG1},${ARG2}))
same => n,GotoIf($["${DIALSTATUS}" = "ANSWER"]?done)
same => n,GotoIf($["${DIALSTATUS}" = "BUSY"]?busy)
same => n(noanswer),Set(FWD=${DB(SP/CFNA/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmu)
same => n,Return()
same => n(vmu),VoiceMail(${ARG1}@default,u)
same => n,Return()
same => n(busy),Set(FWD=${DB(SP/CFB/${ARG1})})
same => n,GotoIf($["${FWD}" != ""]?forward)
same => n,GotoIf($["${ARG3}" = "1"]?vmb)
same => n,Busy(5)
same => n,Return()
same => n(vmb),VoiceMail(${ARG1}@default,b)
same => n,Return()
same => n(forward),Gosub(sp-sub-forward,s,1(${FWD},${ARG1}))
same => n(done),Return()
; ─── Forward a call. ARG1 = target number, ARG2 = ext whose forward rule this is.
; Stops after 3 hops so 101→102→101 can't loop forever.
[sp-sub-forward]
exten => s,1,Set(__SP_FWDHOPS=$[${IF($["${SP_FWDHOPS}" = ""]?0:${SP_FWDHOPS})} + 1])
same => n,GotoIf($[${SP_FWDHOPS} > 3]?stop)
same => n,GotoIf($[${DIALPLAN_EXISTS(sp-local-numbers,${ARG1},1)}]?internal)
same => n,Set(SP_CLASS=${GLOBAL(SP_CLASS_${ARG2})})
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${ARG1},1)
same => n(internal),Goto(sp-local-numbers,${ARG1},1)
same => n(stop),Return()
; ─── Start recording this call once. File: YYYY/MM/DD/<uniqueid>.wav under the monitor dir.
[sp-sub-record]
exten => s,1,GotoIf($["${SP_REC}" != ""]?done)
same => n,Set(__SP_REC=${STRFTIME(${EPOCH},,%Y/%m/%d)}/${UNIQUEID}.wav)
same => n,Set(CDR(SP_REC)=${SP_REC}) ; cdr_manager maps CDR variables only
same => n,MixMonitor(${SP_REC},b)
same => n(done),Return()
; ─── Auto-answer headers for paging/intercom (Yealink, Grandstream, Polycom, Snom)
[sp-sub-autoanswer]
exten => s,1,Set(PJSIP_HEADER(add,Call-Info)=<sip:${SP_EXT}>\;answer-after=0)
same => n,Set(PJSIP_HEADER(add,Alert-Info)=info=alert-autoanswer)
same => n,Return()
; ─── 911 alert ring-out. Originated by the api (04 §5.3): Channel PJSIP/<notify ext>,
; Exten = the extension that dialed 911.
[sp-emergency-alert]
exten => _X!,1,Answer() ; X! also matches "0" (caller unknown)
same => n,Wait(1)
same => n,Playback(beep&beep&beep)
same => n,Playback(extension)
same => n,SayDigits(${EXTEN})
same => n,Playback(beep&beep&beep)
same => n,Playback(extension)
same => n,SayDigits(${EXTEN})
same => n,Hangup()
; ─── Feature codes (03 §6.13).
[sp-featurecodes]
exten => *97,1,Answer() ; my voicemail
same => n,VoiceMailMain(${SP_EXT}@default)
same => n,Hangup()
exten => *98,1,Answer() ; any voicemail (asks mailbox)
same => n,VoiceMailMain(@default)
same => n,Hangup()
exten => *78,1,Answer() ; DND on
same => n,Set(DB(SP/DND/${SP_EXT})=1)
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 1)
same => n,Playback(do-not-disturb&activated)
same => n,Hangup()
exten => *79,1,Answer() ; DND off
same => n,Set(X=${DB_DELETE(SP/DND/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: DND,Ext: ${SP_EXT},Value: 0)
same => n,Playback(do-not-disturb&de-activated)
same => n,Hangup()
exten => _*72X.,1,Answer() ; *72<number> forward always
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN:3}))
same => n,Set(DB(SP/CFU/${SP_EXT})=${GOSUB_RETVAL})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: ${GOSUB_RETVAL})
same => n,Playback(call-fwd-unconditional&activated)
same => n,Hangup()
exten => *73,1,Answer() ; cancel forward always
same => n,Set(X=${DB_DELETE(SP/CFU/${SP_EXT})})
same => n,UserEvent(SpToggle,Key: CFU,Ext: ${SP_EXT},Value: )
same => n,Playback(call-fwd-unconditional&de-activated)
same => n,Hangup()
exten => _**X.,1,PickupChan(PJSIP/${EXTEN:2}-&PJSIP/${EXTEN:2}-web-) ; directed pickup **101
same => n,Hangup()
; *8 group pickup: features.conf pickupexten catches it straight from a phone's INVITE; this
; line covers calls that reach *8 through the dialplan. Uses named_pickup_group.
exten => *8,1,Pickup()
same => n,Hangup()
exten => *43,1,Answer() ; echo test
same => n,Playback(demo-echotest)
same => n,Echo()
same => n,Playback(demo-echodone)
same => n,Hangup()
exten => *65,1,Answer() ; say my extension
same => n,SayDigits(${SP_EXT})
same => n,Hangup()
exten => _*45X.,1,Answer() ; *45<queue> toggle queue login
same => n,Set(Q=${EXTEN:3})
same => n,Set(K=SP/QPAUSE/${Q}/${SP_EXT})
same => n,GotoIf($["${DB(${K})}" = "1"]?login)
same => n,PauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${PQMSTATUS}" != "PAUSED"]?deny) ; not an agent of that queue
same => n,Set(DB(${K})=1)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 1)
same => n,Playback(agent-loggedoff)
same => n,Hangup()
same => n(login),Set(X=${DB_DELETE(${K})})
same => n,UnpauseQueueMember(${Q},Local/${SP_EXT}@sp-queue-member/n)
same => n,GotoIf($["${UPQMSTATUS}" != "UNPAUSED"]?deny)
same => n,UserEvent(SpToggle,Key: QPAUSE,Queue: ${Q},Ext: ${SP_EXT},Value: 0)
same => n,Playback(agent-loginok)
same => n,Hangup()
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
exten => _*80X.,1,Set(T=${FILTER(0-9,${EXTEN:3})}) ; *80<ext> intercom, both ways
same => n,GotoIf($["${T}" != "${EXTEN:3}"]?deny)
same => n,GotoIf($["${PJSIP_ENDPOINT(${T},context)}" != "sp-from-ext"]?deny) ; desk phones only
same => n,Page(PJSIP/${T},db(sp-sub-autoanswer^s^1),30)
same => n,Hangup()
same => n(deny),Answer()
same => n,Playback(ss-noservice)
same => n,Hangup()
; time condition overrides *31N open, *32N closed, *33N auto (admins only)
exten => _*3[123]X,1,Answer()
same => n,GotoIf($["${GLOBAL(SP_TCADMIN_${SP_EXT})}" != "1"]?deny)
same => n,Goto(sp-tc-override,${EXTEN},1)
same => n(deny),Playback(ss-noservice)
same => n,Hangup()
[sp-vm-direct]
exten => _X.,1,Answer()
same => n,VoiceMail(${EXTEN}@default,u)
same => n,Hangup()
[sp-ext-forward]
exten => _[0-9*#+].,1,Set(SP_CLASS=3) ; admin-configured external destination
same => n,Set(__SP_FORWARDED=1)
same => n,Goto(sp-outbound-routes,${EXTEN},1)
[sp-rg-member] ; one member of a ring group (Local channel)
exten => _X.,1,GotoIf($["${DB(SP/DND/${EXTEN})}" = "1"]?skip)
same => n,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n(skip),Hangup()
[sp-queue-member] ; one queue agent (Local channel)
exten => _X.,1,Gosub(sp-sub-dial-user,s,1(${EXTEN},600))
same => n,Hangup()
[sp-from-ext]
include => sp-emergency-dial
include => sp-featurecodes
include => sp-local-numbers
include => sp-outbound-routes
[sp-local-numbers]
include => sp-parkedcalls
; users (sorted by number)
exten => 101,1,Gosub(sp-sub-user,s,1(101,25,1,never))
same => n,Hangup()
exten => 101,hint,PJSIP/101&PJSIP/101-web
; ─── Office hours overrides (admins only, see sp-featurecodes)
[sp-tc-override]
exten => _*3[123]X,1,Playback(ss-noservice)
same => n,Hangup()
; ─── Calls from trunk "voip.ms"
[sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
exten => s,1,Goto(sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37,unknown,1)
exten => _[+0-9a-zA-Z].,1,Set(__SP_DIRECTION=inbound)
same => n,Set(CDR(SP_DIRECTION)=inbound)
same => n,Gosub(sp-sub-normalize,s,1(${EXTEN}))
same => n,Set(SP_IN=${GOSUB_RETVAL})
same => n,GotoIf($["${SP_IN}" != "" & ${DIALPLAN_EXISTS(sp-did-exists,${SP_IN},1)}]?caller)
; Request-URI didn't hold one of our DIDs: try the To header (06 §3)
same => n,Set(TOU=${CUT(CUT(PJSIP_HEADER(read,To),@,1),:,2)})
same => n,Gosub(sp-sub-normalize,s,1(${TOU}))
same => n,Set(SP_IN=${GOSUB_RETVAL})
same => n(caller),Gosub(sp-sub-normalize,s,1(${CALLERID(num)}))
same => n,Set(CALLERID(num)=${GOSUB_RETVAL})
same => n,GotoIf($["${DB(SP/BLOCK/${CALLERID(num)})}" = "1"]?blocked)
same => n,Goto(sp-inbound,${IF($["${SP_IN}" = ""]?unknown:${SP_IN})},1)
same => n(blocked),Hangup(21)
[sp-inbound]
; anything else
exten => _[0-9a-z].,1,Hangup()
[sp-outbound-routes]
exten => pending,1,Hangup() ; no routes yet
[sp-emergency-dial]
exten => 911,1,Goto(sp-emergency,s,1)
exten => 9911,1,Goto(sp-emergency,s,1) ; people who dial 9 first out of habit
[sp-emergency]
exten => s,1,Set(CID=${IF($["${SP_E911CID}" != ""]?${SP_E911CID}:${SP_DEFAULT_E911_CID})})
same => n,UserEvent(SpEmergency,Ext: ${SP_EXT},CallerId: ${CID},Dialed: 911)
same => n,ExecIf($["${CID}" != ""]?Set(CALLERID(num)=${CID}))
same => n,Set(__SP_DIRECTION=outbound)
same => n,Set(CDR(SP_DIRECTION)=outbound)
same => n,Dial(PJSIP/911@trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37)
same => n,Hangup()
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,154 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[global]
type = global
user_agent = SelfPhoneNG
endpoint_identifier_order = ip,username,anonymous
keep_alive_interval = 90
max_initial_qualify_time = 4
[system]
type = system
timer_t1 = 500
timer_b = 32000
[transport-udp]
type = transport
protocol = udp
bind = 0.0.0.0:5060
[transport-tcp]
type = transport
protocol = tcp
bind = 0.0.0.0:5060
[transport-ws]
type = transport
protocol = ws
bind = 0.0.0.0
; ── extension 101 (Dan Doe) ──
[101]
type = endpoint
context = sp-from-ext
disallow = all
allow = g722,ulaw,alaw
auth = 101-auth
aors = 101
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_pai = yes
deny = 0.0.0.0/0.0.0.0
permit = 192.168.1.0/24
permit = 10.0.0.0/8
permit = 203.0.113.0/24
callerid = "Dan Doe" <101>
mailboxes = 101@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-auth]
type = auth
auth_type = userpass
username = 101
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[101]
type = aor
max_contacts = 5
remove_existing = yes
qualify_frequency = 30
mailboxes = 101@default
[101-web]
type = endpoint
context = sp-from-ext
webrtc = yes
disallow = all
allow = g722,ulaw
auth = 101-web-auth
aors = 101-web
from_domain = selfphoneng.invalid
callerid = "Dan Doe" <101>
mailboxes = 101@default
mwi_subscribe_replaces_unsolicited = yes
dtmf_mode = rfc4733
allow_subscribe = yes
language = en
moh_suggest = default
tone_zone = us
set_var = SP_EXT=101
set_var = SP_CLASS=2
set_var = SP_RECMODE=never
set_var = __SP_DIRECTION=internal
[101-web-auth]
type = auth
auth_type = userpass
username = 101-web
password = Ab3dEf9hIj2kLm4nOp6qRs8t
[101-web]
type = aor
max_contacts = 3
remove_existing = yes
qualify_frequency = 0
; ── trunk "voip.ms" ──
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth]
type = auth
auth_type = userpass
username = 123456_pbx
password = TrunkPw123
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-reg]
type = registration
transport = transport-udp
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
server_uri = sip:chicago1.voip.ms:5060
client_uri = sip:123456_pbx@chicago1.voip.ms:5060
contact_user = 123456_pbx
retry_interval = 60
forbidden_retry_interval = 600
fatal_retry_interval = 600
max_retries = 10000
expiration = 300
line = yes
endpoint = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
type = aor
contact = sip:chicago1.voip.ms:5060
qualify_frequency = 60
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37]
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
aors = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
from_user = 123456_pbx
from_domain = chicago1.voip.ms
direct_media = no
rtp_symmetric = yes
force_rport = yes
rewrite_contact = yes
send_rpid = yes
trust_id_inbound = yes
dtmf_mode = rfc4733
[trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-identify]
type = identify
endpoint = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
match = chicago1.voip.ms
@@ -0,0 +1 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
@@ -0,0 +1,2 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
icesupport = yes
@@ -0,0 +1,27 @@
; GENERATED BY SelfPhoneNG — DO NOT EDIT. Changes will be overwritten.
[general]
format = wav
attach = no ; the api sends emails itself
maxmsg = 100
maxsecs = 300
minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
nextaftercmd = yes
sendvoicemail = no
pollmailboxes = yes
pollfreq = 30
[zonemessages]
local = America/Chicago|'vm-received' Q 'digits/at' IMp
[default]
101 => 4826,Dan Doe,,,tz=local|attach=no
@@ -0,0 +1,25 @@
{
"settings": {
"sip.restrictPhonesToLocalNets": true,
"network.localNets": ["192.168.1.0/24", "10.0.0.0/8"],
"sip.extraAllowedNets": ["203.0.113.0/24", "10.0.0.0/8"]
},
"extensions": [
{
"id": "00000000-0000-4000-8000-000000000101",
"number": "101",
"name": "Dan Doe",
"sipPassword": "Ab3dEf9hIj2kLm4nOp6qRs8t",
"webrtcEnabled": true
}
],
"trunks": [
{
"id": "7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37",
"name": "voip.ms",
"serverHost": "chicago1.voip.ms",
"username": "123456_pbx",
"password": "TrunkPw123"
}
]
}
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
@@ -8,6 +8,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
+1
View File
@@ -28,6 +28,7 @@ describe('golden', () => {
'queue',
'conferences',
'paging',
'phone-acl',
]),
);
});
+18 -2
View File
@@ -68,7 +68,21 @@ export function renderTransports(snap: Snapshot): string {
.join('\n\n');
}
function extensionSections(e: SnapExtension): string {
/**
* Phone ACL (08 §2.2): desk phones may only talk to us from these nets. Source-address rules on
* each desk endpoint, not `contact_acl` (that names acl.conf sections) nor a pjsip `type = acl`
* (global: it would filter trunks too). Browsers (-web) arrive through the reverse proxy: not
* filtered.
*/
function phoneAcl(snap: Snapshot): string[] {
if (!snap.settings['sip.restrictPhonesToLocalNets']) return [];
const nets = [
...new Set([...snap.settings['network.localNets'], ...snap.settings['sip.extraAllowedNets']]),
];
return ['deny = 0.0.0.0/0.0.0.0', ...nets.map((n) => `permit = ${s.cidr(n)}`)];
}
function extensionSections(e: SnapExtension, acl: string[]): string {
const num = s.digits(e.number);
const name = s.name(e.name) || num;
const groups = e.pickupGroup ? s.ident(e.pickupGroup) : null;
@@ -111,6 +125,7 @@ function extensionSections(e: SnapExtension): string {
'force_rport = yes',
'rewrite_contact = yes',
'send_pai = yes',
...acl,
...common,
]),
section(`${num}-auth`, [
@@ -229,7 +244,8 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
}
export function renderPjsip(snap: Snapshot): string {
const exts = [...snap.extensions].sort(byNumber).map(extensionSections);
const acl = phoneAcl(snap);
const exts = [...snap.extensions].sort(byNumber).map((e) => extensionSections(e, acl));
const trunks = snap.trunks
.filter((t) => t.enabled)
.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))
+4
View File
@@ -21,6 +21,10 @@ minsecs = 1
maxsilence = 10
silencethreshold = 128
maxlogins = 3
minpassword = 4
; PINs changed from the phone pass the api's rules first (asterisk/sp-vm-pincheck); the api
; stores them from VoicemailPasswordChange (services/vmpin.ts).
externpasscheck = /usr/local/bin/sp-vm-pincheck
envelope = yes
saycid = yes
review = no
+2
View File
@@ -16,6 +16,7 @@ import { syncAstDb } from './services/astdb.ts';
import { watchToggles } from './services/toggles.ts';
import { watchCdr } from './services/cdr.ts';
import { watchQueueCalls } from './services/queuestats.ts';
import { watchPinChanges } from './services/vmpin.ts';
import { cleanupRecordings } from './routes/recordings.ts';
import { getSetting } from './services/settings.ts';
import { todayIn } from './generator/load.ts';
@@ -62,6 +63,7 @@ app.decorate('sendMail', sendMail);
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami);
watchToggles({ db, ami, live, log: app.log }, ami);
watchQueueCalls({ db, log: app.log }, ami);
watchPinChanges({ db, log: app.log, requestApply: () => generator.requestApply('phone-pin') }, ami);
const history = watchCdr({ db, log: app.log, recordingsDir: resolve(config.RECORDINGS_DIR) }, ami);
const voicemail = watchVoicemail(
{
+40
View File
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T1: the phone-side PIN check (asterisk/sp-vm-pincheck) and the api's rules agree.
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { isTrivialPin } from '../crypto/secrets.ts';
const SCRIPT = fileURLToPath(new URL('../../../../asterisk/sp-vm-pincheck', import.meta.url));
const hasSh = spawnSync('sh', ['-c', 'true']).status === 0;
const script = (box: string, pin: string) =>
spawnSync('sh', [SCRIPT, box, 'default', '0000', pin], { encoding: 'utf8' }).stdout.trim();
const api = (box: string, pin: string) =>
/^\d{4,10}$/.test(pin) && !isTrivialPin(pin, box) ? 'VALID' : 'INVALID';
describe.skipIf(!hasSh)('sp-vm-pincheck', () => {
it('says what the api says', () => {
const pins = [
'4826',
'1111',
'000000',
'1234',
'123456',
'4321',
'9876543210',
'0123456789',
'90123',
'101101',
'1011',
'10110',
'12a4',
'123',
'12345678901',
'',
'5550199',
'2024',
];
for (const box of ['101', '2000', '55'])
for (const pin of pins) expect(script(box, pin), `${box} ${pin}`).toBe(api(box, pin));
});
});
+57
View File
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Voicemail PINs changed from the phone (*97/*98 → mailbox options → change password). Asterisk
// checks the new PIN with sp-vm-pincheck (same rules as the api) and writes it into its own copy
// of the config; VoicemailPasswordChange tells us, and we store it so the next config write keeps
// it instead of putting the old one back. Never logs the PIN.
import type { EventEmitter } from 'node:events';
import { and, eq } from 'drizzle-orm';
import { field, type AmiMessage } from '../ami/events.ts';
import { encrypt, isTrivialPin } from '../crypto/secrets.ts';
import type { Db } from '../db/client.ts';
import { extensions } from '../db/schema.ts';
import { audit } from './audit.ts';
export async function recordPinChange(db: Db, m: AmiMessage): Promise<'saved' | 'refused' | null> {
if (m.Event !== 'VoicemailPasswordChange' || field(m, 'Context') !== 'default') return null;
const box = field(m, 'Mailbox') ?? '';
const pin = field(m, 'NewPassword') ?? '';
const [e] = await db
.select({ id: extensions.id, number: extensions.number })
.from(extensions)
.where(and(eq(extensions.number, box), eq(extensions.voicemailEnabled, true)));
if (!e) return null;
// sp-vm-pincheck refused these already; a second look in case the script is missing.
if (!/^\d{4,10}$/.test(pin) || isTrivialPin(pin, e.number)) {
await audit(db, {
action: 'update',
entityType: 'extension',
entityId: e.id,
summary: `Ignored a weak voicemail PIN set from phone ${e.number}; the old one stays`,
});
return 'refused';
}
await db
.update(extensions)
.set({ voicemailPinEnc: encrypt(pin) })
.where(eq(extensions.id, e.id));
await audit(db, {
action: 'update',
entityType: 'extension',
entityId: e.id,
summary: `Voicemail PIN of ${e.number} changed from the phone`,
});
return 'saved';
}
export function watchPinChanges(
d: { db: Db; log: { error(o: object, msg: string): void }; requestApply: () => void },
ami: Pick<EventEmitter, 'on'>,
) {
ami.on('event', (m: AmiMessage) => {
if (m.Event !== 'VoicemailPasswordChange') return;
recordPinChange(d.db, m)
// Either way, rewrite the config from the database: the new PIN, or the old one back.
.then((r) => r && d.requestApply())
.catch((err: unknown) => d.log.error({ err }, 'voicemail PIN change failed'));
});
}
+12
View File
@@ -148,4 +148,16 @@ describe.skipIf(!hasTestDb)('auth', () => {
expect((await get('/api/nope')).statusCode).toBe(404);
expect((await get('/prov/x.cfg')).statusCode).toBe(404);
});
it('M11-T1: security headers on every response (08 §2.4)', async () => {
for (const url of ['/admin/users', '/api/nope', '/healthz']) {
const h = (await get(url)).headers;
expect(h['content-security-policy'], url).toBe(
"default-src 'self'; connect-src 'self' wss://pbx.test; media-src 'self' blob:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'",
);
expect(h['x-content-type-options']).toBe('nosniff');
expect(h['referrer-policy']).toBe('same-origin');
expect(h['permissions-policy']).toBe('microphone=(self), camera=()');
}
});
});