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
32 lines
1.4 KiB
TypeScript
32 lines
1.4 KiB
TypeScript
import { registerDecorator, type ValidationOptions, type ValidationArguments } from "class-validator";
|
|
import { findDisallowedInlineFunctionCalls } from "@b2bcall/telephony";
|
|
|
|
/**
|
|
* Bloqueia `${nome(args)}` que não esteja no allowlist de APIs seguras
|
|
* (`ALLOWED_INLINE_API_FUNCTIONS`) — achado real de segurança (secao 180):
|
|
* o allowlist de `application` (set/export/playback/...) não impedia RCE
|
|
* via `${system(...)}`/`${bg_system(...)}` embutido no `data` de uma
|
|
* application já permitida (`mod_commands` está carregado no FreeSWITCH
|
|
* desta implantação). `${variavel}` sem parênteses nunca é bloqueado.
|
|
*/
|
|
export function IsSafeDialplanData(validationOptions?: ValidationOptions) {
|
|
return function (object: object, propertyName: string) {
|
|
registerDecorator({
|
|
name: "isSafeDialplanData",
|
|
target: object.constructor,
|
|
propertyName,
|
|
options: validationOptions,
|
|
validator: {
|
|
validate(value: unknown) {
|
|
if (typeof value !== "string") return true; // @IsString() já cobre o tipo
|
|
return findDisallowedInlineFunctionCalls(value).length === 0;
|
|
},
|
|
defaultMessage(args: ValidationArguments) {
|
|
const disallowed = findDisallowedInlineFunctionCalls(String(args.value));
|
|
return `data usa função não permitida: ${disallowed.join(", ")} (nunca system/bg_system/curl/db/lua/shell — RCE no FreeSWITCH)`;
|
|
},
|
|
},
|
|
});
|
|
};
|
|
}
|