- dialplan_extensions table (tenant-scoped, RLS): structured editor per agente.md secao 43 -- context, condition field/expr, actions/anti-actions (JSON), continue, order, enabled. One condition per extension (deliberate simplification vs raw FreeSWITCH's multi-condition extensions). - dialplan_versions table (tenant-scoped, RLS): generate/validate/version/ activate flow (secao 44). Reactivating an older version IS the rollback mechanism -- no separate endpoint needed. - apps/api/src/dialplan: extensions CRUD + versions/generate (builds XML, validates well-formedness with fast-xml-parser, saves as DRAFT) + versions/:id/activate (atomically flips ACTIVE, supersedes the previous one). Reused freeswitch.view/.configure permissions rather than inventing new ones not in the agente.md permission list. - packages/telephony: buildDialplanXml() plus ALLOWED_DIALPLAN_APPLICATIONS, an explicit allowlist (answer/bridge/playback/hangup/set/export/... -- deliberately no system/exec/socket) guarding against a tenant configuring a dialplan action that runs arbitrary commands on the FreeSWITCH host (agente.md secao 180) - b2bcall-fs-config resolves dialplan dynamically per call (unlike Trunks' file+rescan approach -- dialplan is fetched fresh via mod_xml_curl on every call anyway) by tenant id from the variable_b2bcall_tenant_id channel variable already injected at directory resolution, then serving whichever DialplanVersion is ACTIVE for that context - verified end-to-end: created a rule for destination_number 7000, generated and activated v1, originated a call that actually routed through the dialplan (not bypassing it via &app()) -- CALL_CREATED -> CALL_ANSWERED -> CALL_ENDED with the correct tenantId throughout. Created and activated a v2, then rolled back to v1 by reactivating it; status transitions (ACTIVE/SUPERSEDED) all confirmed via the API. CRITICAL FINDING, fixed in this same phase: deliberately testing that the application allowlist rejects 'system' got back 201 instead of 400 -- NestJS's ValidationPipe had been silently inert across all of apps/api's @Body() DTOs since the API was first created. Root cause: running via (esbuild) instead of a real build -- esbuild doesn't always resolve cross-file parameter types for design:paramtypes metadata, and Nest skips validation without any error when it can't determine the DTO class. Fixed by always building with tsc before running (tsc && tsx dist/main.js -- still via tsx because internal workspace packages aren't built to JS yet). Re-verified with two deliberate bad-input tests post-fix, both correctly rejected with 400. A stray malicious test row (dialplan action 'system') created while the bug was live was deleted; it was never baked into an activated version, so nothing could have executed it. See docs/VALIDATION_PIPE_BUG.md for the full writeup. docs/DIALPLAN.md, docs/VALIDATION_PIPE_BUG.md, docs/EXTENSIONS.md updated
3.6 KiB
Achado crítico: validação de entrada não funcionava sob tsx
Resumo: de quando apps/api foi criada (fase apps/api) até a fase Dialplan,
ValidationPipe global do NestJS não validava nada. Todo @Body()
passava batendo, incluindo campos não permitidos (forbidNonWhitelisted) e
valores fora de qualquer allowlist (@IsIn). Descoberto ao testar
deliberadamente que a allowlist de applications do dialplan (que existe
especificamente pra impedir uma tenant de configurar application: "system"
com dados de shell arbitrários — agente.md secao 180) aceitou a entrada
maliciosa com 201.
Causa raiz
apps/api rodava via tsx src/main.ts (esbuild por baixo). O NestJS decide
qual classe usar pra instanciar/validar um @Body() lendo a metadata
design:paramtypes do método do controller (reflect-metadata,
emitDecoratorMetadata do TypeScript). esbuild não faz checagem de tipos
completa entre arquivos — pra parâmetros cujo tipo vem de um import de
outro arquivo, ele às vezes emite Object genérico em vez da classe real.
Quando ValidationPipe recebe metatype === Object, ele pula a validação
silenciosamente (comportamento documentado do Nest: tipos primitivos/
genéricos são considerados "nada pra validar").
Isso é a mesma classe de bug já encontrada na fase Extensions
(PermissionGuard injetando Reflector via construtor chegava undefined
em runtime) — mas ali o sintoma era um crash óbvio (TypeError), fácil de
notar. Aqui o sintoma é ausência de erro: a validação simplesmente não
roda, sem log, sem exceção, sem pista nenhuma a não ser testar
deliberadamente com entrada inválida.
Correção
tsc de verdade faz checagem de tipos completa e emite design:paramtypes
corretamente (confirmado inspecionando o dist/*.js gerado: aparece a
referência real da classe, ex. create_extension_dto_1.CreateExtensionDto,
em vez de Object). A partir de agora apps/api sempre builda com
tsc antes de rodar:
"build": "tsc -p tsconfig.json",
"start": "tsx dist/main.js",
"dev": "tsc -p tsconfig.json && tsx dist/main.js"
Importante: o start/dev rodam o JS compilado através do tsx, não
do node puro — porque os pacotes internos (@b2bcall/shared,
@b2bcall/database, ...) ainda não têm build próprio (main aponta pro
.ts fonte, não pra um dist/). tsx resolve isso via seu loader; node
sozinho quebraria tentando importar um arquivo .ts diretamente. Ver
docs/AUTHENTICATION.md pra mais contexto sobre essa limitação dos pacotes
internos.
Nunca rode apps/api com tsx src/main.ts (ou tsx watch) direto — só
via pnpm run build && pnpm run start, ou pnpm run dev.
Limpeza feita
Uma extension de dialplan de teste com application: "system" foi criada
durante o teste que expôs o bug (nunca chegou a ser incluída numa versão
gerada/ativada — não havia como executar de verdade) e foi apagada
imediatamente após a correção.
O que revisar
packages/telephony,packages/auth,packages/database: não usamValidationPipe/decorators do Nest, não são afetados por esse bug específico.apps/freeswitch-eventseapps/freeswitch-config: não usam NestJS, não são afetados.- Todos os DTOs de
apps/api(auth, extensions, trunks, dialplan) — a correção é geral (troca de runtime, não patch por endpoint), então todos passam a validar corretamente a partir desta fase. Reverificado com dois testes deliberados pós-correção (campo não permitido em/extensions, application fora da allowlist em/dialplan/extensions) — ambos corretamente rejeitados com 400.