Files
notas-online/internal/store/files.go
T
lgcosta 6e0ec29888 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
2026-11-05 18:58:10 -03:00

594 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// ---------------------------------------------------------------------------
// Gerenciador de arquivos: árvore de pastas + arquivos com associação opcional
// a uma nota. Persistência: folders.json, files.json e blobs em files/<id>.<ext>.
// ---------------------------------------------------------------------------
// 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"`
Name string `json:"name"`
Parent string `json:"parent"`
CreatedAt time.Time `json:"created_at"`
}
// FileMeta descreve um arquivo anexado ou avulso.
type FileMeta struct {
ID string `json:"id"`
Name string `json:"name"`
Folder string `json:"folder"` // "" = raiz da árvore de arquivos
Note string `json:"note"` // "" = avulso (sem nota)
Size int64 `json:"size"`
MIME string `json:"mime"`
SHA256 string `json:"sha256"`
CreatedAt time.Time `json:"created_at"`
}
// FileNode é a visão de API (meta + URL para <img>/<video>/<a>).
type FileNode struct {
FileMeta
URL string `json:"url"`
}
// TreeNode é a hierarquia completa (pastas aninhadas + arquivos).
type TreeNode struct {
Folder
Children []*TreeNode `json:"children"`
Files []*FileNode `json:"files"`
}
var fileNameRe = regexp.MustCompile(`^[\p{L}\p{N} .,_\-()#]+$`)
func nodeFromFile(m FileMeta) *FileNode {
return &FileNode{FileMeta: m, URL: "/api/files/static/" + m.ID}
}
func (s *Store) foldersPath() string { return filepath.Join(s.dir, "folders.json") }
func (s *Store) filesJSONPath() string { return filepath.Join(s.dir, "files.json") }
func (s *Store) filesRoot() string { return filepath.Join(s.dir, "files") }
// loadFiles carrega os índices de pastas/arquivos (chamado por New).
func (s *Store) loadFiles() error {
s.folders = nil
s.fileMetas = nil
var f []Folder
if err := readJSON(s.foldersPath(), &f); err == nil {
s.folders = f
} else if !errors.Is(err, ErrNotFound) {
return err
}
var m []FileMeta
if err := readJSON(s.filesJSONPath(), &m); err == nil {
s.fileMetas = m
} else if !errors.Is(err, ErrNotFound) {
return err
}
return os.MkdirAll(s.filesRoot(), 0o750)
}
func (s *Store) saveFolders() error { return writeJSONAtomic(s.foldersPath(), s.folders) }
func (s *Store) saveFileMetas() error { return writeJSONAtomic(s.filesJSONPath(), s.fileMetas) }
func (s *Store) folderByID(id string) *Folder {
for i := range s.folders {
if s.folders[i].ID == id {
return &s.folders[i]
}
}
return nil
}
func (s *Store) fileByID(id string) *FileMeta {
for i := range s.fileMetas {
if s.fileMetas[i].ID == id {
return &s.fileMetas[i]
}
}
return nil
}
func (s *Store) folderSubtree(id string) []string {
out := []string{id}
for i := 0; i < len(out); i++ {
for _, f := range s.folders {
if f.Parent == out[i] {
out = append(out, f.ID)
}
}
}
return out
}
func cleanFileName(name string) (string, error) {
name = strings.TrimSpace(name)
name = filepath.Base(name) // nunca aceita path (anti-traversal no nome)
if name == "" || name == "." || name == ".." || len(name) > 200 || !fileNameRe.MatchString(name) {
return "", fmt.Errorf("%w: nome de arquivo inválido", ErrInvalid)
}
return name, nil
}
// extByMime normaliza o MIME detectado quando o sniffing é genérico.
func extByMime(name, sniffed string) string {
switch strings.ToLower(filepath.Ext(name)) {
case ".md", ".markdown":
if strings.HasPrefix(sniffed, "text/plain") {
return "text/markdown; charset=utf-8"
}
case ".html", ".htm":
if strings.HasPrefix(sniffed, "text/plain") {
return "text/html; charset=utf-8"
}
case ".css":
if strings.HasPrefix(sniffed, "text/plain") {
return "text/css; charset=utf-8"
}
case ".js", ".mjs":
if strings.HasPrefix(sniffed, "text/plain") {
return "text/javascript; charset=utf-8"
}
case ".json":
if strings.HasPrefix(sniffed, "text/plain") {
return "application/json"
}
case ".svg":
if strings.Contains(sniffed, "xml") || strings.HasPrefix(sniffed, "text/plain") {
return "image/svg+xml"
}
case ".webp":
if sniffed == "application/octet-stream" {
return "image/webp"
}
case ".mp3":
if sniffed == "application/octet-stream" {
return "audio/mpeg"
}
case ".mp4", ".m4v":
if sniffed == "application/octet-stream" {
return "video/mp4"
}
case ".flac":
if sniffed == "application/octet-stream" {
return "audio/flac"
}
case ".ogg":
if strings.HasPrefix(sniffed, "application/ogg") {
return "audio/ogg"
}
case ".txt", ".log":
if strings.HasPrefix(sniffed, "text/plain") {
return "text/plain; charset=utf-8"
}
}
return sniffed
}
// safeExt decide a extensão física do blob. Extensões perigosas (html/svg/…)
// viram sufixo neutro: o MIME real só vive no metadado e é aplicado no serve.
func safeExt(name, mime string) string {
ext := strings.ToLower(filepath.Ext(name))
if len(ext) > 8 || !regexp.MustCompile(`^\.[a-z0-9]+$`).MatchString(ext) ||
ext == ".html" || ext == ".htm" || ext == ".xhtml" || ext == ".svg" || ext == ".js" || ext == ".mjs" {
switch {
case strings.HasPrefix(mime, "image/"):
return ".img"
case strings.HasPrefix(mime, "video/"):
return ".vid"
case strings.HasPrefix(mime, "audio/"):
return ".aud"
default:
return ".bin"
}
}
return ext
}
func (s *Store) fileBlobPath(id, ext string) string {
return filepath.Join(s.filesRoot(), id+ext)
}
// ---------------------------------------------------------------------------
// Pastas
// ---------------------------------------------------------------------------
// CreateFolder cria uma pasta na árvore de arquivos.
func (s *Store) CreateFolder(name, parent string) (*Folder, error) {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > 100 || !fileNameRe.MatchString(name) {
return nil, fmt.Errorf("%w: nome de pasta inválido", ErrInvalid)
}
s.mu.Lock()
defer s.mu.Unlock()
if parent != "" && s.folderByID(parent) == nil {
return nil, fmt.Errorf("%w: pasta pai inexistente", ErrInvalid)
}
lower := strings.ToLower(name)
for _, f := range s.folders {
if f.Parent == parent && strings.ToLower(f.Name) == lower {
return nil, fmt.Errorf("%w: já existe pasta %q nesse nível", ErrInvalid, name)
}
}
f := Folder{ID: newID(), Name: name, Parent: parent, CreatedAt: time.Now().UTC()}
s.folders = append(s.folders, f)
if err := s.saveFolders(); err != nil {
return nil, err
}
return &f, nil
}
// UpdateFolder renomeia/move pasta com checagem de ciclo.
func (s *Store) UpdateFolder(id, name string, parent *string) (*Folder, error) {
s.mu.Lock()
defer s.mu.Unlock()
f := s.folderByID(id)
if f == nil {
return nil, ErrNotFound
}
if parent != nil && *parent != f.Parent {
if *parent != "" && s.folderByID(*parent) == nil {
return nil, fmt.Errorf("%w: pasta pai inexistente", ErrInvalid)
}
for _, d := range s.folderSubtree(id) {
if d == *parent {
return nil, fmt.Errorf("%w: não é possível mover uma pasta para dentro dela mesma", ErrInvalid)
}
}
f.Parent = *parent
}
if n := strings.TrimSpace(name); n != "" && n != f.Name {
if len([]rune(n)) > 100 || !fileNameRe.MatchString(n) {
return nil, fmt.Errorf("%w: nome de pasta inválido", ErrInvalid)
}
lower := strings.ToLower(n)
for _, other := range s.folders {
if other.ID != id && other.Parent == f.Parent && strings.ToLower(other.Name) == lower {
return nil, fmt.Errorf("%w: já existe pasta %q nesse nível", ErrInvalid, n)
}
}
f.Name = n
}
if err := s.saveFolders(); err != nil {
return nil, err
}
cp := *f
return &cp, nil
}
// DeleteFolder remove a pasta e todo o subtree (incluindo arquivos).
func (s *Store) DeleteFolder(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.folderByID(id) == nil {
return ErrNotFound
}
sub := map[string]bool{}
for _, d := range s.folderSubtree(id) {
sub[d] = true
}
keptFolders := s.folders[:0]
for _, f := range s.folders {
if !sub[f.ID] {
keptFolders = append(keptFolders, f)
}
}
s.folders = keptFolders
keptFiles := s.fileMetas[:0]
for _, m := range s.fileMetas {
if sub[m.Folder] {
_ = os.Remove(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME)))
continue
}
keptFiles = append(keptFiles, m)
}
s.fileMetas = keptFiles
if err := s.saveFolders(); err != nil {
return err
}
return s.saveFileMetas()
}
// ---------------------------------------------------------------------------
// Arquivos
// ---------------------------------------------------------------------------
// AddFile persiste um upload: valida nome/pasta/nota, calcula SHA-256 e MIME
// reais (content sniffing) e grava o blob atomicamente. Limite: MaxFileBytes.
func (s *Store) AddFile(name, folder, note string, r io.Reader) (*FileMeta, error) {
clean, err := cleanFileName(name)
if err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
if folder != "" && s.folderByID(folder) == nil {
return nil, fmt.Errorf("%w: pasta inexistente", ErrInvalid)
}
if note != "" {
if _, err := s.get(note); err != nil {
return nil, fmt.Errorf("%w: nota inexistente", ErrInvalid)
}
}
id := newID()
tmp := s.fileBlobPath(id, ".tmp")
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
if err != nil {
return nil, err
}
fail := func(err error) (*FileMeta, error) {
f.Close()
os.Remove(tmp)
return nil, err
}
h := sha256.New()
head := make([]byte, 512)
n, err := io.ReadFull(r, head)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return fail(err)
}
head = head[:n]
if _, err := f.Write(head); err != nil {
return fail(err)
}
if _, err := h.Write(head); err != nil {
return fail(err)
}
rest, err := io.Copy(io.MultiWriter(f, h), r)
if err != nil {
return fail(err)
}
size := int64(n) + rest
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
}
mime := extByMime(clean, http.DetectContentType(head))
ext := safeExt(clean, mime)
if err := os.Rename(tmp, s.fileBlobPath(id, ext)); err != nil {
os.Remove(tmp)
return nil, err
}
meta := FileMeta{
ID: id, Name: clean, Folder: folder, Note: note, Size: size,
MIME: mime, SHA256: hex.EncodeToString(h.Sum(nil)), CreatedAt: time.Now().UTC(),
}
s.fileMetas = append(s.fileMetas, meta)
if err := s.saveFileMetas(); err != nil {
return nil, err
}
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()
defer s.mu.RUnlock()
out := make([]FileMeta, len(s.fileMetas))
copy(out, s.fileMetas)
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out
}
// GetFile devolve o metadado.
func (s *Store) GetFile(id string) (*FileMeta, error) {
s.mu.RLock()
defer s.mu.RUnlock()
m := s.fileByID(id)
if m == nil {
return nil, ErrNotFound
}
cp := *m
return &cp, nil
}
// MoveFile altera pasta/nome/associação de nota (nil = manter).
func (s *Store) MoveFile(id, name string, folder, note *string) (*FileMeta, error) {
s.mu.Lock()
defer s.mu.Unlock()
m := s.fileByID(id)
if m == nil {
return nil, ErrNotFound
}
if folder != nil {
if *folder != "" && s.folderByID(*folder) == nil {
return nil, fmt.Errorf("%w: pasta inexistente", ErrInvalid)
}
m.Folder = *folder
}
if note != nil {
if *note != "" {
if _, err := s.get(*note); err != nil {
return nil, fmt.Errorf("%w: nota inexistente", ErrInvalid)
}
}
m.Note = *note
}
if n := strings.TrimSpace(name); n != "" && n != m.Name {
clean, err := cleanFileName(n)
if err != nil {
return nil, err
}
m.Name = clean
}
if err := s.saveFileMetas(); err != nil {
return nil, err
}
cp := *m
return &cp, nil
}
// DeleteFile remove metadado + blob.
func (s *Store) DeleteFile(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
idx := -1
for i := range s.fileMetas {
if s.fileMetas[i].ID == id {
idx = i
break
}
}
if idx < 0 {
return ErrNotFound
}
m := s.fileMetas[idx]
_ = os.Remove(s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME)))
s.fileMetas = append(s.fileMetas[:idx], s.fileMetas[idx+1:]...)
return s.saveFileMetas()
}
// FileTree monta a hierarquia de pastas com arquivos.
func (s *Store) FileTree() []*TreeNode {
s.mu.RLock()
defer s.mu.RUnlock()
nodes := map[string]*TreeNode{}
for _, f := range s.folders {
nodes[f.ID] = &TreeNode{Folder: f, Children: []*TreeNode{}, Files: []*FileNode{}}
}
var roots []*TreeNode
for _, f := range s.folders {
n := nodes[f.ID]
if p, ok := nodes[f.Parent]; ok && f.Parent != "" {
p.Children = append(p.Children, n)
} else {
roots = append(roots, n)
}
}
if roots == nil {
roots = []*TreeNode{}
}
rootFiles := []*FileNode{}
for _, m := range s.fileMetas {
n := nodeFromFile(m)
if t, ok := nodes[m.Folder]; ok && m.Folder != "" {
t.Files = append(t.Files, n)
} else {
rootFiles = append(rootFiles, n)
}
}
sort.Slice(roots, func(i, j int) bool { return roots[i].Name < roots[j].Name })
return roots
}
// RootFiles devolve os arquivos da raiz (folder "").
func (s *Store) RootFiles() []*FileNode {
s.mu.RLock()
defer s.mu.RUnlock()
out := []*FileNode{}
for _, m := range s.fileMetas {
if m.Folder == "" {
out = append(out, nodeFromFile(m))
}
}
return out
}
// inlineSafe diz se o tipo pode ser exibido inline com segurança. html/js/svg
// e afins viram download (attachment) — previne stored-XSS same-origin.
func inlineSafe(mime string) bool {
base := strings.ToLower(strings.TrimSpace(strings.Split(mime, ";")[0]))
switch {
case strings.HasPrefix(base, "image/"):
return base != "image/svg+xml"
case strings.HasPrefix(base, "video/"), strings.HasPrefix(base, "audio/"):
return true
case base == "text/plain", base == "text/markdown", base == "text/css",
base == "application/json", base == "application/pdf",
base == "text/csv":
return true
default:
return false
}
}
// ServeFile entrega o blob com headers endurecidos (MIME real, ETag, Range via
// ServeContent, CSP sandbox e attachment para tipos não-inline-seguros).
// A URL aceita apenas o id gerado — nada de path do disco.
func (s *Store) ServeFile(w http.ResponseWriter, r *http.Request, id string) error {
s.mu.RLock()
m := s.fileByID(id)
var p string
if m != nil {
p = s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))
}
cp := m
s.mu.RUnlock()
if cp == nil {
return ErrNotFound
}
f, err := os.Open(p)
if err != nil {
return ErrNotFound
}
defer f.Close()
w.Header().Set("Content-Security-Policy", "sandbox")
w.Header().Set("X-Content-Type-Options", "nosniff")
if !inlineSafe(cp.MIME) {
w.Header().Set("Content-Disposition", `attachment; filename="`+sanitizeHeaderName(cp.Name)+`"`)
}
w.Header().Set("ETag", `"`+cp.SHA256+`"`)
http.ServeContent(w, r, cp.Name, cp.CreatedAt, f)
return nil
}
func sanitizeHeaderName(name string) string {
return strings.Map(func(r rune) rune {
switch r {
case '"', '\\', '\n', '\r', '/':
return '_'
}
return r
}, name)
}
// Folders devolve todas as pastas (lista plana; a UI monta a árvore).
func (s *Store) Folders() []Folder {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]Folder, len(s.folders))
copy(out, s.folders)
return out
}