mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-02 18:22:40 +01:00
* filer: scope a prefix JWT to the directory a zip extract writes under Without a trailing slash, an extract request writes members beside the named path, in its parent directory, while the JWT check only saw the request path. A token scoped to /tenant1 could therefore extract into / by posting to /tenant1?extract=zip. Add the extract root to the paths a prefix-restricted token must cover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * filer: do not keep an extracted archive under the name ".." A multipart file name of ".." passes path.Base unchanged, so extract.keep stored the archive as an entry literally named ".." in the target directory, a name that local sinks resolve to the parent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * leveldb3: keep a non-local bucket name out of the per-bucket folder findDB splits the bucket out of a /buckets/ path and uses it as a folder name under the store directory. The name cannot hold a slash and dot names already go to the default DB, so this only rejects an empty or non-local name, but it also lets static analysis see the folder cannot escape the store directory. * filer: clean the zip extract root before scoping and writing A percent-encoded dot segment survives the path cleaning in front of the mux, so a request path ending in "/" reached extractZipRoot with ".." in it. The JWT scope check cleans the path it compares, but the members were written under the raw root, so the two could disagree. * leveldb3: apply the bucket folder check to bucket create and delete events findDB already keeps dot and non-local bucket names out of the per-bucket folders, but OnBucketCreation and OnBucketDeletion used the name as is. A directory event for /buckets/.. opened a DB in the parent of the store directory, and its delete removed that parent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * leveldb3: keep a bucket delete event off the default DB A bucket named _main maps to the default DB in findDB, so deleting /buckets/_main closed the default DB and removed its folder, taking every non-bucket entry with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * leveldb3: keep bucket names with a path separator out of the store dir A bucket event named x/../_main passed the local-name check, and its delete removed the default DB folder once store.dir/x existed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * leveldb3: only reject the platform separator in a bucket name A backslash is an ordinary file name character on Unix, and a bucket folder named that way already exists for anyone who created one. Rejecting it on every platform would route those entries to the default DB after an upgrade. The slash can never appear in a bucket name here, and on Windows the backslash is the separator, so rejecting os.PathSeparator alongside the slash keeps the folder flat without changing Unix routing. * leveldb3: check for an escaped bucket db before its delete event --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
319 lines
10 KiB
Go
319 lines
10 KiB
Go
package weed_server
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/json"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/util"
|
|
)
|
|
|
|
func buildTestZip(t *testing.T, members map[string]string, dirs []string, unsafe []string) []byte {
|
|
t.Helper()
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
for _, dir := range dirs {
|
|
h := &zip.FileHeader{Name: dir, Method: zip.Store, Modified: time.Unix(1700000000, 0)}
|
|
h.SetMode(0755 | 1<<31)
|
|
if _, err := zw.CreateHeader(h); err != nil {
|
|
t.Fatalf("create dir %s: %v", dir, err)
|
|
}
|
|
}
|
|
write := func(name, content string) {
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
t.Fatalf("create %s: %v", name, err)
|
|
}
|
|
if _, err := w.Write([]byte(content)); err != nil {
|
|
t.Fatalf("write %s: %v", name, err)
|
|
}
|
|
}
|
|
for name, content := range members {
|
|
write(name, content)
|
|
}
|
|
for _, name := range unsafe {
|
|
write(name, "evil")
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatalf("close zip: %v", err)
|
|
}
|
|
return buf.Bytes()
|
|
}
|
|
|
|
func newExtractTestServer(t *testing.T) (*FilerServer, *filer.Filer) {
|
|
store := newRenameTestStore()
|
|
f := newRenameTestFiler(t, store)
|
|
return &FilerServer{
|
|
filer: f,
|
|
option: &FilerOption{
|
|
MaxMB: 1,
|
|
SaveToFilerLimit: 1 << 20,
|
|
},
|
|
}, f
|
|
}
|
|
|
|
func TestExtractZipArchivePut(t *testing.T) {
|
|
fs, f := newExtractTestServer(t)
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
dirHeader := &zip.FileHeader{Name: "empty/", Method: zip.Store, Modified: time.Unix(1700000000, 0)}
|
|
dirHeader.SetMode(0755 | 1<<31)
|
|
if _, err := zw.CreateHeader(dirHeader); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fileHeader := &zip.FileHeader{Name: "a/b.txt", Modified: time.Unix(1600000000, 0)}
|
|
fileHeader.SetMode(0600)
|
|
fw, err := zw.CreateHeader(fileHeader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := fw.Write([]byte("hello")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pngW, err := zw.Create("c.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := pngW.Write([]byte("\x89png")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
evil, err := zw.Create("../evil.txt")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := evil.Write([]byte("evil")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zipBytes := buf.Bytes()
|
|
|
|
r := httptest.NewRequest(http.MethodPut, "/dst/archive.zip?extract=zip", bytes.NewReader(zipBytes))
|
|
w := httptest.NewRecorder()
|
|
fs.PostHandler(w, r, int64(len(zipBytes)))
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201, body: %s", w.Code, w.Body.String())
|
|
}
|
|
var result FilerExtractResult
|
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("decode result: %v", err)
|
|
}
|
|
if len(result.Extracted) != 2 {
|
|
t.Fatalf("extracted %d members, want 2: %+v", len(result.Extracted), result)
|
|
}
|
|
if len(result.Errors) != 1 {
|
|
t.Fatalf("errors = %v, want 1 unsafe-name entry", result.Errors)
|
|
}
|
|
|
|
entry, err := f.FindEntry(r.Context(), util.FullPath("/dst/a/b.txt"))
|
|
if err != nil {
|
|
t.Fatalf("find extracted member: %v", err)
|
|
}
|
|
if string(entry.Content) != "hello" {
|
|
t.Fatalf("member content = %q", entry.Content)
|
|
}
|
|
if entry.Mtime.Unix() != 1600000000 {
|
|
t.Fatalf("member mtime = %v, want %v", entry.Mtime, time.Unix(1600000000, 0))
|
|
}
|
|
if entry.Mode.Perm() != 0600 {
|
|
t.Fatalf("member mode = %v, want 0600", entry.Mode.Perm())
|
|
}
|
|
|
|
dirEntry, err := f.FindEntry(r.Context(), util.FullPath("/dst/empty"))
|
|
if err != nil || !dirEntry.IsDirectory() {
|
|
t.Fatalf("dir member missing: %v %+v", err, dirEntry)
|
|
}
|
|
if dirEntry.Mtime.Unix() != 1700000000 {
|
|
t.Fatalf("dir mtime = %v, want %v", dirEntry.Mtime, time.Unix(1700000000, 0))
|
|
}
|
|
if _, err := f.FindEntry(r.Context(), util.FullPath("/dst/archive.zip")); err == nil {
|
|
t.Fatal("archive should not be stored without extract.keep")
|
|
}
|
|
if _, err := f.FindEntry(r.Context(), util.FullPath("/dst/../evil.txt")); err == nil {
|
|
t.Fatal("unsafe member should not be extracted")
|
|
}
|
|
if _, err := f.FindEntry(r.Context(), util.FullPath("/evil.txt")); err == nil {
|
|
t.Fatal("zip-slip member escaped the extraction root")
|
|
}
|
|
}
|
|
|
|
func TestExtractZipArchiveKeep(t *testing.T) {
|
|
fs, f := newExtractTestServer(t)
|
|
zipBytes := buildTestZip(t, map[string]string{"x.txt": "data"}, nil, nil)
|
|
|
|
r := httptest.NewRequest(http.MethodPut, "/dst/pack.zip?extract=zip&extract.keep=true", bytes.NewReader(zipBytes))
|
|
w := httptest.NewRecorder()
|
|
fs.PostHandler(w, r, int64(len(zipBytes)))
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
archive, err := f.FindEntry(r.Context(), util.FullPath("/dst/pack.zip"))
|
|
if err != nil {
|
|
t.Fatalf("archive not stored with extract.keep: %v", err)
|
|
}
|
|
if int64(len(archive.Content)) != int64(len(zipBytes)) {
|
|
t.Fatalf("archive size = %d, want %d", len(archive.Content), len(zipBytes))
|
|
}
|
|
if _, err := f.FindEntry(r.Context(), util.FullPath("/dst/x.txt")); err != nil {
|
|
t.Fatalf("member missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExtractZipArchiveKeepSkipsParentName(t *testing.T) {
|
|
fs, f := newExtractTestServer(t)
|
|
zipBytes := buildTestZip(t, map[string]string{"x.txt": "data"}, nil, nil)
|
|
|
|
var body bytes.Buffer
|
|
mw := multipartWriter(t, &body, "..", zipBytes)
|
|
r := httptest.NewRequest(http.MethodPost, "/a/b/?extract=zip&extract.keep=true", &body)
|
|
r.Header.Set("Content-Type", mw)
|
|
w := httptest.NewRecorder()
|
|
fs.PostHandler(w, r, int64(body.Len()))
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
if _, err := f.FindEntry(r.Context(), util.FullPath("/a/b/..")); err == nil {
|
|
t.Fatal("archive kept under a parent-directory name")
|
|
}
|
|
if _, err := f.FindEntry(r.Context(), util.FullPath("/a/b/x.txt")); err != nil {
|
|
t.Fatalf("member missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExtractZipArchiveNotAZip(t *testing.T) {
|
|
fs, _ := newExtractTestServer(t)
|
|
r := httptest.NewRequest(http.MethodPut, "/dst/notzip.bin?extract=zip", bytes.NewReader([]byte("not a zip")))
|
|
w := httptest.NewRecorder()
|
|
fs.PostHandler(w, r, 9)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want 400", w.Code)
|
|
}
|
|
}
|
|
|
|
// A member larger than chunkSize must not take the inline fast path when
|
|
// SaveToFilerLimit exceeds chunkSize — the first chunk is not the whole member.
|
|
func TestExtractZipArchiveMemberLargerThanChunk(t *testing.T) {
|
|
store := newRenameTestStore()
|
|
f := newRenameTestFiler(t, store)
|
|
fs, _ := newInlineAppendUploadServer(t, f, 4<<20, 1) // inline limit exceeds the 1 MiB chunkSize
|
|
member := bytes.Repeat([]byte("z"), (1<<20)+1)
|
|
zipBytes := buildTestZip(t, map[string]string{"big.bin": string(member)}, nil, nil)
|
|
|
|
r := httptest.NewRequest(http.MethodPut, "/dst/pack.zip?extract=zip", bytes.NewReader(zipBytes))
|
|
w := httptest.NewRecorder()
|
|
fs.PostHandler(w, r, int64(len(zipBytes)))
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
var result FilerExtractResult
|
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
|
t.Fatalf("decode result: %v", err)
|
|
}
|
|
if len(result.Errors) != 0 {
|
|
t.Fatalf("member errors: %v", result.Errors)
|
|
}
|
|
entry, err := f.FindEntry(r.Context(), util.FullPath("/dst/big.bin"))
|
|
if err != nil {
|
|
t.Fatalf("member missing: %v", err)
|
|
}
|
|
if int64(entry.Attr.FileSize) != int64(len(member)) {
|
|
t.Fatalf("member stored truncated: %d bytes, want %d", entry.Attr.FileSize, len(member))
|
|
}
|
|
}
|
|
|
|
func TestExtractZipArchiveRawPost(t *testing.T) {
|
|
fs, f := newExtractTestServer(t)
|
|
zipBytes := buildTestZip(t, map[string]string{"raw.txt": "raw"}, nil, nil)
|
|
|
|
r := httptest.NewRequest(http.MethodPost, "/dst/?extract=zip", bytes.NewReader(zipBytes))
|
|
r.Header.Set("Content-Type", "application/zip")
|
|
w := httptest.NewRecorder()
|
|
fs.PostHandler(w, r, int64(len(zipBytes)))
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
entry, err := f.FindEntry(r.Context(), util.FullPath("/dst/raw.txt"))
|
|
if err != nil {
|
|
t.Fatalf("member missing: %v", err)
|
|
}
|
|
if string(entry.Content) != "raw" {
|
|
t.Fatalf("member content = %q", entry.Content)
|
|
}
|
|
}
|
|
|
|
func TestExtractZipArchiveCleansDotSegments(t *testing.T) {
|
|
fs, f := newExtractTestServer(t)
|
|
zipBytes := buildTestZip(t, map[string]string{"raw.txt": "raw"}, nil, nil)
|
|
|
|
// A percent-encoded ".." survives path cleaning in front of the mux, so the
|
|
// handler sees it decoded and must land members under the cleaned root.
|
|
r := httptest.NewRequest(http.MethodPost, "/other/%2e%2e/dst/?extract=zip", bytes.NewReader(zipBytes))
|
|
r.Header.Set("Content-Type", "application/zip")
|
|
w := httptest.NewRecorder()
|
|
fs.PostHandler(w, r, int64(len(zipBytes)))
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
if _, err := f.FindEntry(r.Context(), util.FullPath("/dst/raw.txt")); err != nil {
|
|
t.Fatalf("member missing under the cleaned root: %v", err)
|
|
}
|
|
if _, err := f.FindEntry(r.Context(), util.FullPath("/other/../dst/raw.txt")); err == nil {
|
|
t.Fatal("member written under the raw root")
|
|
}
|
|
}
|
|
|
|
func TestExtractZipArchiveMultipartPost(t *testing.T) {
|
|
fs, f := newExtractTestServer(t)
|
|
zipBytes := buildTestZip(t, map[string]string{"img/1.png": "png-bytes"}, nil, nil)
|
|
|
|
var body bytes.Buffer
|
|
mw := multipartWriter(t, &body, "bundle.zip", zipBytes)
|
|
r := httptest.NewRequest(http.MethodPost, "/assets/?extract=zip", &body)
|
|
r.Header.Set("Content-Type", mw)
|
|
w := httptest.NewRecorder()
|
|
fs.PostHandler(w, r, int64(body.Len()))
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, body: %s", w.Code, w.Body.String())
|
|
}
|
|
entry, err := f.FindEntry(r.Context(), util.FullPath("/assets/img/1.png"))
|
|
if err != nil {
|
|
t.Fatalf("member missing: %v", err)
|
|
}
|
|
if string(entry.Content) != "png-bytes" {
|
|
t.Fatalf("member content = %q", entry.Content)
|
|
}
|
|
if _, err := f.FindEntry(r.Context(), util.FullPath("/assets/bundle.zip")); err == nil {
|
|
t.Fatal("archive should not be stored")
|
|
}
|
|
}
|
|
|
|
func multipartWriter(t *testing.T, buf *bytes.Buffer, fileName string, data []byte) string {
|
|
t.Helper()
|
|
mw := multipart.NewWriter(buf)
|
|
part, err := mw.CreateFormFile("file", fileName)
|
|
if err != nil {
|
|
t.Fatalf("create form file: %v", err)
|
|
}
|
|
if _, err := part.Write(data); err != nil {
|
|
t.Fatalf("write part: %v", err)
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
t.Fatalf("close multipart: %v", err)
|
|
}
|
|
return mw.FormDataContentType()
|
|
}
|