Pular para o conteúdo principal

Status: Approved · v1 · SPEC-DELIVERY-SECURITY-AUDIT-001

depends_on: SPEC-ARCH-SECURITY-001

used_by: IS-MVP-15.2

Auditoria de permissões, segurança e privacidade

O que foi auditado, o que foi corrigido nesta pack e o que fica registrado como risco conhecido para uma pack futura (EP-MVP-15.2).

Método

Duas revisões independentes e paralelas do código real de services/api/src (não apenas das specs): uma mapeando o modelo de autenticação/autorização módulo a módulo (guards, membership lookups, invariantes de owner, rate limit, upload, webhook, logging), outra escaneando packages/contracts + os mappers de DTO público em busca de campos proibidos e padrões de blocklist. Cada achado foi verificado lendo o código-fonte diretamente (arquivo:linha), não apenas o comentário/spec, e depois testado com um teste automatizado (positivo ou negativo) antes de ser classificado como corrigido.

Achados corrigidos nesta pack

#AchadoSeveridadeEvidência (antes)CorreçãoTeste
1GET /support/:supportId e GET /payments/:paymentId são públicos por design (SPEC-API-FINANCE-001 "secure token ou owner"), mas os ids eram sequenciais (payment_1, support_1, ...) — qualquer chamador anônimo podia enumerar e coletar nome + valor de todo doador identificado da plataforma.HIGH (privacidade + financeiro, SPEC-ARCH-SECURITY-001 "nunca confiar em ID fornecido sem membership lookup")in-memory-payment.repository.ts usava ${this.#sequence}Ids trocados para payment_${randomUUID()} / support_${randomUUID()} (in-memory-payment.repository.ts), mesmo padrão já usado por shareable-card.application-service.ts. Endpoint continua público por design — a correção fecha a lacuna entre o comentário "unguessable token" e a implementação real, sem mudar contrato/autenticação.services/api/test/finance.test.mjspayments/support ids are unguessable tokens, not enumerable sequential ids; scripts/validation/check-security-invariants.mjs (financial-id-enumerable)
2POST /sponsorships/:id/check-status tinha o mesmo esquema de id sequencial (sponsorship_0001). Severidade menor (o conteúdo já é destinado a ficar público), mas a mesma classe de vulnerabilidade.MEDIUMin-memory-sponsorship.repository.tsId trocado para sponsorship_${randomUUID()}.check-security-invariants.mjs (financial-id-enumerable)
3toPublicFieldProjection incluía record.address inteiro (com street) independentemente de visibility, violando SPEC-DOMAIN-FIELD-001 ("Endereço privado em página pública → retornar apenas approximate/text seguro", FIELD_ADDRESS_PRIVATE) — um campo com visibility: 'private'/'approximate' ainda vazava o endereço completo na página pública.HIGH (privacidade, viola critério de aceite explícito da spec)field.mapper.ts#toPublicFieldProjection (address: record.address)street só é incluído quando visibility === 'public'; caso contrário só visibility/displayText (o texto seguro que o gestor escreveu) chegam ao DTO público. Espelhado em packages/mocks/src/handlers/fields-services.handlers.ts. A view de gestão (autenticada, dono) continua vendo o endereço completo.services/api/test/field.test.mjspublic field page never exposes street when address visibility is approximate/private; check-security-invariants.mjs (field-address-privacy-bypass)
4toPublicSearchResult construía o DTO público com spread + delete do campo interno attributes (blocklist), em vez de allowlist explícita — SPEC-ARCH-SECURITY-001 exige "DTO público construído explicitamente". Não havia vazamento real hoje (o único campo extra é attributes), mas qualquer campo interno futuro adicionado a SearchIndexRecord vazaria por padrão.MEDIUM (latente, não um vazamento ativo)search.record.ts / search.fixtures.tsdelete publicResult.attributesReescrito como allowlist campo a campo nos dois lados (API e mocks).services/api/test/search.test.mjs (pré-existente, continua verde); check-security-invariants.mjs (public-dto-blocklist)
5A verificação de assinatura do webhook Pix comparava signature !== PIX_WEBHOOK_FAKE_SECRET — comparação de string comum, não timingSafeEqual, o que abre um side-channel de tempo (baixo impacto real dado que o segredo é um placeholder de dev commitado, mas o padrão importa antes de plugar um provider real).LOWpayments.application-service.ts#handleWebhooktimingSafeEqual (mesmo padrão já usado por password-hash.ts), com checagem de comprimento antes de comparar (evita o throw que timingSafeEqual faz para buffers de tamanho diferente).services/api/test/finance.test.mjspayments: webhook applies the effect exactly once and rejects a bad signature (assinatura errada com tamanho diferente do segredo real, já cobre o branch)
6/auth/register e /auth/forgot-password não tinham nenhum rate limit (apenas /auth/login tinha), apesar de SPEC-ARCH-SECURITY-001 listar "Auth/refresh rotation e rate limit" como controle mínimo.MEDIUMauth.application-service.tsMecanismo de lockout por identifier já existente em /auth/login generalizado (AuthRepositoryPort#isActionRateLimited/recordActionAttempt, escopado por scope:identifier) e aplicado também a register e forgot-password, com o mesmo limite (5 tentativas) e resposta (RATE_LIMITED, 429). Espelhado nos mocks.services/api/test/auth.test.mjsforgot-password and register are rate limited per identifier

Confirmado íntegro (sem correção necessária)

Verificado por leitura direta do código, não apenas pela spec:

  • IDOR/membership: praticamente todo endpoint de gestão (team-management, torcidas, wallets, sponsors, services, championships, matches, reports, support, notifications) faz um lookup real de membership por (entityType, entityId, userId) antes de autorizar qualquer mutação — nunca confia em um id de entidade fornecido pelo cliente sem essa checagem. Ver services/api/test/{team-management,torcida,matches,service,championship}.test.mjs para a cobertura de permissão negativa já existente.
  • Invariante de primary owner: team-management e torcidas bloqueiam remover/rebaixar o único primary_owner (PRIMARY_OWNER_REQUIRED); a troca de ownership é atômica, versionada e exige que o alvo já seja um gestor active. Nenhuma entidade pode ficar sem owner.
  • Replay/idempotência do webhook: hasWebhookEvent/recordWebhookEvent fazem o dedup por eventId antes de qualquer efeito colateral; uma segunda entrega do mesmo evento é no-op. #applyEffect só transiciona waiting_payment → paid uma vez.
  • Senhas e sessões: hash com scrypt + salt por senha + timingSafeEqual (password-hash.ts); refresh token rotation com revogação de família inteira em caso de replay de um refresh token já usado (proteção real contra token theft).
  • Logging: os únicos dois arquivos que logam algo (technical-logging.interceptor.ts, main.ts) nunca incluem senha, token, corpo de request/response, Pix code ou CPF/documento — apenas requestId, rota, método, status e duração.
  • Privacidade do jogador: as flags (contactsPublic, statsPublic, etc.) omitem o campo de verdade no DTO público — não é uma máscara de UI, o campo simplesmente não existe na resposta quando a flag está desligada.

Registrado como risco, não corrigido nesta pack

Achados reais, mas que exigem uma decisão de produto/arquitetura maior do que o escopo proporcional desta pack de auditoria — registrados em risk_register.md (R-011, R-012) em vez de corrigidos silenciosamente, seguindo a regra do próprio IS ("Se uma decisão... de autoridade estiver ambígua, não inferir; pedir aprovação").

  • Camada paralela de "viewer relationship" com tokens fixos. public-team/public-torcida/public-territory (resolveViewer, in-memory repos) resolvem isFollowing/isCheering/canManage a partir de um token de acesso comparado contra um Map próprio, seedado com as strings literais 'viewer_supporter'/'viewer_manager'não valida contra a sessão real de AuthRepositoryPort. Qualquer chamador que envie Authorization: Bearer viewer_manager recebe canManage: true nesse DTO de relacionamento sem ter feito login. Rastreado até o fim: essa flag é somente de exibição — nenhuma mutação real passa por ela (todas usam ManagementRepositoryPort.findMembership, um store completamente separado) — então não é um caminho de escalação de privilégio para dado real, mas é um bypass de autenticação real nessa superfície específica (follow/cheer count) e um design legado já reconhecido em comentário de código (public-championship.application-service.ts "legacy resolveViewer/hardcoded-token relationship map"). Corrigir exige unificar essa camada com o session store real do AuthRepositoryPort em ~5 módulos — maior do que o escopo proporcional desta pack.
  • Segredo do webhook Pix é um literal de código-fonte. PIX_WEBHOOK_FAKE_SECRET já é documentado como placeholder de desenvolvimento (comentário no próprio código apontando para "verificação de assinatura contra chaves do provider" antes de produção real). Continua registrado explicitamente aqui para não ser esquecido quando um provider Pix real for escolhido (SPEC-DELIVERY-READINESS-001: "Pagamentos reais condicionado a provider/compliance/testes").
  • Upload/asset real não existe ainda. Não há endpoint de upload, presigned URL ou verificação de MIME/tamanho real no backend — todo campo de imagem é uma string (URL) que o cliente informa diretamente, sem verificação do conteúdo real. O único fluxo próximo de "upload" (evidência de denúncia/support request) só valida metadata declarada pelo cliente (mimeType/sizeBytes), não o arquivo em si. Não é uma regressão desta pack — é a ausência de uma feature de upload real, que está fora do escopo de "no new product feature" desta auditoria. Fica registrado para quando essa feature for implementada: a validação real (fetch + sniff do conteúdo) precisa entrar junto.

Comandos e resultados

  • pnpm run typecheck — 11/11 tasks, verde.
  • pnpm run lint — boundaries, ESLint, Prettier verdes.
  • pnpm run test — verde: services/api 218 (+3 novos: IDOR de pagamento, privacidade de endereço, rate limit de register/forgot-password), packages/mocks 282, packages/shared 54, packages/ui 54, apps/web 91, apps/mobile 71 — sem regressão.
  • pnpm run repository:validate — inclui o novo pnpm run security:invariants (scripts/validation/check-security-invariants.mjs), 4 guardas de regressão verdes.
  • pnpm run build — 7/7 tasks.
  • pnpm run validate:contract-parity — 18/18 (a mudança de formato de id não afeta a comparação de forma).

Não coberto por esta auditoria

  • Auditoria de performance (SPEC-ARCH-PERF-001) e acessibilidade/qualidade visual — escopo de IS-MVP-15.3.
  • Observabilidade/jobs além do que já existe — escopo de IS-MVP-15.4.
  • Rate limiting por IP (o mecanismo atual, herdado de /auth/login, é por identifier — suficiente para o MVP em memória de um único processo, mas não substitui um limitador de infraestrutura quando houver deploy real multi-instância).
  • Escolha e integração de um provider Pix real — o webhook segue com o segredo placeholder até essa decisão ser tomada.