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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user