Security
Modelo de ameaças e medidas de segurança implementadas + checklist OWASP.
Resumo defensivo do Geek Social. Lista o que está coberto, o que não está, e o que está no roadmap.
Modelo de ameaças
Atores considerados:
- User regular — pode tentar acessar/manipular dados de outros users via API
- User malicioso autenticado — tem JWT válido mas tenta exploits
- Atacante externo (não autenticado) — força bruta, enumeration, scans
- Atacante com acesso físico ao DB — vazamento de secrets em rest
- Compromise de credencial — JWT/cookie roubado
NÃO considerados (out of scope hoje):
- Atacante com root no servidor (compromise infra) — fora do escopo da app
- Análise sofisticada de side-channels (timing, cache)
Autenticação e sessão
| Vetor | Mitigação |
|---|---|
| Sessão hijack via XSS | Refresh token em cookie HttpOnly — JS não lê |
| CSRF em refresh | SameSite=strict em prod, lax em dev |
| Replay de refresh | Rotação obrigatória — usar 2x falha |
| Roubo de DB → forjar sessões | token_hash = sha256(rawToken) — DB nunca tem raw |
| Brute force de senha | bcrypt cost 12 (~250ms) + (TODO) rate limit em /login |
| Enumeration de e-mails | /forgot-password sempre retorna 200 |
| Reset com token roubado | Single-use (used_at) + TTL 1h |
| Múltiplas sessões abertas | Reset password apaga TODOS refresh_tokens |
| JWT roubado dentro de 15min | Aceito risco residual; reset password é rota de recuperação |
Detalhes em Conceito de Autenticação.
Autorização (vertical)
Cada endpoint que mexe com recurso valida ownership/membership:
// Padrão em services
async update(userId: string, listingId: string, input: ...) {
const listing = await this.repo.findById(listingId)
if (!listing) throw new ListingsError('NOT_FOUND')
if (listing.ownerId !== userId) throw new ListingsError('NOT_AUTHORIZED')
// ... prossegue
}Princípio: validação centrada no service (não no controller). Controllers podem ser ignorados pra debugging mas service nunca abre brecha.
| Recurso | Ownership/access check |
|---|---|
| User profile (edit) | userId === request.user.userId |
| Collection (CRUD) | collection.userId === userId |
| Item (CRUD) | via collection ownership |
| Listing (CRUD) | listing.ownerId === userId |
| Offer (accept/reject) | turno via latestProposal.proposerId !== userId |
| Group chat (admin actions) | member.role IN ('owner', 'admin') |
| Notification (mark read) | notification.recipientId === userId |
Validação de input
Todas as rotas têm Zod schema:
- Body:
body: registerSchemanoapp.post(...) - Query:
querystring: ... - Params:
params: ...
fastify-type-provider-zod valida antes do handler rodar. Schema inválido → 400 sem chegar no service.
Tipos com validação extra:
- UUIDs:
z.string().uuid()rejeita strings malformadas - Emails:
z.string().email()rejeita formato inválido - URLs:
z.string().url()rejeita protocolos suspeitos - Hex colors: regex
^#[0-9a-fA-F]{6}$ - Paths/filenames de upload:
multipartvalida via mime + size limit
SQL injection
Não há SQL string concatenation no código de domínio.
Drizzle parameteriza:
db.select().from(users).where(eq(users.email, userInput))→ bind parametersql`SELECT ... ${userInput}`→ bind parameter (não inline)
Cuidado: sql.raw(userInput) injeta direto. Nunca usado no código atual. Ponto de atenção em PRs.
XSS
Frontend (Vue 3): escape automático via interpolação {{ value }}. v-html proibido em conteúdo de usuário.
Backend: APIs retornam JSON. Sem renderização server-side. Markdown não é renderizado em bio/posts (texto plano).
Vetores possíveis (quando renderizar markdown no futuro):
- Sanitizar HTML antes de renderizar (DOMPurify)
- Whitelist de tags permitidas
- Atributos de URL com
target="_blank" rel="noopener noreferrer"
Path traversal em uploads
S3Adapter recebe key montado pelo backend:
const key = `posts/${postId}/${randomUUID()}.${ext}`
await s3.put(key, buffer, mimeType)postId é UUID validado. randomUUID() é seguro. ext extraído do filename é único ponto sensível:
const ext = path.extname(filename).toLowerCase()
if (!['.jpg', '.png', '.webp', '.mp4', '.m4a', '.pdf'].includes(ext)) {
throw new Error('UNSUPPORTED_EXT')
}Whitelist evita extensões executáveis (.html, .js, .svg que pode ter JS).
Storage público
Arquivos no MinIO/S3 são publicamente acessíveis via URL. Implicação:
| Cenário | Risco |
|---|---|
Post private | Quem souber a URL acessa o arquivo |
| Message attachment em conversa privada | Idem |
| Avatar de user com perfil private | Idem |
Mitigação atual: URLs incluem UUID random, então não é enumerável. Mas leak de URL = leak de mídia.
Mitigação futura (signed URLs): backend gera URL temporária (TTL minutos) por request. Custo: complexidade adicional, URLs não-cacheáveis.
Rate limiting
Implementado via middleware customizado em api/src/shared/middleware/rate-limit.ts com store Redis (auditoria #5, Pacote B).
// rate-limit.ts — INCR + PEXPIRE atômicos por janela fixa
const key = `rl:ip:${ip}:${Math.floor(Date.now() / windowMs)}`
const count = await redis.multi().incr(key).pexpire(key, windowMs).exec()
if (count > maxRequests) throw 429- Sem fallback in-memory: rate-limit é controle de segurança. Se o Redis cair, o middleware fail-closes com
503 RATE_LIMIT_UNAVAILABLE(forçando o operador a tratar a indisponibilidade) em vez de silenciosamente liberar tudo. - Distribuído: funciona com N réplicas da API — o Redis é a fonte da verdade do contador.
- Algoritmo: janela fixa (fixed window counter) — aceita até 2× limit no instante do rollover; suficiente pros endpoints atuais.
Endpoints com limite hoje:
| Endpoint | Limite | Escopo |
|---|---|---|
/auth/register | 10/min | IP |
/auth/login | 5/min | IP |
/auth/forgot-password | 5/min | IP |
/auth/reset-password | 5/min | IP |
/auth/resend-verification | 5/min | IP |
/auth/verify-email | 20/min | IP |
/auth/set-password | 3/hora | userId |
/auth/change-password | 5/hora | userId |
/auth/google/exchange | 5/min | IP |
/users/search | 30/min | userId |
DELETE /users/me | 1/24h | userId |
/notifications (deleteAll) | 5/hora | userId |
/notifications/:id (delete) | 20/hora | userId |
Secrets handling
| Secret | Onde fica | Cuidado |
|---|---|---|
JWT_SECRET | env var | Random ≥32 chars; rotação requer re-login de todos |
GOOGLE_CLIENT_SECRET | env var | — |
AWS_SECRET_ACCESS_KEY | env var | — |
VAPID_PRIVATE_KEY | env var | Trocar invalida todas push subscriptions |
users.steam_api_key | DB | At-rest sem criptografia (pendência) |
password_hash | DB | bcrypt cost 12 |
refresh_tokens.token_hash | DB | sha256 |
password_reset_tokens.token_hash | DB | sha256 |
.env no .gitignore. Em prod: secrets via env do container (não no Dockerfile).
CORS
await app.register(fastifyCors, {
origin: env.FRONTEND_URL,
credentials: true,
})Single origin (frontend específico). Cookies credentials: true exige origem exata, não wildcard.
CSRF
Token Bearer + cookie HttpOnly:
- Bearer JWT em request normal — não vai automaticamente, atacante precisa do JS pra forjar
- Cookie refresh —
SameSite=strictimpede cross-site
Atacante que consegue XSS no nosso domínio passa por tudo. Defesa profundidade contra XSS é prioridade.
Headers de segurança
Implementado via @fastify/helmet registrado em app.ts após o CORS (auditoria #4, Pacote B).
await app.register(fastifyHelmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
formAction: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
fontSrc: ["'self'", 'data:'],
objectSrc: ["'none'"],
},
},
crossOriginResourcePolicy: { policy: 'cross-origin' },
hsts: env.NODE_ENV === 'production'
? { maxAge: 31536000, includeSubDomains: true, preload: true }
: false,
})| Header | Valor |
|---|---|
Strict-Transport-Security | 1 ano + includeSubDomains + preload (apenas em prod) |
Content-Security-Policy | default-src 'none' + sources mínimas |
X-Content-Type-Options | nosniff |
X-Frame-Options | DENY (via frame-ancestors 'none') |
Referrer-Policy | no-referrer |
Cross-Origin-Resource-Policy | cross-origin (necessário pra frontend separado) |
Cross-Origin-Embedder-Policy | desabilitado (incompatível com mídia pública S3) |
A API serve apenas JSON (e Swagger em dev), por isso o CSP pode ser bem restritivo. Pra docs/ferramentas que precisem de CSP relaxado, use o frontend ou um subdomínio dedicado.
Trust proxy
TRUST_PROXY env var (auditoria #3, Pacote B) controla como o Fastify lê request.ip atrás de proxies/load balancers:
| Valor | Uso |
|---|---|
false (default dev) | request.ip = socket peer (correto sem proxy) |
true | confia no X-Forwarded-For da request (only-prod-com-proxy-confiavel) |
| Número | nº de hops a confiar |
| CIDR(s) | lista separada por vírgula (ex: 10.0.0.0/8,192.168.0.0/16) |
Por que importa pro rate-limit: sem TRUST_PROXY configurado, todas as requests atrás de um proxy reverso aparecem com o IP do proxy → rate-limit no request.ip viraria limit global por IP do load balancer. Com TRUST_PROXY correto, o IP real do cliente é extraído.
Criptografia end-to-end (chat)
O chat (DMs e grupos) é end-to-end encrypted com Signal Protocol — o backend só vê ciphertext + material público de chave. Nenhuma instância do servidor (incluindo operadores com acesso ao DB) consegue ler conteúdo.
| Componente | Algoritmo |
|---|---|
| Identidade long-term | Curve25519 (32B pub/priv) |
| Pre-key signed (SPK) | Curve25519 + assinatura Ed25519, rotação ~7d |
| Post-quantum prekey | Kyber1024 (PQXDH), rotação ~7d |
| One-time prekey | Curve25519, lote 100, refill quando <20 |
| DM | X3DH/PQXDH + Double Ratchet (HKDF-SHA256 + AES-256-GCM) |
| Grupos | Sender Keys distribuídas via Signal session 1-1 |
| Backup de identidade | PBKDF2-SHA256(PIN, 600k iter) → AES-256-GCM, blob até 256 KB |
Garantias:
- Forward secrecy: comprometer uma message key não decifra outras mensagens.
- Post-quantum: Kyber1024 na composição PQXDH protege contra "harvest now, decrypt later" mesmo se Curve25519 cair pra computação quântica.
- Group forward secrecy: quando membro sai/é removido,
conversations.sender_key_idrotaciona — ex-membro não decifra mensagens futuras. - TOFU + safety numbers: identidade do peer cacheada localmente, troca silenciosa dispara alerta + bloqueia até user acknowledgar.
- Push notifications metadata-only: server VAPID nunca vê plaintext.
Detalhes completos em Criptografia E2EE.
Logging vs privacidade
Logs não devem conter:
- Senhas (em qualquer forma)
- JWTs completos (apenas claims relevantes: userId)
- Cookies inteiros
- E-mails personalizados em mass logs (LGPD)
Pino sanitiza por default; mas hooks customizados podem vazar — auditar antes de centralizar logs.
Compliance LGPD (princípios)
- Direito ao esquecimento:
DELETE /users/meapaga conta + cascade — implementado - Portabilidade: endpoint pra exportar dados próprios (
GET /users/me/export) — pendência - Consentimento explícito: termos no signup — UI implementada
- Dados sensíveis (CPF, geo precisa, etc.) — não coletados hoje
OWASP Top 10 — checklist
| # | Categoria | Status |
|---|---|---|
| A01 | Broken Access Control | ✅ Validação por service em todos recursos |
| A02 | Cryptographic Failures | ✅ bcrypt, sha256, HTTPS em prod, E2EE Signal Protocol no chat |
| A03 | Injection (SQLi/CMD) | ✅ Drizzle parametriza; Zod valida |
| A04 | Insecure Design | ⚠️ Storage público é decisão consciente |
| A05 | Security Misconfiguration | ✅ Helmet/CSP/HSTS configurados (Pacote B) |
| A06 | Vulnerable Components | ⚠️ npm audit periódico; deps RCE corrigidas no Pacote A; sem Dependabot ainda |
| A07 | Identification & Auth Failures | ✅ JWT + rotation + rate-limit Redis em endpoints sensíveis |
| A08 | Software & Data Integrity | ⚠️ Sem signed builds / SBOM |
| A09 | Security Logging & Monitoring | ❌ Audit log pendente; alerts pendente |
| A10 | SSRF | ⚠️ Sem requests outbound a URLs controladas pelo user; Steam OpenID validado por allowlist (pendente — Pacote C) |
Itens em ⚠️ ou ❌ são parte do roadmap. O processo de auditoria interna está dividido em pacotes A–E (round 1 — 2026-04-30):
| Pacote | Itens | Status |
|---|---|---|
| A | Bump deps com RCE (happy-dom, serialize-javascript) + bloqueio de secrets dev-only em prod | ✅ commit 579e206 |
| B | trustProxy + helmet/CSP + rate-limit Redis distribuído | ✅ commit 0a4b498 |
| C | SSRF, Steam OpenID allowlist, IDOR no chat, Google delete confirm + P2 auth | 🔜 |
| D | scheme guard, IndexedDB encryption, OAuth fragment, EventEdit guard, logout cleanup | 🔜 |
| E | CSP frontend, withCredentials, magic bytes, body limit + deps P2 (esbuild, vite, uuid) | 🔜 |
Pendências priorizadas (pós-Pacote B)
- Pacote C — SSRF / OpenID allowlist / IDOR chat
- Pacote D — IDB encryption (cripto material no IndexedDB) + logout cleanup
- Pacote E — frontend CSP + magic bytes em uploads
- Audit log dedicado
- Signed URLs pra storage privado
- Criptografia at-rest de
steam_api_keyem DB npm auditem CI pra alertar de CVE em deps