Files
notas-online/internal/api/limit.go
T
lgcosta 6e0ec29888 fix(security): corrige achados da varredura — render, brute-force, upload, WYSIWYG, cookie, sessões
1. /api/render: cap de 512 KiB na entrada (amplificação ~2× medida);
   decodeJSON agora amarra http.MaxBytesReader ao ResponseWriter (413
   real ao cliente em todos os endpoints JSON)
2. Login com rate-limit por IP em memória (5 falhas/15 min → 429, acerto
   reseta, RemoteAddr puro — X-F-F não é confiável) + log de falha com IP
3. Upload: MaxBytesReader no corpo inteiro (64 MiB + 64 KiB) antes do
   parse do multipart; quota global de 2 GiB (MaxFilesTotalBytes) em AddFile
4. sanitizeRich neutraliza href/src com javascript:/vbscript:/data:
   (case/whitespace-insensível); link legítimo preservado
5. Cookie Secure passa a ser configuração explícita (NOTES_SECURE_COOKIE)
   — X-Forwarded-Proto é spoofável; documentado em .env.example e compose
6. Cookie agora carrega id de sessão opaco (crypto/rand 32B, TTL 30d,
   revogação no logout e no restart); token mestre só vale via Bearer —
   furo pego pelo próprio teste novo (token no cookie era aceito)
   Testes: rate-limit, revogação, canais separados, render 413, upload cap
   sw v16
2026-11-05 18:58:10 -03:00

87 lines
1.9 KiB
Go

package api
import (
"net"
"net/http"
"sync"
"time"
)
// loginLimiter limita tentativas de login por IP em janela fixa, em memória.
// Escopo deliberado: instância única (sem cluster). Atrás de proxy reverso o
// RemoteAddr é o do proxy — o limite vira global, o que só torna o limite mais
// rígido; NÃO confiamos em X-Forwarded-For (spoofável).
type loginLimiter struct {
mu sync.Mutex
fails map[string]*loginBucket
max int
window time.Duration
}
type loginBucket struct {
count int
resetAt time.Time
}
func newLoginLimiter(max int, window time.Duration) *loginLimiter {
return &loginLimiter{fails: map[string]*loginBucket{}, max: max, window: window}
}
// remoteIP extrai o host de RemoteAddr sem depender de headers do cliente.
func remoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// allow diz se o IP ainda pode tentar; não consome tentativa.
func (l *loginLimiter) allow(ip string) bool {
l.mu.Lock()
defer l.mu.Unlock()
l.sweep()
b, ok := l.fails[ip]
if !ok {
return true
}
if time.Now().After(b.resetAt) {
delete(l.fails, ip)
return true
}
return b.count < l.max
}
// fail registra uma tentativa errada.
func (l *loginLimiter) fail(ip string) {
l.mu.Lock()
defer l.mu.Unlock()
l.sweep()
b, ok := l.fails[ip]
if !ok || time.Now().After(b.resetAt) {
l.fails[ip] = &loginBucket{count: 1, resetAt: time.Now().Add(l.window)}
return
}
b.count++
}
// success limpa o histórico do IP após login correto.
func (l *loginLimiter) success(ip string) {
l.mu.Lock()
delete(l.fails, ip)
l.mu.Unlock()
}
// sweep evita crescimento ilimitado do mapa com IPs variados.
func (l *loginLimiter) sweep() {
if len(l.fails) < 8192 {
return
}
now := time.Now()
for ip, b := range l.fails {
if now.After(b.resetAt) {
delete(l.fails, ip)
}
}
}