Files
B2BCall-dialer/packages/telephony/src/directory-xml.ts
Matheus af46739af2 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
2026-08-30 15:24:58 -03:00

86 lines
3.6 KiB
TypeScript

/** Escapa texto pra uso seguro dentro de atributos/elementos XML. */
function xmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
export interface DirectoryUserParams {
domain: string;
extensionNumber: string;
extensionName: string;
sipPassword: string;
context: string;
callerIdName?: string;
callerIdNumber?: string;
tenantId: string;
extensionId: string;
/** 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;
}
/**
* Monta o XML de directory que o FreeSWITCH espera do mod_xml_curl pra
* autenticar/rotear um ramal (agente.md secao 26). `b2bcall_tenant_id` e
* `b2bcall_extension_id` viram channel variables em toda chamada desse
* ramal — é assim que o resto do sistema volta a saber de qual tenant uma
* chamada é (secao 81).
*/
export function buildDirectoryUserXml(params: DirectoryUserParams): string {
const callerIdName = xmlEscape(params.callerIdName ?? params.extensionName);
const callerIdNumber = xmlEscape(params.callerIdNumber ?? params.extensionNumber);
return `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="directory">
<domain name="${xmlEscape(params.domain)}">
<params>
<!-- Sem isto, o endpoint "user/" nao sabe montar o dialstring pra
alcançar o contato registrado (mesmo padrão da config vanilla
em directory/default.xml). -->
<param name="dial-string" value="{^^:sip_invite_domain=\${dialed_domain}:presence_id=\${dialed_user}@\${dialed_domain}}\${sofia_contact(*/\${dialed_user}@\${dialed_domain})}"/>
</params>
<groups>
<group name="default">
<users>
<user id="${xmlEscape(params.extensionNumber)}">
<params>
<param name="password" value="${xmlEscape(params.sipPassword)}"/>
</params>
<variables>
<variable name="user_context" value="${xmlEscape(params.context)}"/>
<variable name="effective_caller_id_name" value="${callerIdName}"/>
<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="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""}
</variables>
</user>
</users>
</group>
</groups>
</domain>
</section>
</document>`;
}
export const NOT_FOUND_XML = `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="result">
<result status="not found"/>
</section>
</document>`;