Lista conteudo de .zip, .tar, .tar.gz e .gz sem extrair, com limite anti-bomba.
This commit is contained in:
1 parent
dc829b9d43
commit
f6a74e1092
6 files changed
+389
-15
No files matched your search
@@ -1,6 +1,9 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
@@ -56,6 +59,162 @@ type FileNode struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// ArchiveEntry descreve um item dentro de um arquivo compactado.
|
||||
type ArchiveEntry struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"` // -1 = tamanho desconhecido sem extrair
|
||||
Dir bool `json:"dir"`
|
||||
}
|
||||
|
||||
// maxArchiveEntries limita quantos itens da listagem são devolvidos.
|
||||
const maxArchiveEntries = 2000
|
||||
|
||||
// maxArchiveScanBytes limita a leitura ao contar bytes descomprimidos de um
|
||||
// .gz simples (proteção contra bomba de descompressão).
|
||||
const maxArchiveScanBytes = 1 << 30
|
||||
|
||||
// isArchiveExt diz se o nome tem extensão de compactado com listagem
|
||||
// suportada (zip/tar/tar.gz/tgz/gz, sem extrair nada em disco).
|
||||
func isArchiveExt(name string) bool {
|
||||
l := strings.ToLower(name)
|
||||
return strings.HasSuffix(l, ".zip") || strings.HasSuffix(l, ".tar") ||
|
||||
strings.HasSuffix(l, ".tar.gz") || strings.HasSuffix(l, ".tgz") ||
|
||||
strings.HasSuffix(l, ".gz")
|
||||
}
|
||||
|
||||
// cleanArchiveName higieniza um nome vindo de dentro do compactado para
|
||||
// exibição (UTF-8 válido, tamanho limitado).
|
||||
func cleanArchiveName(name string) string {
|
||||
name = strings.ToValidUTF8(name, "�")
|
||||
name = strings.Trim(name, "\x00")
|
||||
if len(name) > 512 {
|
||||
name = name[:512] + "…"
|
||||
}
|
||||
if name == "" {
|
||||
name = "(sem nome)"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// ListArchive lista o conteúdo de um compactado (.zip, .tar, .tar.gz, .tgz
|
||||
// ou .gz simples) lendo só os cabeçalhos — nada é extraído em disco.
|
||||
// Devolve os itens (no máximo maxArchiveEntries), o total e se houve corte.
|
||||
func (s *Store) ListArchive(id string) (entries []ArchiveEntry, total int, truncated bool, err error) {
|
||||
s.mu.RLock()
|
||||
m := s.fileByID(id)
|
||||
var p, name string
|
||||
if m != nil {
|
||||
name = m.Name
|
||||
p = s.fileBlobPath(m.ID, safeExt(m.Name, m.MIME))
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if m == nil {
|
||||
return nil, 0, false, ErrNotFound
|
||||
}
|
||||
if !isArchiveExt(name) {
|
||||
return nil, 0, false, fmt.Errorf("%w: listagem suportada apenas para .zip, .tar, .tar.gz e .gz", ErrInvalid)
|
||||
}
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return nil, 0, false, ErrNotFound
|
||||
}
|
||||
defer f.Close()
|
||||
lower := strings.ToLower(name)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".zip"):
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, 0, false, ErrNotFound
|
||||
}
|
||||
return listZipEntries(f, st.Size())
|
||||
case strings.HasSuffix(lower, ".tar"):
|
||||
return listTarEntries(f)
|
||||
default: // .tar.gz, .tgz ou .gz simples
|
||||
single := !strings.Contains(lower, ".tar") && !strings.HasSuffix(lower, ".tgz")
|
||||
return listTarGzEntries(f, single, strings.TrimSuffix(name, filepath.Ext(name)))
|
||||
}
|
||||
}
|
||||
|
||||
// listZipEntries lê o diretório central do zip (sem descomprimir).
|
||||
func listZipEntries(r io.ReaderAt, size int64) ([]ArchiveEntry, int, bool, error) {
|
||||
zr, err := zip.NewReader(r, size)
|
||||
if err != nil {
|
||||
return nil, 0, false, fmt.Errorf("%w: zip inválido ou corrompido", ErrInvalid)
|
||||
}
|
||||
total := len(zr.File)
|
||||
out := make([]ArchiveEntry, 0, min(total, maxArchiveEntries))
|
||||
for _, zf := range zr.File {
|
||||
if len(out) >= maxArchiveEntries {
|
||||
return out, total, true, nil
|
||||
}
|
||||
out = append(out, ArchiveEntry{
|
||||
Name: cleanArchiveName(zf.Name),
|
||||
Size: int64(zf.UncompressedSize64),
|
||||
Dir: zf.FileInfo().IsDir(),
|
||||
})
|
||||
}
|
||||
return out, total, false, nil
|
||||
}
|
||||
|
||||
// listTarEntries percorre os cabeçalhos do tar (sem extrair).
|
||||
func listTarEntries(r io.Reader) ([]ArchiveEntry, int, bool, error) {
|
||||
tr := tar.NewReader(r)
|
||||
var out []ArchiveEntry
|
||||
total := 0
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
if total == 0 {
|
||||
return nil, 0, false, fmt.Errorf("%w: tar inválido ou corrompido", ErrInvalid)
|
||||
}
|
||||
return out, total, true, nil
|
||||
}
|
||||
total++
|
||||
if len(out) < maxArchiveEntries {
|
||||
out = append(out, ArchiveEntry{
|
||||
Name: cleanArchiveName(hdr.Name),
|
||||
Size: hdr.Size,
|
||||
Dir: hdr.FileInfo().IsDir(),
|
||||
})
|
||||
}
|
||||
}
|
||||
if out == nil {
|
||||
out = []ArchiveEntry{}
|
||||
}
|
||||
return out, total, total > len(out), nil
|
||||
}
|
||||
|
||||
// listTarGzEntries abre um .tar.gz/.tgz (lista o tar interno) ou um .gz
|
||||
// simples (descreve o arquivo único, contando bytes com teto anti-bomba).
|
||||
func listTarGzEntries(r io.Reader, single bool, fallbackName string) ([]ArchiveEntry, int, bool, error) {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return nil, 0, false, fmt.Errorf("%w: gzip inválido ou corrompido", ErrInvalid)
|
||||
}
|
||||
defer gz.Close()
|
||||
if !single {
|
||||
return listTarEntries(gz)
|
||||
}
|
||||
name := strings.TrimSpace(gz.Name)
|
||||
if name == "" {
|
||||
name = fallbackName
|
||||
}
|
||||
n, err := io.Copy(io.Discard, io.LimitReader(gz, maxArchiveScanBytes+1))
|
||||
if err != nil {
|
||||
return nil, 0, false, fmt.Errorf("%w: gzip inválido ou corrompido", ErrInvalid)
|
||||
}
|
||||
size := n
|
||||
truncated := false
|
||||
if n > maxArchiveScanBytes {
|
||||
size = maxArchiveScanBytes
|
||||
truncated = true
|
||||
}
|
||||
return []ArchiveEntry{{Name: cleanArchiveName(name), Size: size}}, 1, truncated, nil
|
||||
}
|
||||
|
||||
// TreeNode é a hierarquia completa (pastas aninhadas + arquivos).
|
||||
type TreeNode struct {
|
||||
Folder
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeZip(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := zip.NewWriter(&buf)
|
||||
fw, err := w.Create("docs/nota.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write([]byte("ola"))
|
||||
w.Create("vazia/")
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func makeTar(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
w := tar.NewWriter(&buf)
|
||||
body := []byte("abc")
|
||||
w.WriteHeader(&tar.Header{Name: "a.txt", Mode: 0o600, Size: int64(len(body))})
|
||||
w.Write(body)
|
||||
w.WriteHeader(&tar.Header{Name: "dir", Typeflag: tar.TypeDir, Mode: 0o755})
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestListArchiveZip(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
m, err := s.AddFile("backup.zip", "", "", bytes.NewReader(makeZip(t)))
|
||||
if err != nil {
|
||||
t.Fatalf("AddFile: %v", err)
|
||||
}
|
||||
entries, total, trunc, err := s.ListArchive(m.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListArchive: %v", err)
|
||||
}
|
||||
if total != 2 || trunc || len(entries) != 2 {
|
||||
t.Fatalf("inesperado: total=%d trunc=%v entries=%v", total, trunc, entries)
|
||||
}
|
||||
if entries[0].Name != "docs/nota.txt" || entries[0].Dir || entries[0].Size != 3 {
|
||||
t.Errorf("entry0: %+v", entries[0])
|
||||
}
|
||||
if entries[1].Name != "vazia/" || !entries[1].Dir {
|
||||
t.Errorf("entry1: %+v", entries[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestListArchiveTarAndRejectsOthers(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
m, err := s.AddFile("dados.tar", "", "", bytes.NewReader(makeTar(t)))
|
||||
if err != nil {
|
||||
t.Fatalf("AddFile: %v", err)
|
||||
}
|
||||
entries, total, _, err := s.ListArchive(m.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListArchive: %v", err)
|
||||
}
|
||||
if total != 2 || len(entries) != 2 || entries[0].Name != "a.txt" {
|
||||
t.Fatalf("inesperado: total=%d entries=%v", total, entries)
|
||||
}
|
||||
txt, err := s.AddFile("nota.txt", "", "", strings.NewReader("oi"))
|
||||
if err != nil {
|
||||
t.Fatalf("AddFile: %v", err)
|
||||
}
|
||||
if _, _, _, err := s.ListArchive(txt.ID); err == nil {
|
||||
t.Fatalf("esperava erro para .txt")
|
||||
}
|
||||
if _, _, _, err := s.ListArchive("inexistente01"); err == nil {
|
||||
t.Fatalf("esperava erro para id inexistente")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user