Pular para conteúdo

Segurança — o piso

Status: Aprovado · Responsável: Gustavo Madruga · Atualizado em: 2026-07-05

O mínimo que todo app da casa cumpre, nas duas stacks. Não é threat modeling formal (desproporcional pro nosso tamanho) — é o piso que evita as falhas baratas de evitar. Destilado em 2026-07-05 do que os pilotos praticam; onde a prática divergia, o lado escolhido está declarado.

Authn / authz

  • Fail-closed por default: a regra de segurança nega; os paths públicos são a exceção explícita (intercept-url-map com a allowlist, não uma denylist). Bypass de desenvolvimento existe como tri-estado declaradoenabled / bypass (só dev/test) / fail-closed em produção — numa SecurityRule/config nomeada, nunca um if solto no handler.
  • Micronaut: micronaut-security nativo com authentication: bearer; papel via claim/TokenValidator consumido por @Secured("ROLE_…") no endpoint. Filtro HTTP sempre @ServerFilter (o @Filter legado ignora @Orderarmadilha).
  • A allowlist do intercept-url-map é por prefixo uniforme (/api/** Bearer, infra) — view pública NÃO. A exceção de path do fail-closed serve para o que é homogêneo por prefixo. Uma view server-render anônima (landing, health público, callback sem Bearer) se libera com @Secured(SecurityRule.IS_ANONYMOUS) na própria rota (rota-exata por construção, colada ao handler, sem allowlist paralela pra driftar); allowlist programática/dinâmica de rotas públicas vai numa isPublicView() na SecurityRule custom (rota exata). O que nunca serve pra view é um pattern greedy (/*, /**) no intercept-url-map: o ConfigurationInterceptUrlMapRule roda em ordem -100, antes da SecurityRule custom (0), e a primeira que devolve ALLOWED/REJECTED vence — um intercept greedy com ALLOWED ganha da regra que protegia a rota irmã e deixa /admin público sem 500 e sem sinal. Receita de view em Java/Micronaut §UI.
  • Tenancy (cliente_id): o servidor decide. A identidade do cliente vem da claim do JWT validada no servidor e cruzada com o dado (FK) — nunca de um parâmetro/header que o cliente controla. No Flutter, restrição no cliente (ex. domínio @xadm.com.br no login) é UX, não segurança — o servidor revalida sempre.
  • Comparação de segredo em tempo constante: shared secret/token se compara com MessageDigest.isEqual (Java), nunca equals/== (timing attack).
  • Endpoint público tem rate limit (piloto: 60 req/min/IP no login). Ação administrativa/sensível gera log INFO de auditoria (quem, o quê, path).
  • Flutter: JWT em flutter_secure_storage (não SharedPreferences); 401 em qualquer chamada → logout automático; senha nunca é persistida — vive só em memória; em prefs vão apenas id/nome. Evento de auditoria de segurança pertence ao backend (evento client-side é bypassável — visibilidade, não auditoria).

Validação de input

  • Todo record de request valida com jakarta.validation (@Body @Valid + @NotBlank/@Pattern/@Size…). O processor (micronaut-validation-processor) já está no build dos apps — o que falta é o hábito; a partir desta versão, endpoint novo sem @Valid no request é lacuna de review. O erro de validação sai no formato RFC 7807 da casa — e se um consumidor NOSSO ramifica no code do corpo, esse code precisa ser uniforme entre borda e service para a mesma classe de falta (ver Discriminador de máquina).
  • Query nunca concatenada: Micronaut Data / prepared statement com parâmetro. String interpolada em SQL é bloqueador de review, sem exceção.
  • Upload/arquivo do usuário: valide extensão E conteúdo esperado antes de processar (os processadores XLS validam por linha e não falham o lote — o padrão certo).

Segredos

  • Client-grade ≠ segredo. Client-grade (DSN do GlitchTip, app-key de analytics, URL pública) embarca no bundle/repo por design — mora no docs/app.json e a Central de Apps o gerencia. Segredo de verdade (key S3 de escrita, token de serviço, chave privada) nunca entra em repo, .ia/, log ou fixture — vive em secret da org no Forgejo / env do Coolify (constituição §1.9).
  • ${ENV_VAR:default-dev-inócuo} no application.yml; chave PEM aceita conteúdo-ou-caminho (AUTH_PRIVATE_KEY > AUTH_PRIVATE_KEY_PATH). Segredo ausente → o app degrada com comportamento declarado (503 no endpoint, no-op com WARN, fail-closed em prod) — nunca sobe fingindo que a feature funciona.
  • ENV de auth/URL inter-app: nomeie pelo DESTINO da chamada, não pelo papel. Um Bearer/base-URL entre apps da casa mora em <APP>_URL + <APP>_API_TOKEN = "como chamar o <APP>". Cada app valida o próprio <SELF>_API_TOKEN; cada chamador seta <DESTINO>_URL + <DESTINO>_API_TOKEN. O efeito é um mapa de segredo coerente: um token tem nome global (WEBSTORM_API_TOKEN) com o mesmo valor no callee que valida e em todo caller que manda — para a aresta A→B, tanto A quanto B têm B_API_TOKEN igual. Num app que é chamador e chamado, a env diz o fluxo de cara: WEBSTORM_API_TOKEN (meu inbound), INTEGRADOR_API_TOKEN/PARCEIRO_API_TOKEN (pra chamar). Anti-padrão: nomear pelo papel (API_BEARER_TOKEN = "meu inbound", WEBHOOK_TOKEN = "meu outbound") — o mesmo nome vira valores diferentes por servidor e não diz a direção; foi o que colidiu na integração N-hop. Trade-off declarado: é um token por callee (todos os callers de B partilham B_API_TOKEN) — simples e suficiente pro N-hop interno; revogar um caller exige rotacionar B pra todos. Só antecipe nome por aresta (<CALLEE>_<CALLER>_API_TOKEN) quando revogação por caller virar requisito real. Rotação fica óbvia: grep <APP>_API_TOKEN acha o conjunto exato a girar de uma vez (casa com "vazou → rotaciona" abaixo). Escopo: só auth/URL HTTP inter-app (+ terceiros PARCEIRO_*); DB (DATASOURCES_*), Firebase/sessão (AUTH_*), observabilidade (SENTRY_DSN) seguem o padrão do framework/produto e ficam como estão. Receita de config e migração de env — sem cascata no yaml (o Micronaut não aninha placeholder; ver a armadilha), via rename atômico ou resolve-in-code — em Java/Micronaut §Específico.
  • Fórmula de derivação de token é segredo se não há key separada. Se um token de auth é derivável de uma fórmula sem um segredo à parte, a fórmula É o segredo — não vai em javadoc/comentário em claro (quem lê o fonte cunha um token válido = bypass). O certo é Kerckhoffs: derive por HMAC com a key em env/secret — aí o algoritmo pode ser público e só a key é secreta. Vale também para quem opera com o fonte à mão (agente incluso): peça o token; não o compute a partir da fórmula (/xadm-setup).
  • Teste gera a própria chave (task Gradle, ex. genAuthTestKeys) — chave de produção nunca aparece em teste/fixture.
  • Vazou → rotaciona. Apagar o arquivo não basta; o Git guarda (já aconteceu com as keys DOCS_S3 — foram rotacionadas).
  • Log não carrega senha nem token — nem em breadcrumb (regra escrita na fachada de log dos dois lados).

Checklist do revisor (PR)

Ao revisar qualquer PR, os 60 segundos de segurança:

  1. Segredo no diff? (string alta-entropia, DSN com senha, PEM) → bloqueia + rotaciona.
  2. Endpoint novo tem @Secured/regra e o público está na allowlist de propósito? (View pública: @Secured(IS_ANONYMOUS) na rota — ou isPublicView() se for allowlist programática —, nunca por intercept-url-map greedy. Rota de view sem decisão = 401.)
  3. Request novo tem @Valid? Query tem parâmetro (não concatenação)?
  4. Migração destrutiva tem cabeçalho de justificativa + IF EXISTS? (banco)
  5. Fixture nova está anonimizada (ou justificada no javadoc)?
  6. Dado sensível novo em docs/? → só em privado/ (constituição §5).