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
This commit is contained in:
1 parent
1b9ce995ba
commit
6e0ec29888
20 files changed
+427
-53
No files matched your search
@@ -0,0 +1,86 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user