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:
@@ -1,5 +1,6 @@
|
||||
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
import { ALLOWED_DIALPLAN_APPLICATIONS, type AllowedDialplanApplication } from "@b2bcall/telephony";
|
||||
import { IsSafeDialplanData } from "./safe-dialplan-data.validator";
|
||||
|
||||
export class ActionDto {
|
||||
@IsIn(ALLOWED_DIALPLAN_APPLICATIONS)
|
||||
@@ -8,5 +9,6 @@ export class ActionDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
@IsSafeDialplanData({ message: "data usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
|
||||
data?: string;
|
||||
}
|
||||
|
||||
31
apps/api/src/dialplan/dto/safe-dialplan-data.validator.ts
Normal file
31
apps/api/src/dialplan/dto/safe-dialplan-data.validator.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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)`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user