From 6e0ec298880180125939d013c776e708352ab840 Mon Sep 17 00:00:00 2001 From: lgcosta Date: Thu, 5 Nov 2026 18:58:10 -0300 Subject: [PATCH] =?UTF-8?q?fix(security):=20corrige=20achados=20da=20varre?= =?UTF-8?q?dura=20=E2=80=94=20render,=20brute-force,=20upload,=20WYSIWYG,?= =?UTF-8?q?=20cookie,=20sess=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 3 + .../.claude-security-run/scan-meta.json | 28 ++++++ .../.claude-security-run/target-files.json | 1 + README.md | 21 +++-- cmd/notas/main.go | 5 +- docker-compose.yml | 2 + internal/api/api_test.go | 79 +++++++++++++++- internal/api/categories.go | 4 +- internal/api/files.go | 17 +++- internal/api/files_test.go | 26 ++++++ internal/api/limit.go | 86 ++++++++++++++++++ internal/api/notes.go | 4 +- internal/api/presence.go | 2 +- internal/api/render.go | 11 ++- internal/api/revisions.go | 2 +- internal/api/server.go | 91 +++++++++++++------ internal/api/sessions.go | 73 +++++++++++++++ internal/store/files.go | 16 ++++ web/js/app.js | 7 ++ web/sw.js | 2 +- 20 files changed, 427 insertions(+), 53 deletions(-) create mode 100644 CLAUDE-SECURITY-20261105-212932/.claude-security-run/scan-meta.json create mode 100644 CLAUDE-SECURITY-20261105-212932/.claude-security-run/target-files.json create mode 100644 internal/api/limit.go create mode 100644 internal/api/sessions.go diff --git a/.env.example b/.env.example index b9de378..6059c25 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE-SECURITY-20261105-212932/.claude-security-run/scan-meta.json b/CLAUDE-SECURITY-20261105-212932/.claude-security-run/scan-meta.json new file mode 100644 index 0000000..4bba631 --- /dev/null +++ b/CLAUDE-SECURITY-20261105-212932/.claude-security-run/scan-meta.json @@ -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": [] +} diff --git a/CLAUDE-SECURITY-20261105-212932/.claude-security-run/target-files.json b/CLAUDE-SECURITY-20261105-212932/.claude-security-run/target-files.json new file mode 100644 index 0000000..d3a79a4 --- /dev/null +++ b/CLAUDE-SECURITY-20261105-212932/.claude-security-run/target-files.json @@ -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"] diff --git a/README.md b/README.md index 7c29f3d..d272403 100644 --- a/README.md +++ b/README.md @@ -364,14 +364,21 @@ curl -sI -H "Authorization: Bearer $T" $B/api/files/static/ | 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 diff --git a/cmd/notas/main.go b/cmd/notas/main.go index a7fa002..25a8222 100644 --- a/cmd/notas/main.go +++ b/cmd/notas/main.go @@ -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, diff --git a/docker-compose.yml b/docker-compose.yml index 61d6d84..1e7e730 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 2f611fb..36f00f6 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -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) + } +} diff --git a/internal/api/categories.go b/internal/api/categories.go index 3ac19b4..d2b93da 100644 --- a/internal/api/categories.go +++ b/internal/api/categories.go @@ -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 } diff --git a/internal/api/files.go b/internal/api/files.go index bb05162..f41e9de 100644 --- a/internal/api/files.go +++ b/internal/api/files.go @@ -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 } diff --git a/internal/api/files_test.go b/internal/api/files_test.go index 7a32c7e..0a7e42f 100644 --- a/internal/api/files_test.go +++ b/internal/api/files_test.go @@ -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) + } +} diff --git a/internal/api/limit.go b/internal/api/limit.go new file mode 100644 index 0000000..5f49c43 --- /dev/null +++ b/internal/api/limit.go @@ -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) + } + } +} diff --git a/internal/api/notes.go b/internal/api/notes.go index 1704a78..858eefc 100644 --- a/internal/api/notes.go +++ b/internal/api/notes.go @@ -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 } diff --git a/internal/api/presence.go b/internal/api/presence.go index ddf43ca..e52e6c3 100644 --- a/internal/api/presence.go +++ b/internal/api/presence.go @@ -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 } diff --git a/internal/api/render.go b/internal/api/render.go index aef4928..1269890 100644 --- a/internal/api/render.go +++ b/internal/api/render.go @@ -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. diff --git a/internal/api/revisions.go b/internal/api/revisions.go index cb6ae68..97900de 100644 --- a/internal/api/revisions.go +++ b/internal/api/revisions.go @@ -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 } diff --git a/internal/api/server.go b/internal/api/server.go index 1890af2..8927201 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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 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 diff --git a/internal/api/sessions.go b/internal/api/sessions.go new file mode 100644 index 0000000..82a3102 --- /dev/null +++ b/internal/api/sessions.go @@ -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 +} diff --git a/internal/store/files.go b/internal/store/files.go index 51272de..e662b39 100644 --- a/internal/store/files.go +++ b/internal/store/files.go @@ -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() diff --git a/web/js/app.js b/web/js/app.js index 57ac047..1d4f625 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -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; } diff --git a/web/sw.js b/web/sw.js index 7653c6b..f71155b 100644 --- a/web/sw.js +++ b/web/sw.js @@ -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',