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
+61
-30
@@ -25,18 +25,29 @@ const cookieMaxAge = 30 * 24 * time.Hour
|
||||
|
||||
// Server agrupa dependências e rotas.
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
token string
|
||||
webFS fs.FS
|
||||
md goldmark.Markdown
|
||||
hub *hub
|
||||
pres *presenceReg
|
||||
st *store.Store
|
||||
token string
|
||||
secure bool // força Secure no cookie mesmo atrás de proxy sem X-Forwarded-Proto
|
||||
webFS fs.FS
|
||||
md goldmark.Markdown
|
||||
hub *hub
|
||||
pres *presenceReg
|
||||
sess *sessionStore
|
||||
login *loginLimiter
|
||||
}
|
||||
|
||||
// Options parametriza o servidor além do token.
|
||||
type Options struct {
|
||||
// SecureCookie força o atributo Secure no cookie de sessão. Use quando
|
||||
// o acesso é sempre por TLS atrás de proxy reverso — não se confia em
|
||||
// X-Forwarded-Proto do cliente para decidir isso.
|
||||
SecureCookie bool
|
||||
}
|
||||
|
||||
// New cria o servidor da API. webFS é o filesystem embutido do frontend
|
||||
// (raiz contém index.html, css/, js/).
|
||||
func New(st *store.Store, token string, webFS fs.FS) *Server {
|
||||
return &Server{
|
||||
func New(st *store.Store, token string, webFS fs.FS, opts ...Options) *Server {
|
||||
s := &Server{
|
||||
st: st,
|
||||
token: token,
|
||||
webFS: webFS,
|
||||
@@ -46,7 +57,14 @@ func New(st *store.Store, token string, webFS fs.FS) *Server {
|
||||
),
|
||||
hub: newHub(),
|
||||
pres: newPresenceReg(),
|
||||
sess: newSessionStore(cookieMaxAge),
|
||||
// 5 tentativas erradas por IP em 15 minutos; acerto reseta
|
||||
login: newLoginLimiter(5, 15*time.Minute),
|
||||
}
|
||||
if len(opts) > 0 {
|
||||
s.secure = opts[0].SecureCookie
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler monta o mux completo (rotas públicas, autenticadas e estáticas).
|
||||
@@ -136,26 +154,27 @@ func (s *Server) wrap(next http.Handler) http.Handler {
|
||||
// Autenticação
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) presentedToken(r *http.Request) string {
|
||||
if v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
return c.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Server) validToken(candidate string) bool {
|
||||
if s.token == "" || candidate == "" {
|
||||
// credOK valida o credencial da requisição por canal separado:
|
||||
// - "Authorization: Bearer <token>" → token mestre (scripts/CLI), constant-time;
|
||||
// - cookie notas_token → id de sessão opaco revogável; o token mestre NUNCA
|
||||
// vale como cookie (o cookie pode vazar por XSS de subdomínio/logs).
|
||||
func (s *Server) credOK(r *http.Request) bool {
|
||||
if s.token == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(candidate), []byte(s.token)) == 1
|
||||
if v, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); ok {
|
||||
return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(v)), []byte(s.token)) == 1
|
||||
}
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
return s.sess.valid(c.Value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// auth valida o credencial por canal (Bearer=token mestre, cookie=sessão).
|
||||
func (s *Server) auth(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validToken(s.presentedToken(r)) {
|
||||
if !s.credOK(r) {
|
||||
writeError(w, http.StatusUnauthorized, "token inválido ou ausente")
|
||||
return
|
||||
}
|
||||
@@ -168,36 +187,48 @@ type loginRequest struct {
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ip := remoteIP(r)
|
||||
if !s.login.allow(ip) {
|
||||
writeError(w, http.StatusTooManyRequests, "muitas tentativas; tente novamente mais tarde")
|
||||
return
|
||||
}
|
||||
var req loginRequest
|
||||
if err := decodeJSON(r, &req, 1<<16); err != nil {
|
||||
if err := decodeJSON(w, r, &req, 1<<16); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
|
||||
return
|
||||
}
|
||||
if !s.validToken(strings.TrimSpace(req.Token)) {
|
||||
if subtle.ConstantTimeCompare([]byte(strings.TrimSpace(req.Token)), []byte(s.token)) != 1 {
|
||||
s.login.fail(ip)
|
||||
slog.Warn("login falhou", "ip", ip)
|
||||
writeError(w, http.StatusUnauthorized, "token inválido")
|
||||
return
|
||||
}
|
||||
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
s.login.success(ip)
|
||||
sid, exp := s.sess.new()
|
||||
secure := s.secure || r.TLS != nil
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: strings.TrimSpace(req.Token),
|
||||
Value: sid,
|
||||
Path: "/",
|
||||
MaxAge: int(cookieMaxAge.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authed": true})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authed": true, "expires_at": exp.UTC().Format(time.RFC3339)})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(CookieName); err == nil {
|
||||
s.sess.revoke(c.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: CookieName, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"authed": false})
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.validToken(s.presentedToken(r)) {
|
||||
if !s.credOK(r) {
|
||||
writeError(w, http.StatusUnauthorized, "não autenticado")
|
||||
return
|
||||
}
|
||||
@@ -208,9 +239,9 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
// JSON helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func decodeJSON(r *http.Request, v any, maxBytes int64) error {
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any, maxBytes int64) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, maxBytes))
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBytes))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return err
|
||||
|
||||
Reference in new issue
Block a user