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
87 lines
1.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|