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:
lgcosta committed 2026-11-05 18:58:10 -03:00
1 parent 1b9ce995ba
commit 6e0ec29888
20 files changed
+427 -53

No files matched your search

+3
View File
@@ -3,5 +3,8 @@
NOTES_TOKEN=troque-este-token
# Porta do host (opcional):
# NOTAS_PORT=9090
# Marcar o cookie de sessão como Secure — ative quando o acesso é SEMPRE por
# HTTPS (proxy reverso TLS); nunca se decide isso pelo header do cliente:
# NOTES_SECURE_COOKIE=true
# Podman rootless: use 0:0 (mapeia para o seu usuário no host, escreve em ./data sem chown):
# NOTAS_USER=0:0
@@ -0,0 +1,28 @@
{
"scan_id": "e31ff46c-fb11-489c-a9e7-32f7bf9b73dd",
"started_at": "2026-11-05T21:29:33+00:00",
"scan_root": "/home/lgcosta/lab/notas-online",
"scan_prefix": "",
"remote": "https://git.labexposed.com/lgcosta/notas-online",
"run_dir": "/home/lgcosta/lab/notas-online/CLAUDE-SECURITY-20261105-212932/.claude-security-run",
"flow": "scan",
"agent": "claude-security:claude-security",
"mode": "scan",
"scope": [],
"effort": "medium",
"asked_for_max": false,
"model": null,
"revision": {
"versioned": true,
"commit": "1b9ce995ba364ac63b99f963503df78af51bcad2",
"branch": "main",
"dirty": false
},
"revision_source": "self-reported",
"top_level_dirs": [
"cmd",
"internal",
"web"
],
"unfollowed_symlinks": []
}
@@ -0,0 +1 @@
[".dockerignore", ".env.example", ".gitignore", "Dockerfile", "README.md", "cmd/notas/main.go", "docker-compose.yml", "go.mod", "go.sum", "internal/api/api_test.go", "internal/api/categories.go", "internal/api/files.go", "internal/api/files_test.go", "internal/api/notes.go", "internal/api/presence.go", "internal/api/render.go", "internal/api/revisions.go", "internal/api/server.go", "internal/diffx/diffx.go", "internal/diffx/diffx_test.go", "internal/store/files.go", "internal/store/store.go", "internal/store/store_test.go", "web/css/app.css", "web/embed.go", "web/icons/icon-192.png", "web/icons/icon-512.png", "web/icons/maskable-192.png", "web/icons/maskable-512.png", "web/icons/monochrome-512.png", "web/index.html", "web/js/app.js", "web/manifest.webmanifest", "web/sw.js"]
+14 -7
View File
@@ -364,14 +364,21 @@ curl -sI -H "Authorization: Bearer $T" $B/api/files/static/<id> | grep -i dispos
## Segurança
- Token verificado em **tempo constante**; nunca é registrado nos logs.
- Sessão do navegador em cookie **HttpOnly + SameSite=Lax** (mitiga CSRF); o token em
`localStorage` foi preterido de propósito por ser exposto a XSS.
- Token verificado em **tempo constante**; nunca é registrado nos logs. **Login tem rate-limit por
IP** (5 tentativas erradas em 15 min → 429; acerto reseta; log de falha com IP).
- Sessão do navegador em cookie **HttpOnly + SameSite=Lax** contendo um **id de sessão opaco**
(`crypto/rand`, 32 bytes) — o token mestre nunca trafega em cookie. Logout **revoga** a sessão no
servidor; reiniciar o processo revoga todas (relogin). O token mestre vale apenas via
`Authorization: Bearer` (scripts/CLI).
- **Cookie `Secure` é decisão de configuração** (`NOTES_SECURE_COOKIE=true` no `.env`), nunca de
`X-Forwarded-Proto` do cliente (spoofável). Ative quando o acesso é sempre por HTTPS.
- CSP restritiva (`default-src 'self'`), `X-Frame-Options: DENY`, `nosniff`; preview de HTML roda
em iframe `sandbox` sem mesma origem; Markdown renderiza com HTML cru escapado.
- Limite de 2 MiB por nota; corpo HTTP limitado a 4 MiB.
- Rode atrás de TLS (reverse proxy) em produção — o cookie marca `Secure` automaticamente quando
a requisição chega por HTTPS/`X-Forwarded-Proto`. O modelo é **token único compartilhado**
em iframe `sandbox` sem mesma origem; Markdown renderiza com HTML cru escapado; o editor
WYSIWYG neutraliza `href/src` com esquemas perigosos (`javascript:`, `vbscript:`, `data:`).
- Limite de 2 MiB por nota; corpo HTTP limitado a 4 MiB; `/api/render` aceita no máximo 512 KiB de
entrada (a conversão amplifica ~2×); upload limitado a 64 MiB **no corpo inteiro** e a
**2 GiB de total** de anexos por instância (quota contra esgotamento de disco).
- Rode atrás de TLS (reverse proxy) em produção. O modelo é **token único compartilhado**
(sem usuários múltiplos); a imagem final é `scratch` non-root (uid 65534) — sem shell para
`exec` de depuração, por design.
- Preferências do editor e abas abertas ficam em `localStorage` (dados não sensíveis, por
+4 -1
View File
@@ -40,13 +40,16 @@ func run() error {
return errors.New("NOTES_TOKEN não definido — defina o token de acesso " +
"(ex.: NOTES_TOKEN=$(openssl rand -hex 24) docker compose up -d)")
}
// Secure no cookie só é decidido por configuração explícita: X-Forwarded-Proto
// do cliente é spoofável. Ative quando o acesso é sempre por HTTPS (proxy).
secureCookie := envOr("NOTES_SECURE_COOKIE", "") == "true"
st, err := store.New(*dataDir)
if err != nil {
return err
}
handler := api.New(st, token, web.FS).Handler()
handler := api.New(st, token, web.FS, api.Options{SecureCookie: secureCookie}).Handler()
srv := &http.Server{
Addr: *addr,
Handler: handler,
+2
View File
@@ -10,6 +10,8 @@ services:
# Obrigatório: defina em .env (veja .env.example) ou exporte no shell.
NOTES_TOKEN: ${NOTES_TOKEN:?defina NOTES_TOKEN em .env ou no ambiente}
NOTES_DATA_DIR: /data
# Cookie Secure: ative no .env quando o acesso é sempre por HTTPS.
NOTES_SECURE_COOKIE: ${NOTES_SECURE_COOKIE:-false}
volumes:
# Dados no diretório local ./data (backup = copiar a pasta)
- ./data:/data
+77 -2
View File
@@ -90,17 +90,23 @@ func TestAuthRequiredAndLoginFlow(t *testing.T) {
t.Fatalf("login errado: %s", resp.Status)
}
// Login correto: cookie HttpOnly emitido.
// Login correto: cookie HttpOnly com ID DE SESSÃO opaco (nunca o token mestre).
resp = do(t, srv, "POST", "/api/login", `{"token":"`+testToken+`"}`, "", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("login: %s", resp.Status)
}
sc := resp.Header.Get("Set-Cookie")
if !strings.Contains(sc, "notas_token="+testToken) || !strings.Contains(sc, "HttpOnly") ||
if !strings.HasPrefix(sc, "notas_token=") || !strings.Contains(sc, "HttpOnly") ||
strings.Contains(sc, "Max-Age=0") {
t.Errorf("Set-Cookie inadequado: %q", sc)
}
cookieValue := strings.SplitN(strings.TrimPrefix(sc, "notas_token="), ";", 2)[0]
if cookieValue == testToken || strings.Contains(sc, testToken) {
t.Errorf("cookie não deveria carregar o token mestre: %q", sc)
}
if len(cookieValue) != 64 {
t.Errorf("id de sessão deveria ter 64 hex chars (32 bytes), tem %d", len(cookieValue))
}
// /api/me com o cookie.
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+cookieValue)
@@ -472,3 +478,72 @@ func TestInvalidPayloads(t *testing.T) {
}
resp.Body.Close()
}
func TestLoginRateLimitPerIP(t *testing.T) {
srv := newTestServer(t)
for i := 0; i < 5; i++ {
resp := do(t, srv, "POST", "/api/login", `{"token":"errada"}`, "", "")
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("tentativa %d: esperava 401, obtive %s", i+1, resp.Status)
}
}
// 6ª tentativa errada: bloqueada por IP
resp := do(t, srv, "POST", "/api/login", `{"token":"errada"}`, "", "")
resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("esperava 429 após 5 falhas, obtive %s", resp.Status)
}
// bloqueio vale até para o token CORRETO (o IP está penalizado)
resp = do(t, srv, "POST", "/api/login", `{"token":"`+testToken+`"}`, "", "")
resp.Body.Close()
if resp.StatusCode != http.StatusTooManyRequests {
t.Fatalf("esperava 429 mesmo com token correto, obtive %s", resp.Status)
}
}
func TestSessionRevokedOnLogoutAndBadSessionRejected(t *testing.T) {
srv := newTestServer(t)
resp := do(t, srv, "POST", "/api/login", `{"token":"`+testToken+`"}`, "", "")
sc := resp.Header.Get("Set-Cookie")
resp.Body.Close()
sid := strings.SplitN(strings.TrimPrefix(sc, "notas_token="), ";", 2)[0]
// sessão válida autentica
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+sid)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("sessão válida: %s", resp.Status)
}
// token mestre cru NO cookie não vale mais (cookie = só id de sessão)
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+testToken)
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("token mestre no cookie deveria ser rejeitado, obtive %s", resp.Status)
}
// token mestre via Bearer continua valendo (scripts/CLI)
resp = do(t, srv, "GET", "/api/me", "", testToken, "")
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("bearer com token mestre: %s", resp.Status)
}
// logout revoga a sessão
resp = do(t, srv, "POST", "/api/logout", "", "", "notas_token="+sid)
resp.Body.Close()
resp = do(t, srv, "GET", "/api/me", "", "", "notas_token="+sid)
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("sessão após logout deveria estar revogada, obtive %s", resp.Status)
}
}
func TestRenderRejectsOversizeInput(t *testing.T) {
srv := newTestServer(t)
big := strings.Repeat("x", 512<<10+1)
body := `{"content":"` + big + `","format":"md"}`
resp := do(t, srv, "POST", "/api/render", body, testToken, "")
resp.Body.Close()
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("esperava 413 em render >512KiB, obtive %s", resp.Status)
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ type categoryInput struct {
func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
var in categoryInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
@@ -29,7 +29,7 @@ func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleUpdateCategory(w http.ResponseWriter, r *http.Request) {
var in categoryInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
+12 -5
View File
@@ -2,6 +2,8 @@ package api
import (
"net/http"
"github.com/lgcosta/notas-online/internal/store"
)
// ---------------------------------------------------------------------------
@@ -9,10 +11,15 @@ import (
// ---------------------------------------------------------------------------
// handleFileUpload recebe multipart/form-data com campo "file" e parâmetros
// opcionais folder_id e note_id.
// opcionais folder_id e note_id. O corpo inteiro é limitado a MaxFileBytes +
// folga de multipart (64 MiB + 64 KiB): sem o cap, o parser do multipart
// grava o corpo inteiro em temporários antes de qualquer checagem.
const maxUploadBody = store.MaxFileBytes + 64<<10
func (s *Server) handleFileUpload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBody)
if err := r.ParseMultipartForm(8 << 20); err != nil {
writeError(w, http.StatusBadRequest, "multipart inválido: "+err.Error())
writeError(w, http.StatusBadRequest, "multipart inválido ou excede limite: "+err.Error())
return
}
defer r.MultipartForm.RemoveAll()
@@ -65,7 +72,7 @@ type fileMoveInput struct {
func (s *Server) handleFileMove(w http.ResponseWriter, r *http.Request) {
var in fileMoveInput
if r.ContentLength > 0 {
if err := decodeJSON(r, &in, 1<<20); err != nil {
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
@@ -89,7 +96,7 @@ type folderInput struct {
func (s *Server) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
var in folderInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
@@ -107,7 +114,7 @@ func (s *Server) handleFolderCreate(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleFolderUpdate(w http.ResponseWriter, r *http.Request) {
var in folderInput
if err := decodeJSON(r, &in, 1<<20); err != nil {
if err := decodeJSON(w, r, &in, 1<<20); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
+26
View File
@@ -9,6 +9,8 @@ import (
"net/http/httptest"
"strings"
"testing"
"github.com/lgcosta/notas-online/internal/store"
)
// auth faz uma chamada autenticada via helper comum.
@@ -230,3 +232,27 @@ func TestFilesAPI(t *testing.T) {
}
noAuth.Body.Close()
}
func TestUploadRejectsOversizeBody(t *testing.T) {
// corpo maior que o cap (64 MiB + 64 KiB): MaxBytesReader deve abortar
// antes do multipart ser gravado em temporários
big := bytes.Repeat([]byte("A"), store.MaxFileBytes+128<<10)
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, _ := w.CreateFormFile("file", "big.bin")
_, _ = fw.Write(big)
_ = w.Close()
srv := newTestServer(t)
req, _ := http.NewRequest("POST", srv.URL+"/api/files/upload", &buf)
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+testToken)
resp, err := srv.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("esperava 400 para corpo acima do cap, obtive %s", resp.Status)
}
}
+86
View File
@@ -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)
}
}
}
+2 -2
View File
@@ -87,7 +87,7 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
var in noteInput
if err := decodeJSON(r, &in, maxBody); err != nil {
if err := decodeJSON(w, r, &in, maxBody); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
@@ -112,7 +112,7 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
var in noteInput
if err := decodeJSON(r, &in, maxBody); err != nil {
if err := decodeJSON(w, r, &in, maxBody); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
+1 -1
View File
@@ -210,7 +210,7 @@ type presenceInput struct {
// handlePresence recebe o heartbeat {session, notes:[ids abertos]}.
func (s *Server) handlePresence(w http.ResponseWriter, r *http.Request) {
var in presenceInput
if err := decodeJSON(r, &in, 1<<16); err != nil {
if err := decodeJSON(w, r, &in, 1<<16); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
+10 -1
View File
@@ -7,6 +7,11 @@ import (
const maxBody = 4 << 20 // folga sobre o limite de conteúdo da store (2 MiB)
// maxRenderInput limita o tamanho do markdown aceito pelo endpoint de render:
// conversão goldmark é O(n)–O(n²) por extensão; sem cap, 1 MiB perverso de
// entrada produz ~2 MiB de HTML (amplificação medida) e consome CPU.
const maxRenderInput = 512 << 10 // 512 KiB
type renderInput struct {
Content string `json:"content"`
Format string `json:"format"`
@@ -16,10 +21,14 @@ type renderInput struct {
// escapado pelo goldmark; a nota em si é sempre renderizada via innerHTML).
func (s *Server) handleRender(w http.ResponseWriter, r *http.Request) {
var in renderInput
if err := decodeJSON(r, &in, maxBody); err != nil {
if err := decodeJSON(w, r, &in, maxBody); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
if len(in.Content) > maxRenderInput {
writeError(w, http.StatusRequestEntityTooLarge, "conteúdo para render excede 512 KiB")
return
}
switch in.Format {
case "html":
// O preview de HTML usa iframe com sandbox no cliente; não renderiza aqui.
+1 -1
View File
@@ -87,7 +87,7 @@ func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
}
var in restoreInput
if r.ContentLength > 0 {
if err := decodeJSON(r, &in, 1<<16); err != nil {
if err := decodeJSON(w, r, &in, 1<<16); err != nil {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
+61 -30
View File
@@ -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
+73
View File
@@ -0,0 +1,73 @@
package api
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// sessionStore mantém sessões de login em memória: o cookie do navegador
// carrega um id de sessão opaco — nunca o token mestre. Reiniciar o processo
// revoga tudo (o usuário faz login de novo); logout individual revoga na hora.
// Não há crypto própria: id vem de crypto/rand.
type sessionStore struct {
mu sync.Mutex
m map[string]time.Time // id → expiração
ttl time.Duration
}
func newSessionStore(ttl time.Duration) *sessionStore {
return &sessionStore{m: map[string]time.Time{}, ttl: ttl}
}
// new cria uma sessão e devolve (id, expiração).
func (ss *sessionStore) new() (string, time.Time) {
var buf [32]byte
if _, err := rand.Read(buf[:]); err != nil {
// crypto/rand falhar é condição fatal de sistema; não há fallback seguro
panic("crypto/rand indisponível: " + err.Error())
}
id := hex.EncodeToString(buf[:])
exp := time.Now().Add(ss.ttl)
ss.mu.Lock()
ss.m[id] = exp
ss.mu.Unlock()
return id, exp
}
// valid confere se o id existe e não expirou; expirados são removidos no passe.
func (ss *sessionStore) valid(id string) bool {
if id == "" {
return false
}
now := time.Now()
ss.mu.Lock()
defer ss.mu.Unlock()
exp, ok := ss.m[id]
if !ok {
return false
}
if now.After(exp) {
delete(ss.m, id)
return false
}
// limpeza oportunista: mapa cresceu além do saudável → poda expirados
if len(ss.m) > 4096 {
for k, e := range ss.m {
if now.After(e) {
delete(ss.m, k)
}
}
}
return true
}
// revoke remove a sessão; devolve true se existia.
func (ss *sessionStore) revoke(id string) bool {
ss.mu.Lock()
defer ss.mu.Unlock()
_, ok := ss.m[id]
delete(ss.m, id)
return ok
}
+16
View File
@@ -23,6 +23,10 @@ import (
// MaxFileBytes limita o tamanho de cada anexo (64 MiB).
const MaxFileBytes = 64 << 20
// MaxFilesTotalBytes limita o total de anexos (2 GiB). Sem quota agregada,
// uploads concorrentes (1000 × 64 MiB) esgotam o disco do host.
const MaxFilesTotalBytes = 2048 << 20
// Folder é um nó da árvore de pastas (parent "" = raiz).
type Folder struct {
ID string `json:"id"`
@@ -362,6 +366,9 @@ func (s *Store) AddFile(name, folder, note string, r io.Reader) (*FileMeta, erro
if size > MaxFileBytes {
return fail(fmt.Errorf("%w: arquivo excede %d MiB", ErrTooLarge, MaxFileBytes>>20))
}
if total := s.totalFileBytesLocked(); size+total > MaxFilesTotalBytes {
return fail(fmt.Errorf("%w: total de anexos excede %d GiB", ErrTooLarge, MaxFilesTotalBytes>>30))
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return nil, err
@@ -385,6 +392,15 @@ func (s *Store) AddFile(name, folder, note string, r io.Reader) (*FileMeta, erro
return &meta, nil
}
// totalFileBytesLocked soma o tamanho dos anexos; requer lock de escrita.
func (s *Store) totalFileBytesLocked() int64 {
var total int64
for i := range s.fileMetas {
total += s.fileMetas[i].Size
}
return total
}
// Files devolve todos os metadados (ordenados por data desc).
func (s *Store) Files() []FileMeta {
s.mu.RLock()
+7
View File
@@ -1407,6 +1407,13 @@
doc.body.querySelectorAll('*').forEach((n) => {
for (const a of [...n.attributes]) if (/^on/i.test(a.name)) n.removeAttribute(a.name);
});
// href/src com esquema perigoso (javascript:, data:text/html, vbscript:…)
// não executam inline, mas um clique/copy basta para o vetor — neutra
doc.body.querySelectorAll('[href],[src]').forEach((n) => {
const attr = n.hasAttribute('href') ? 'href' : 'src';
const v = n.getAttribute(attr);
if (v && /^[ \t\r\n]*(javascript|vbscript|data)[ \t\r\n]*:/i.test(v)) n.setAttribute(attr, '#');
});
return doc.body.innerHTML;
}
+1 -1
View File
@@ -1,7 +1,7 @@
/* Service Worker do Notas — caches versionados.
* BUMP de CACHE em todo deploy que mude shell/assets (o activate limpa os antigos).
*/
const CACHE = 'notas-shell-v15';
const CACHE = 'notas-shell-v16';
const SHELL = [
'/',
'/css/app.css',