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
@@ -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"]
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user