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

@@ -18,11 +18,17 @@ export interface DirectoryUserParams {
callerIdNumber?: string;
tenantId: string;
extensionId: string;
/** Grupo de captura (secao 178) — vira a variable `call-group` no
* directory; o *8 de group pickup no dialplan usa essa mesma variable
* pra achar um canal tocando no mesmo grupo (`pickup` da FreeSWITCH
* exige `call-group` batendo, senão qualquer ramal capturaria a
* chamada de qualquer outro — achado real reportado pelo usuário). */
/** Grupo de captura (secao 178) — vira a variable `callgroup` (sem
* hífen: é o nome que o `mod_dptools`/convenção FreeSWITCH usa,
* confirmado contra a documentação oficial — `${user_data(<ext>@
* <domain> var callgroup)}` no dialplan lê exatamente esse nome). O
* pickup em si não é automático por variável: o dialplan que atende a
* ligação pro ramal precisa forkar um leg `pickup/${...essa var...}`
* no `bridge`, e uma extension separada de feature code (`*8`) chama a
* application `pickup` com o grupo de quem discou. Ver
* docs/EXTENSIONS.md — achado real reportado pelo usuário, verificado
* contra a documentação do FreeSWITCH antes de close (a primeira versão
* usava `call-group`, que não é lido por nada). */
callGroup?: string | null;
}
@@ -60,7 +66,7 @@ export function buildDirectoryUserXml(params: DirectoryUserParams): string {
<variable name="effective_caller_id_number" value="${callerIdNumber}"/>
<variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/>
<variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/>
${params.callGroup ? `<variable name="call-group" value="${xmlEscape(params.callGroup)}"/>` : ""}
${params.callGroup ? `<variable name="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""}
</variables>
</user>
</users>