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

View File

@@ -92,8 +92,22 @@ era só a camada de aplicação: `TenantsController.create()` gravava o mesmo
`telephonyDomain` fixo ("b2bcall.local") pra todo tenant novo. Corrigido:
`telephonyDomain` agora é obrigatório e único na criação do tenant
(constraint no banco), sugerido como `{code}.b2bcall.net` na tela mas
editável. Nenhuma mudança de infra (Dockerfile/sofia profile) foi
necessária — só era preciso um domínio de verdade por tenant.
editável.
**Correção (PHASE 53)**: o parágrafo acima concluía "nenhuma mudança de
infra foi necessária", baseado só em testar o `mod_xml_curl` via curl
direto (que de fato já funcionava por domínio). Isso era **incompleto**
— só um teste de REGISTER de verdade, com um softphone real, achou que o
profile `internal` TAMBÉM tinha `force-register-domain`/
`force-subscription-domain`/`force-register-db-domain` fixados em
`$${domain}`, ignorando completamente o domínio do REGISTER e sempre
resolvendo contra "b2bcall.local" (403 Forbidden pra qualquer domínio
real de tenant). O `<domain name="all" alias="true".../>` citado acima só
afeta resolução de contexto de dialplan — não essa checagem de REGISTER,
que é um código completamente separado dentro do sofia profile. Corrigido
no `Dockerfile` removendo os 3 params (mesmo `sed` do `$${domain}` acima)
— procedimento padrão documentado do próprio FreeSWITCH pra
multi-domínio. Ver PHASE 53 no `TODO.md` pro teste completo.
## Verificado ponta a ponta
@@ -136,22 +150,60 @@ decifra e devolve a senha ATUAL sem trocar nada, auditado
(`EXTENSION_PASSWORD_REVEALED`) por ser uma ação sensível mesmo sem
escrita nenhuma.
## Grupo de captura (call group, PHASE 51)
## Grupo de captura (call group, PHASE 51/53)
Achado real: sem isso, qualquer ramal conseguia capturar a chamada de
qualquer outro (o PBX não tinha noção de "grupo"). `Extension.callGroup`
(nullable) vira a variable `call-group` no directory XML — o FreeSWITCH já
resolve `*8` (group pickup) comparando essa variable entre canais. A
extensão de dialplan pro `*8` em si fica pra configurar em Telefonia >
Dialplan (já é um editor de regras por tenant), não precisa de código
novo — só precisava existir o dado.
(nullable) vira a variable `callgroup` no directory XML.
**Correção (PHASE 53)**: o parágrafo original dizia que a variable era
`call-group` (com hífen, convenção do Asterisk) e que o FreeSWITCH "já
resolve `*8` sozinho comparando essa variable entre canais" — ambas as
afirmações estavam erradas, e nunca tinham sido testadas com uma chamada
de verdade. O mecanismo real, confirmado contra a documentação oficial do
FreeSWITCH e testado ponta a ponta com softphones reais (PHASE 53):
1. A variable correta é `callgroup`, sem hífen — não tem nenhum efeito
automático sozinha. É lida no dialplan via
`${user_data(<ramal>@<domínio> var callgroup)}` (API `mod_commands`,
já carregada).
2. O `bridge` que atende a ligação pro ramal chamado precisa forkar um
leg extra `pickup/<grupo>` junto do `user/...` normal — isso é o que
registra a ligação tocando num hash em memória sob a chave do grupo:
`bridge data="user/${destination_number}@${domain_name},pickup/${called_party_callgroup}"`.
3. Uma extension de feature code separada (`*8`) precisa chamar a
application `pickup` (adicionada ao `ALLOWED_DIALPLAN_APPLICATIONS`)
com o grupo do PRÓPRIO CALLADOR como `data`.
Essas duas regras de dialplan (bridge com o pickup fork + `*8`) foram
configuradas de verdade no tenant Acme via Telefonia > Dialplan — não é
mais só "o dado existe, falta configurar a regra". Testado com 3
softphones reais: ramal do mesmo grupo captura a ligação tocando (`*8`
funciona e o canal migra de verdade); ramal de outro grupo tenta `*8` na
mesma ligação e falha (nenhuma captura). Ver PHASE 53 no `TODO.md`.
### Achado de segurança relacionado: RCE via função inline no dialplan (PHASE 53)
Implementando o mecanismo acima, foi descoberto que o allowlist de
`application` (`set`/`export`/`playback`/...) nunca bloqueava
`${nome(args)}` — uma chamada de API do FreeSWITCH — embutida dentro do
`data` de uma application já permitida. Como `mod_commands` está
carregado, isso permitia `${system(...)}`/`${bg_system(...)}` e RCE
completo no host do FreeSWITCH pra qualquer Tenant Admin com permissão
`freeswitch.configure`. Corrigido com um segundo allowlist,
`ALLOWED_INLINE_API_FUNCTIONS` (`packages/telephony`), validado no DTO
via `IsSafeDialplanData` (`apps/api/src/dialplan/dto/`) — bloqueia
qualquer `${nome(...)}` fora de um punhado de funções de leitura
(`user_data`, `escape`, `url_encode`, `url_decode`, `regex`, `strftime`).
`${variavel}` sem parênteses nunca é afetado.
## O que falta
- ~~Quota de ramais~~ — implementada na fase Plans/Entitlements (ver
docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar.
- Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante.
- ~~Multi-domínio real por tenant~~ — resolvido na PHASE 50 (ver acima).
- Extensão de dialplan padrão pro `*8` de group pickup — o dado
(`callGroup`) já existe, falta só alguém configurar a regra em
Telefonia > Dialplan (ou decidir seedar uma default por tenant).
- Tela "Telefonia → Ramais" (frontend) — já existe (ver PHASE 30).
- ~~Multi-domínio real por tenant~~ — resolvido na PHASE 50/53 (ver
acima).
- ~~Extensão de dialplan padrão pro `*8` de group pickup~~ — configurada
de verdade no tenant Acme na PHASE 53 (ver acima); ainda não é seedada
automaticamente pra tenant novo, decisão de produto em aberto.