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
| # | Achado | Severidade | Evidência (antes) | Correção | Teste |
|---|---|---|---|---|---|
| 1 | GET /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.mjs — payments/support ids are unguessable tokens, not enumerable sequential ids; scripts/validation/check-security-invariants.mjs (financial-id-enumerable) |
| 2 | POST /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. | MEDIUM | in-memory-sponsorship.repository.ts | Id trocado para sponsorship_${randomUUID()}. | check-security-invariants.mjs (financial-id-enumerable) |
| 3 | toPublicFieldProjection 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.mjs — public field page never exposes street when address visibility is approximate/private; check-security-invariants.mjs (field-address-privacy-bypass) |
| 4 | toPublicSearchResult 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.ts — delete publicResult.attributes | Reescrito 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) |
| 5 | A 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). | LOW | payments.application-service.ts#handleWebhook | timingSafeEqual (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.mjs — payments: 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. | MEDIUM | auth.application-service.ts | Mecanismo 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.mjs — forgot-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. Verservices/api/test/{team-management,torcida,matches,service,championship}.test.mjspara a cobertura de permissão negativa já existente. - Invariante de primary owner:
team-managementetorcidasbloqueiam remover/rebaixar o únicoprimary_owner(PRIMARY_OWNER_REQUIRED); a troca de ownership é atômica, versionada e exige que o alvo já seja um gestoractive. Nenhuma entidade pode ficar sem owner. - Replay/idempotência do webhook:
hasWebhookEvent/recordWebhookEventfazem o dedup poreventIdantes de qualquer efeito colateral; uma segunda entrega do mesmo evento é no-op.#applyEffectsó transicionawaiting_payment → paiduma 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 — apenasrequestId, 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) resolvemisFollowing/isCheering/canManagea partir de um token de acesso comparado contra umMappróprio, seedado com as strings literais'viewer_supporter'/'viewer_manager'— não valida contra a sessão real deAuthRepositoryPort. Qualquer chamador que envieAuthorization: Bearer viewer_managerrecebecanManage: truenesse 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 usamManagementRepositoryPort.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 doAuthRepositoryPortem ~5 módulos — maior do que o escopo proporcional desta pack. - Segredo do webhook Pix é um literal de código-fonte.
PIX_WEBHOOK_FAKE_SECRETjá é 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/api218 (+3 novos: IDOR de pagamento, privacidade de endereço, rate limit de register/forgot-password),packages/mocks282,packages/shared54,packages/ui54,apps/web91,apps/mobile71 — sem regressão.pnpm run repository:validate— inclui o novopnpm 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 deIS-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.