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
86 lines
3.6 KiB
TypeScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
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>`;
|