security: review fixes (dialed-number filter, browser endpoint ACL, IVR direct dial, …)

The 08 §1 threat table checked line by line against the code, plus a review by a separate agent
that hadn't written it. Findings were reproduced with a failing test before each fix:

- Critical: "011…,4" turned the route's Goto into Goto(sp-route-…,011…,4,1), starting past the
  class check, so any extension could call abroad. FILTER on the dialed number (also stops "&"
  adding a Dial target). trunk-test.ts proves it.
- High: -web endpoints (desk phone's password, any transport) skipped the phone ACL; now
  127.0.0.1 only when it's on. call-test.ts: UDP refused, WebSocket from 127.0.0.1 registers.
- IVR direct dial no longer reaches paging groups or parked calls (sp-ivr-direct).
- Trunks identify_by = ip: a From of trunk-<id> from anywhere no longer rings phones.
- .dockerignore is an allowlist, so the public source archive can't pick up stray files.
- CSRF check on the matched route (/%61pi/… got around it); Linphone tokens masked in logs;
  /api/live checks Origin and re-checks the session; *45/** filter digits; pg_dump password in
  the environment; more webhook ranges blocked.

The review and what's left for the owner are in 08 §6.

Task: M11-T8
This commit is contained in:
iamdoubz committed 2026-10-20 12:19:47 -05:00
1 parent 0ff0f9292b
commit 5c46bd0e85
46 files changed
+555 -87

No files matched your search

+14 -1
View File
@@ -54,6 +54,8 @@ declare module 'fastify' {
// Paths the SPA must never swallow (04 §1 step 5).
const NON_SPA = /^\/(api|prov|hooks)(\/|$)/;
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/** A Linphone setup link's token is the credential (07 §5): keep it out of the request log. */
export const logUrl = (url: string) => url.replace(/^(\/prov\/linphone\/)[^/?]+/i, '$1…');
export function buildApp(config: Config, db: Db, publicDir = resolve('public')) {
const app = Fastify({
@@ -69,6 +71,16 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
'req.headers.authorization',
'req.headers.cookie',
],
serializers: {
// Fastify's own req serializer, with the URL masked (M11-T8).
req: (req) => ({
method: req.method,
url: logUrl(req.url),
host: req.host,
remoteAddress: req.ip,
remotePort: req.socket?.remotePort,
}),
},
},
});
@@ -85,7 +97,8 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
// and a cross-site page can't set Authorization either.
app.addHook('onRequest', async (req, reply) => {
if (
req.url.startsWith('/api/') &&
// The matched route, not req.url: the router decodes "/%61pi/…" to /api/… (M11-T8).
(req.routeOptions.url ?? req.url).startsWith('/api/') &&
!SAFE_METHODS.has(req.method) &&
!req.headers.authorization?.startsWith('Bearer ') &&
req.headers['x-requested-with'] !== 'selfphoneng'
+6 -3
View File
@@ -44,8 +44,11 @@ export async function createSession(
return token;
}
/** Resolves a cookie token to its active user, sliding the expiry when under 7 days are left. */
export async function getSessionUser(db: Db, token: string) {
/**
* Resolves a cookie token to its active user, sliding the expiry when under 7 days are left
* (not for the live socket's re-checks: an open dashboard alone doesn't keep a session going).
*/
export async function getSessionUser(db: Db, token: string, slide = true) {
const id = sha256(token);
const [row] = await db
.select({ expiresAt: sessions.expiresAt, user: users })
@@ -54,7 +57,7 @@ export async function getSessionUser(db: Db, token: string) {
.where(and(eq(sessions.id, id), gt(sessions.expiresAt, new Date()), eq(users.isActive, true)));
if (!row) return null;
let extended = false;
if (row.expiresAt.getTime() - Date.now() < EXTEND_BELOW) {
if (slide && row.expiresAt.getTime() - Date.now() < EXTEND_BELOW) {
await db
.update(sessions)
.set({ expiresAt: new Date(Date.now() + LIFETIME) })
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -130,6 +130,7 @@ qualify_frequency = 60
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
identify_by = ip
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -130,6 +130,7 @@ qualify_frequency = 60
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
identify_by = ip
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -217,7 +218,7 @@ exten => s,1,Set(__SP_TARGET=500)
; ─── Digital receptionist 600 "Main menu"
[sp-ivr-33333333-3333-4333-8333-333333333600]
include => sp-local-numbers
include => sp-ivr-direct
exten => s,1,Answer()
same => n,Set(SP_IVR_TRIES=0)
same => n(menu),Set(TIMEOUT(digit)=3)
@@ -235,6 +236,13 @@ exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
same => n,Goto(s,menu)
same => n(giveup),Goto(sp-rg-22222222-2222-4222-8222-222222222500,s,1)
[sp-ivr-direct]
exten => 101,1,Goto(sp-local-numbers,101,1)
exten => 102,1,Goto(sp-local-numbers,102,1)
exten => 103,1,Goto(sp-local-numbers,103,1)
exten => 500,1,Goto(sp-local-numbers,500,1)
exten => 600,1,Goto(sp-local-numbers,600,1)
; ─── Office hours "Business hours"
[sp-tc-55555555-5555-4555-8555-555555555001]
exten => s,1,Set(OVR=${DB(SP/TCOVR/55555555-5555-4555-8555-555555555001)})
@@ -276,6 +276,7 @@ qualify_frequency = 60
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
identify_by = ip
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -215,7 +216,7 @@ exten => s,1,Set(__SP_TARGET=500)
; ─── Digital receptionist 600 "Main menu"
[sp-ivr-33333333-3333-4333-8333-333333333600]
include => sp-local-numbers
include => sp-ivr-direct
exten => s,1,Answer()
same => n,Set(SP_IVR_TRIES=0)
same => n(menu),Set(TIMEOUT(digit)=3)
@@ -254,6 +255,13 @@ exten => t,1,Set(SP_IVR_TRIES=$[${SP_IVR_TRIES} + 1])
same => n,Goto(s,menu)
same => n(giveup),Goto(sp-ivr-33333333-3333-4333-8333-333333333600,s,1)
[sp-ivr-direct]
exten => 101,1,Goto(sp-local-numbers,101,1)
exten => 102,1,Goto(sp-local-numbers,102,1)
exten => 500,1,Goto(sp-local-numbers,500,1)
exten => 600,1,Goto(sp-local-numbers,600,1)
exten => 601,1,Goto(sp-local-numbers,601,1)
; ─── Office hours overrides (admins only, see sp-featurecodes)
[sp-tc-override]
exten => _*3[123]X,1,Playback(ss-noservice)
@@ -203,6 +203,7 @@ qualify_frequency = 60
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
identify_by = ip
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -78,6 +78,8 @@ allow = g722,ulaw
auth = 101-web-auth
aors = 101-web
from_domain = selfphoneng.invalid
deny = 0.0.0.0/0.0.0.0
permit = 127.0.0.1/255.255.255.255
callerid = "Dan Doe" <101>
mailboxes = 101@default
mwi_subscribe_replaces_unsolicited = yes
@@ -134,6 +136,7 @@ qualify_frequency = 60
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
identify_by = ip
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -276,6 +276,7 @@ qualify_frequency = 60
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
identify_by = ip
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -242,6 +242,7 @@ qualify_frequency = 60
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
identify_by = ip
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -260,7 +261,7 @@ include => sp-or-bbbbbbbb-0000-4000-8000-000000000001 ; First
include => sp-or-bbbbbbbb-0000-4000-8000-000000000002 ; Second evil
[sp-or-bbbbbbbb-0000-4000-8000-000000000001]
exten => _1[2-9]XXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000001,${EXTEN:0},1)
exten => _1[2-9]XXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000001,${FILTER(0-9*#+,${EXTEN:0})},1)
[sp-route-bbbbbbbb-0000-4000-8000-000000000001]
exten => _X.,1,Set(__SP_DIRECTION=outbound)
@@ -279,7 +280,7 @@ exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,Hangup()
[sp-or-bbbbbbbb-0000-4000-8000-000000000002]
exten => _9NXXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000002,1${EXTEN:1},1)
exten => _9NXXNXXXXXX,1,Goto(sp-route-bbbbbbbb-0000-4000-8000-000000000002,1${FILTER(0-9*#+,${EXTEN:1})},1)
[sp-route-bbbbbbbb-0000-4000-8000-000000000002]
exten => _X.,1,Set(__SP_DIRECTION=outbound)
@@ -46,6 +46,7 @@ qualify_frequency = 60
type = endpoint
transport = transport-udp
context = sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000001
identify_by = ip
disallow = all
allow = alaw,ulaw
aors = trunk-a1b2c3d4-0000-4000-8000-000000000001
@@ -94,6 +95,7 @@ qualify_frequency = 60
type = endpoint
transport = transport-tcp
context = sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000002
identify_by = ip
disallow = all
allow = g722,ulaw
outbound_auth = trunk-a1b2c3d4-0000-4000-8000-000000000002-auth
@@ -144,6 +146,7 @@ qualify_frequency = 0
type = endpoint
transport = transport-tls
context = sp-from-trunk-a1b2c3d4-0000-4000-8000-000000000004
identify_by = ip
disallow = all
allow = ulaw
outbound_auth = trunk-a1b2c3d4-0000-4000-8000-000000000004-auth
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
@@ -243,13 +244,13 @@ include => sp-or-aaaaaaaa-0000-4000-8000-000000000002 ; Toll-free
include => sp-or-aaaaaaaa-0000-4000-8000-000000000001 ; Local/National NANP
[sp-or-aaaaaaaa-0000-4000-8000-000000000002]
exten => _1800NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1888NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1877NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1866NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1855NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1844NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1833NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${EXTEN:0},1)
exten => _1800NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
exten => _1888NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
exten => _1877NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
exten => _1866NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
exten => _1855NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
exten => _1844NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
exten => _1833NXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000002,${FILTER(0-9*#+,${EXTEN:0})},1)
[sp-route-aaaaaaaa-0000-4000-8000-000000000002]
exten => _X.,1,Set(__SP_DIRECTION=outbound)
@@ -272,8 +273,8 @@ exten => _X.,1,Set(__SP_DIRECTION=outbound)
same => n,Hangup()
[sp-or-aaaaaaaa-0000-4000-8000-000000000001]
exten => _NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,1${EXTEN:0},1)
exten => _1NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,${EXTEN:0},1)
exten => _NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,1${FILTER(0-9*#+,${EXTEN:0})},1)
exten => _1NXXNXXXXXX,1,Goto(sp-route-aaaaaaaa-0000-4000-8000-000000000001,${FILTER(0-9*#+,${EXTEN:0})},1)
[sp-route-aaaaaaaa-0000-4000-8000-000000000001]
exten => _X.,1,Set(__SP_DIRECTION=outbound)
@@ -130,6 +130,7 @@ qualify_frequency = 60
type = endpoint
transport = transport-udp
context = sp-from-trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37
identify_by = ip
disallow = all
allow = ulaw
outbound_auth = trunk-7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37-auth
@@ -115,7 +115,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=${FILTER(0-9,${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/${T}-&PJSIP/${T}-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.
@@ -130,7 +131,7 @@ 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(Q=${FILTER(0-9,${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)
+1 -1
View File
@@ -23,7 +23,7 @@ export function renderIvrs(snap: Snapshot): string {
return [
`; ─── Digital receptionist ${s.digits(r.number)} "${s.name(r.name)}"`,
`[sp-ivr-${s.ident(r.id)}]`,
...(r.allowDirectDial ? ['include => sp-local-numbers'] : []),
...(r.allowDirectDial ? ['include => sp-ivr-direct'] : []),
'exten => s,1,Answer()',
' same => n,Set(SP_IVR_TRIES=0)',
' same => n(menu),Set(TIMEOUT(digit)=3)',
+23
View File
@@ -39,6 +39,29 @@ export function renderLocalNumbers(snap: Snapshot): string {
return lines.join('\n') + '\n';
}
/**
* sp-ivr-direct (03 §6.9): what a caller in a digital receptionist may dial. Not paging groups
* (an outside caller on the office speakers) and not parked calls (someone else's call), M11-T8.
*/
export function renderIvrDirect(snap: Snapshot): string {
if (!snap.ivrs.some((r) => r.allowDirectDial)) return '';
const nums = [
...snap.extensions,
...snap.ringGroups,
...snap.queues,
...snap.conferences,
...snap.ivrs,
].sort(byNumber);
return (
[
'[sp-ivr-direct]',
...nums
.map((x) => s.digits(x.number))
.map((n) => `exten => ${n},1,Goto(sp-local-numbers,${n},1)`),
].join('\n') + '\n'
);
}
export function renderGlobals(snap: Snapshot): string {
const st = snap.settings;
const didNumber = (id: string | null) => {
+5 -1
View File
@@ -27,7 +27,11 @@ export function renderOutbound(snap: Snapshot): string {
seen.add(match);
const strip = s.digits(String(p.strip));
const prepend = p.prepend ? s.dialable(p.prepend) : '';
pat.push(`exten => _${match},1,Goto(sp-route-${id},${prepend}\${EXTEN:${strip}},1)`);
// FILTER: SIP allows "," in a dialed number, and Goto(sp-route-…,011…,4,1) would start
// past the class check (M11-T8). "&" would add a second Dial target.
pat.push(
`exten => _${match},1,Goto(sp-route-${id},${prepend}\${FILTER(0-9*#+,\${EXTEN:${strip}})},1)`,
);
}
const route = [
+3 -2
View File
@@ -118,7 +118,8 @@ exten => *73,1,Answer() ; cancel forward alw
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
exten => _**X.,1,Set(T=\${FILTER(0-9,\${EXTEN:2})}) ; digits only: "&" or "," would add arguments
same => n,PickupChan(PJSIP/\${T}-&PJSIP/\${T}-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.
@@ -133,7 +134,7 @@ 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(Q=\${FILTER(0-9,\${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)
+2 -1
View File
@@ -13,7 +13,7 @@ import { resendPauses } from '../services/queues.ts';
import { loadSnapshot, type Snapshot } from './load.ts';
import { renderPjsip, renderRtp, renderTransports } from './pjsip.ts';
import { STATIC_DIALPLAN } from './dialplan/static.ts';
import { renderGlobals, renderLocalNumbers } from './dialplan/local.ts';
import { renderGlobals, renderIvrDirect, renderLocalNumbers } from './dialplan/local.ts';
import { renderDids, renderTrunkEntries } from './dialplan/inbound.ts';
import { renderOutbound } from './dialplan/outbound.ts';
import { renderEmergency } from './dialplan/emergency.ts';
@@ -66,6 +66,7 @@ const BODIES: Partial<Record<GeneratedFileName, (snap: Snapshot) => string>> = {
renderConferences(snap),
renderPaging(snap),
renderIvrs(snap),
renderIvrDirect(snap),
renderTimeConditions(snap),
renderAnnouncements(snap),
renderTrunkEntries(snap),
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T8: a caller in a digital receptionist with direct dial on reaches people, groups, queues,
// rooms and other menus, but not paging groups (the office speakers) or parked calls.
import { describe, expect, it } from 'vitest';
import { loadCase } from './golden-case.ts';
import { render } from './index.ts';
const context = (conf: string, name: string) => {
const start = conf.indexOf(`[${name}]\n`);
const end = conf.indexOf('\n[', start + 1);
return conf.slice(start, end === -1 ? undefined : end);
};
describe('IVR direct dial', () => {
const snap = loadCase('ivr');
snap.pagingGroups = loadCase('paging').pagingGroups;
const conf = render(snap)['extensions_selfphoneng.conf'];
const direct = context(conf, 'sp-ivr-direct');
const page = snap.pagingGroups[0]!.number;
it('the menu includes sp-ivr-direct, not sp-local-numbers', () => {
expect(conf).toContain('include => sp-ivr-direct');
expect(conf).not.toMatch(/\[sp-ivr-[0-9a-f-]+\]\ninclude => sp-local-numbers/);
});
it('lists users, ring groups and menus, but no paging group or parking lot', () => {
for (const n of [...snap.extensions, ...snap.ringGroups, ...snap.ivrs].map((x) => x.number))
expect(direct).toContain(`exten => ${n},1,Goto(sp-local-numbers,${n},1)`);
expect(context(conf, 'sp-local-numbers')).toContain(`exten => ${page},1,`);
expect(direct).not.toContain(`exten => ${page},`);
expect(direct).not.toContain('parkedcalls');
});
});
+14 -5
View File
@@ -77,15 +77,20 @@ export function renderTransports(snap: Snapshot): string {
* (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 [];
function phoneAcl(snap: Snapshot): { desk: string[]; web: string[] } {
if (!snap.settings['sip.restrictPhonesToLocalNets']) return { desk: [], web: [] };
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)}`)];
return {
desk: ['deny = 0.0.0.0/0.0.0.0', ...nets.map((n) => `permit = ${s.cidr(n)}`)],
// M11-T8: -web has the desk phone's password and pjsip takes it on any transport, so
// without this it was a way around the ACL. Asterisk's HTTP server only listens on 127.0.0.1.
web: ['deny = 0.0.0.0/0.0.0.0', 'permit = 127.0.0.1/255.255.255.255'],
};
}
function extensionSections(e: SnapExtension, acl: string[]): string {
function extensionSections(e: SnapExtension, acl: { desk: string[]; web: string[] }): string {
const num = s.digits(e.number);
const name = s.name(e.name) || num;
const groups = e.pickupGroup ? s.ident(e.pickupGroup) : null;
@@ -128,7 +133,7 @@ function extensionSections(e: SnapExtension, acl: string[]): string {
'force_rport = yes',
'rewrite_contact = yes',
'send_pai = yes',
...acl,
...acl.desk,
...common,
]),
section(`${num}-auth`, [
@@ -158,6 +163,7 @@ function extensionSections(e: SnapExtension, acl: string[]): string {
// Otherwise the From domain is the machine's hostname, and SIP.js drops INVITEs whose host
// label starts with a digit (e.g. a Docker or VPS hostname). The browser ignores the domain.
'from_domain = selfphoneng.invalid',
...acl.web,
...common,
]),
section(`${num}-web-auth`, [
@@ -230,6 +236,9 @@ function trunkSections(t: SnapTrunk, snap: Snapshot): string {
'type = endpoint',
`transport = ${transport}`,
`context = sp-from-trunk-${id}`,
// No inbound auth on a trunk: without this, a From of trunk-<id> from anywhere would match
// it by username and skip the password (M11-T8). Its calls come from its server's address.
'identify_by = ip',
'disallow = all',
`allow = ${t.codecs.map(s.codec).join(',')}`,
reg && `outbound_auth = trunk-${id}-auth`,
+20
View File
@@ -10,9 +10,13 @@ import type { LiveState } from './state.ts';
const BATCH_MS = 250;
const PING_MS = 30_000;
/** How often each socket's session is checked again. Tests shorten it. */
export const timing = { recheckMs: 30_000 };
type Client = {
ws: WebSocket;
/** The session cookie: a logout, deactivation or role change closes the socket (M11-T8). */
token: string;
admin: boolean;
userId: string;
/** The user's extension numbers: they see the queues they're an agent of. */
@@ -94,13 +98,28 @@ export async function liveRoutes(app: FastifyInstance) {
}
}, PING_MS);
// A logout, deactivation or role change reaches open sockets within recheckMs (M11-T8).
const recheck = setInterval(() => {
for (const c of clients)
void getSessionUser(app.db, c.token, false)
.then((f) => {
if (!f || f.user.mustChangePassword || (f.user.role === 'admin') !== c.admin)
c.ws.close(4401, 'unauthorized');
})
.catch(() => {}); // database down: keep the socket, check again next time
}, timing.recheckMs);
app.addHook('onClose', async () => {
clearInterval(flush);
clearInterval(ping);
clearInterval(recheck);
for (const c of clients) c.ws.close(1001, 'server shutting down');
});
app.get('/api/live', { websocket: true }, async (ws, req) => {
// Cross-site WebSocket hijacking: browsers always send Origin; other clients have none.
const origin = req.headers.origin;
if (origin && URL.parse(origin)?.host !== req.headers.host) return ws.close(4403, 'forbidden');
const token = req.cookies[COOKIE];
const found = token ? await getSessionUser(app.db, token) : null;
// Browsers can't read an HTTP 401 on an upgrade, so open and close with 4401 (04 §5.2).
@@ -111,6 +130,7 @@ export async function liveRoutes(app: FastifyInstance) {
.where(eq(extensions.userId, found.user.id));
const c: Client = {
ws,
token: token!,
admin: found.user.role === 'admin',
userId: found.user.id,
exts: mine.map((e) => e.n),
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T8: a Linphone setup token is a credential, so the request log never shows it.
import { describe, expect, it } from 'vitest';
import { logUrl } from './app.ts';
describe('logUrl', () => {
it('masks the Linphone token and leaves other URLs alone', () => {
expect(logUrl('/prov/linphone/AbC_123-xyzxyzxyzxyzxyz.xml')).toBe('/prov/linphone/…');
expect(logUrl('/PROV/linphone/secret.xml?x=1')).toBe('/PROV/linphone/…?x=1');
expect(logUrl('/prov/001565123456.cfg')).toBe('/prov/001565123456.cfg');
expect(logUrl('/api/calls?from=2026-10-01')).toBe('/api/calls?from=2026-10-01');
});
});
+7 -7
View File
@@ -74,13 +74,13 @@ export async function createBackup(
const work = await mkdtemp(join(tmpdir(), 'sp-backup-'));
const tmp = join(src.outDir, `.${name}.tmp`);
try {
await run('pg_dump', [
'--no-owner',
'--dbname',
src.databaseUrl,
'--file',
join(work, 'db.sql'),
]);
// The password goes in the environment: `ps` on the host shows a container's arguments (M11-T8).
const db = new URL(src.databaseUrl);
const password = decodeURIComponent(db.password);
db.password = '';
await run('pg_dump', ['--no-owner', '--dbname', db.href, '--file', join(work, 'db.sql')], {
env: { ...process.env, PGPASSWORD: password },
});
const included: string[] = [];
const args = ['-czf', tmp, '-C', work, 'db.sql', 'manifest.json'];
for (const [as, dir] of Object.entries(src.dirs)) {
+4
View File
@@ -22,12 +22,16 @@ for (const [net, bits] of [
['169.254.0.0', 16],
['172.16.0.0', 12],
['192.168.0.0', 16],
['192.0.0.0', 24], // IETF protocol assignments (M11-T8)
['198.18.0.0', 15], // benchmarking, sometimes used inside networks
['224.0.0.0', 3], // multicast and reserved
] as const)
blocked.addSubnet(net, bits, 'ipv4');
for (const [net, bits] of [
['::', 127], // :: and ::1 (IPv4-mapped addresses match the IPv4 rules)
['fc00::', 7],
['64:ff9b::', 96], // NAT64: the low 32 bits are an IPv4 address, possibly a private one
['2002::', 16], // 6to4: the same
['fe80::', 10],
['ff00::', 8],
] as const)
+31 -2
View File
@@ -39,12 +39,15 @@ function snapshot(): Snapshot {
.filter((k) => k !== 'sms.webhookToken')
.map((k) => [k, SettingsSchemas[k].parse(undefined)]),
) as Snapshot['settings'];
// Phone ACL on, the Docker networks allowed: the desk phones register, a "-web" login doesn't.
settings['sip.restrictPhonesToLocalNets'] = true;
settings['network.localNets'] = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
const ext = (number: '101' | '102', name: string) => ({
id: `00000000-0000-4000-8000-000000000${number}`,
number,
name,
sipPassword: PASS[number],
webrtcEnabled: false,
webrtcEnabled: number === '102',
maxContacts: 5,
ringSeconds: 25,
voicemailEnabled: false,
@@ -104,7 +107,12 @@ function phoneConfig(dir: string, ext: '101' | '102', port: number, toneHz: numb
);
writeFileSync(
join(d, 'accounts'),
`<sip:${ext}@${AST};transport=udp>;auth_pass=${PASS[ext]};answermode=auto;audio_codecs=PCMU;regint=60\n`,
`<sip:${ext}@${AST};transport=udp>;auth_pass=${PASS[ext]};answermode=auto;audio_codecs=PCMU;regint=60\n` +
// M11-T8: the desk phone's password also opens 102-web; over UDP from a phone's network
// that must fail (browsers only come through the reverse proxy on 127.0.0.1).
(ext === '102'
? `<sip:102-web@${AST};transport=udp>;auth_pass=${PASS[ext]};answermode=manual;regint=60\n`
: ''),
);
}
@@ -207,6 +215,27 @@ async function main() {
if (!/101\/sip:.*Avail/.test(contacts) || !/102\/sip:.*Avail/.test(contacts))
throw new Error(`phones did not register:\n${contacts}`);
log('101 and 102 registered');
await sleep(3000);
if (/102-web\/sip:/.test(await ami.command('pjsip show contacts')))
problems.push('102-web registered over UDP from the phones network with the ACL on');
else log('102-web over UDP: refused (phone ACL covers browser endpoints)');
// ...while a browser through the reverse proxy (WebSocket from 127.0.0.1) still registers.
const wsReg = docker(
'run',
'--rm',
'--network',
`container:${AST}`,
'-v',
`${join(import.meta.dirname, 'ws-register.mjs')}:/ws-register.mjs:ro`,
'node:24-trixie-slim',
'node',
'/ws-register.mjs',
'ws://127.0.0.1:8088/ws',
'102-web',
PASS['102'],
).out.trim();
if (!wsReg.startsWith('SIP/2.0 200')) problems.push(`102-web over WebSocket: ${wsReg}`);
else log('102-web over WebSocket from 127.0.0.1: registered');
// Real list responses with registered phones, for the live-state fixture.
recorder?.write(
'Action: PJSIPShowContacts\r\nActionID: rec-2\r\n\r\n' +
+60 -2
View File
@@ -31,6 +31,7 @@ const GOOD = '7d3e9a52-1c4b-4f6e-9a0d-2b8c5e1f4a37';
const BAD = '0b9f6c21-8e4d-4a7b-b3c5-d1e2f3a4b5c6';
const SUB = { user: '123456_pbx', pass: 'ProviderSecret42' };
const PHONES = 'sp-trunk-phones';
const ATTACKER = 'sp-trunk-attacker';
const EXT_ID = '00000000-0000-4000-8000-000000000101';
const PHONE_PASS = 'TrunkTest101xxxxxxxxxxxx';
const DID = '13125551234';
@@ -127,6 +128,16 @@ function snapshot(): Snapshot {
trunkIds: [GOOD],
callerId: null,
},
{
id: 'aaaaaaaa-0000-4000-8000-000000000002',
name: 'International',
position: 1,
enabled: true,
patterns: [{ match: '011.', strip: 0, prepend: '' }],
requiredClass: 'international',
trunkIds: [GOOD],
callerId: null,
},
],
};
}
@@ -246,7 +257,7 @@ async function main() {
);
try {
docker('rm', '-f', PBX, PROVIDER, PHONES);
docker('rm', '-f', PBX, PROVIDER, PHONES, ATTACKER);
docker('network', 'create', NET);
const prov = asterisk(PROVIDER, provConf, []);
if (!prov.ok) throw new Error(`provider: ${prov.out}`);
@@ -387,6 +398,24 @@ async function main() {
log('class check: ss-noservice');
await ami.command('channel request hangup all');
// M11-T8: SIP allows "," in a dialed number. Unfiltered, Goto(sp-route-…,${EXTEN},1) became
// Goto(sp-route-…,011…,4,1) and started past the class check: international for anyone.
await sleep(1000);
await ami.command('channel originate Local/01144207946095,4@sp-from-ext application Wait 10');
await waitFor(
'ss-noservice for 01144207946095,4',
async () => /Playback!ss-noservice/.test(await ami.command('core show channels concise')),
10,
).catch((e: Error) => problems.push(e.message));
if (
/LAST_IN=to=011/.test(
docker('exec', PROVIDER, 'asterisk', '-rx', 'dialplan show globals').out,
)
)
problems.push('a comma in the dialed number skipped the class check: 011… reached the trunk');
else log('class check: a comma in the number does not skip it');
await ami.command('channel request hangup all');
// M3-T4: 911 from a caller with no class at all still goes out, with the E911 caller ID, and
// the dialplan tells the api. (The fake provider answers it; nothing real is dialed.)
const userEvents: Record<string, string | string[]>[] = [];
@@ -437,6 +466,35 @@ async function main() {
log('911: out with the E911 caller ID, SpEmergency raised, alert plays on 101');
await ami.command('channel request hangup all');
// M11-T8: a trunk has no inbound password, so only its server's address may match it. Before
// identify_by = ip, an INVITE from anywhere with From: trunk-<id> matched it by username.
const atkConf = join(dir, 'attacker');
mkdirSync(atkConf);
for (const f of GENERATED_FILES)
writeFileSync(
join(atkConf, f),
f === 'pjsip_selfphoneng.conf'
? `[transport-udp]\ntype = transport\nprotocol = udp\nbind = 0.0.0.0:5060\n\n` +
`[pbx]\ntype = endpoint\ncontext = default\ndisallow = all\nallow = ulaw\naors = pbx\n` +
`from_user = trunk-${GOOD}\n\n[pbx]\ntype = aor\ncontact = sip:${PBX}:5060\n`
: '',
);
if (!asterisk(ATTACKER, atkConf, []).ok) throw new Error('attacker container');
await booted(ATTACKER);
dials.length = 0;
docker(
'exec',
ATTACKER,
'asterisk',
'-rx',
`channel originate PJSIP/${DID}@pbx application Wait 5`,
);
await sleep(6000);
if (dials.some((m) => String(m.DestChannel).startsWith('PJSIP/101-')))
problems.push(`an INVITE claiming From: trunk-${GOOD} from another address rang 101`);
else log('a From of trunk-<id> from another address is not taken for the trunk');
await ami.command('channel request hangup all');
// eslint-disable-next-line no-control-regex -- strips Asterisk's ANSI colors
const plainLog = docker('logs', PBX).out.replace(/\x1b\[[0-9;]*m/g, '');
// An RTCP report sent while a test container's network is going away fails with EPERM;
@@ -454,7 +512,7 @@ async function main() {
}
recorder?.destroy();
ami.stop();
docker('rm', '-f', PBX, PROVIDER, PHONES);
docker('rm', '-f', PBX, PROVIDER, PHONES, ATTACKER);
docker('network', 'rm', NET);
rmSync(dir, { recursive: true, force: true });
}
+66
View File
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// REGISTER over SIP WebSocket with digest auth, like the browser softphone through the reverse
// proxy. call-test.ts runs it in Asterisk's network namespace, so it comes from 127.0.0.1.
// Usage: node ws-register.mjs <ws url> <user> <password>. Prints the final status line.
import { createHash, randomUUID } from 'node:crypto';
const [url, user, pass] = process.argv.slice(2);
const md5 = (s) => createHash('md5').update(s).digest('hex');
const host = new URL(url).hostname;
const uri = `sip:${host}`;
const callId = randomUUID();
const tag = randomUUID().slice(0, 8);
const register = (cseq, auth) =>
[
`REGISTER ${uri} SIP/2.0`,
`Via: SIP/2.0/WS ws-test.invalid;branch=z9hG4bK${randomUUID().slice(0, 12)}`,
'Max-Forwards: 70',
`From: <sip:${user}@${host}>;tag=${tag}`,
`To: <sip:${user}@${host}>`,
`Call-ID: ${callId}`,
`CSeq: ${cseq} REGISTER`,
`Contact: <sip:${user}@ws-test.invalid;transport=ws>`,
'Expires: 60',
...(auth ? [`Authorization: ${auth}`] : []),
'Content-Length: 0',
'',
'',
].join('\r\n');
const ws = new WebSocket(url, 'sip');
const timer = setTimeout(() => {
console.log('TIMEOUT');
process.exit(1);
}, 10_000);
ws.onopen = () => ws.send(register(1));
ws.onerror = (e) => {
console.log(`ERROR ${e.message ?? e}`);
process.exit(1);
};
ws.onmessage = ({ data }) => {
const msg = String(data);
const status = msg.split('\r\n')[0];
const challenge = /WWW-Authenticate: Digest (.*)/i.exec(msg)?.[1];
if (status.includes(' 401 ') && challenge) {
const p = Object.fromEntries(
[...challenge.matchAll(/(\w+)="?([^",]*)"?/g)].map((m) => [m[1], m[2]]),
);
const cnonce = randomUUID().slice(0, 8);
const ha1 = md5(`${user}:${p.realm}:${pass}`);
const ha2 = md5(`REGISTER:${uri}`);
const response = md5(`${ha1}:${p.nonce}:00000001:${cnonce}:auth:${ha2}`);
ws.send(
register(
2,
`Digest username="${user}", realm="${p.realm}", nonce="${p.nonce}", uri="${uri}", ` +
`response="${response}", algorithm=MD5, qop=auth, nc=00000001, cnonce="${cnonce}"` +
(p.opaque ? `, opaque="${p.opaque}"` : ''),
),
);
return;
}
console.log(status);
clearTimeout(timer);
ws.close();
};
+12
View File
@@ -73,6 +73,18 @@ describe.skipIf(!hasTestDb)('auth', () => {
expect(r.json().error.code).toBe('csrf');
});
it('M11-T8: a percent-encoded path gets the same CSRF check', async () => {
for (const url of ['/%61pi/auth/login', '/api%2Fauth/login', '//api/auth/login']) {
const r = await t.app.inject({
method: 'POST',
url,
payload: { email: ADMIN.email, password: adminPassword },
});
expect([403, 404], url).toContain(r.statusCode);
if (r.statusCode === 403) expect(r.json().error.code).toBe('csrf');
}
});
it('must_change_password blocks everything outside /api/auth/* until changed', async () => {
const cookie = cookieOf(await login(ADMIN.email, adminPassword));
const blocked = await get('/api/admin-probe', cookie);
+4
View File
@@ -27,6 +27,10 @@ it('isPrivateAddress', () => {
'fe80::1',
'fd00::1',
'::ffff:7f00:1',
'198.18.0.1',
'192.0.0.8',
'64:ff9b::a00:1',
'2002:7f00:1::1',
])
expect(isPrivateAddress(ip), ip).toBe(true);
for (const ip of ['8.8.8.8', '203.0.113.9', '2001:4860:4860::8888', '::ffff:8.8.8.8'])
+51 -1
View File
@@ -8,7 +8,9 @@ import type { LiveMessage } from '@selfphoneng/shared';
import { AmiParser, field, type AmiMessage } from '../src/ami/events.ts';
import { encrypt, setSecretKey } from '../src/crypto/secrets.ts';
import type { Db } from '../src/db/client.ts';
import { extensions, userRuntimeState } from '../src/db/schema.ts';
import { eq } from 'drizzle-orm';
import { extensions, sessions, userRuntimeState, users } from '../src/db/schema.ts';
import { timing } from '../src/live/hub.ts';
import { LiveState, extOfEndpoint, stateFromStatus } from '../src/live/state.ts';
import { createTestDb, hasTestDb } from './db.ts';
import { ADMIN, createTestApp } from './app.ts';
@@ -252,3 +254,51 @@ describe.skipIf(!hasTestDb)('/api/live WebSocket', () => {
for (const c of [admin, me]) c.ws.terminate();
});
});
// M11-T8: a socket from another site is refused; a logout, deactivation or demotion closes it.
describe.skipIf(!hasTestDb)('/api/live WebSocket re-checks', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
beforeAll(async () => {
timing.recheckMs = 200;
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
});
afterAll(() => {
timing.recheckMs = 30_000;
return t?.close();
});
const open = async (headers: Record<string, string>) => {
const ws = await t.app.injectWS('/api/live', { headers });
let closed: number | undefined;
ws.on('close', (code: number) => (closed = code));
return () => closed;
};
const until = async (fn: () => boolean) => {
for (let i = 0; i < 100 && !fn(); i++) await new Promise((r) => setTimeout(r, 20));
expect(fn()).toBe(true);
};
it('refuses an Origin that is not this host', async () => {
const cookie = await t.login(ADMIN.email, ADMIN.password);
const closed = await open({ cookie, origin: 'https://evil.example', host: 'pbx.test' });
await until(() => closed() === 4403);
const ok = await open({ cookie, origin: 'https://pbx.test', host: 'pbx.test' });
await new Promise((r) => setTimeout(r, 500));
expect(ok()).toBeUndefined();
});
it('closes the socket once the session is gone or the role changed', async () => {
const admin = await t.addUser('demoted@example.com', 'user-password-123', 'admin');
const closedByRole = await open({
cookie: await t.login('demoted@example.com', 'user-password-123'),
});
const loggedOut = await open({ cookie: await t.login(ADMIN.email, ADMIN.password) });
await new Promise((r) => setTimeout(r, 500));
expect(closedByRole()).toBeUndefined();
await t.db.update(users).set({ role: 'user' }).where(eq(users.id, admin.id));
const [me] = await t.db.select().from(users).where(eq(users.email, ADMIN.email));
await t.db.delete(sessions).where(eq(sessions.userId, me!.id));
await until(() => closedByRole() === 4401 && loggedOut() === 4401);
});
});