Files
seaweedfs/weed/server/filer_server_tus_idor_test.go
Chris LuandGitHub cab666fca1 filer: configurable TUS max upload size and session expiry (#10638)
* make TUS max upload size and session expiry configurable

* default TUS session expiry to 24h
2026-08-07 21:56:15 -07:00

115 lines
4.7 KiB
Go

package weed_server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/util"
)
// TestFilerServer_tusHandler_CrossPrefixSessionHijack reproduces
// GHSA-99q7-x53r-6j4g: a prefix-restricted token acting on another tenant's TUS
// session (HEAD/PATCH/DELETE) must be scoped against the session's stored
// TargetPath, not authorized on signature and method alone. The victim's session
// must survive a denied mutation.
func TestFilerServer_tusHandler_CrossPrefixSessionHijack(t *testing.T) {
tests := []struct {
name string
method string
prefix string
expectStatus int
expectExists bool
}{
{"cross-prefix HEAD denied", http.MethodHead, "/buckets/allowed", http.StatusUnauthorized, true},
{"matching-prefix HEAD allowed", http.MethodHead, "/buckets/secret", http.StatusOK, true},
{"cross-prefix PATCH denied", http.MethodPatch, "/buckets/allowed", http.StatusUnauthorized, true},
{"matching-prefix PATCH allowed", http.MethodPatch, "/buckets/secret", http.StatusNoContent, true},
{"cross-prefix DELETE denied", http.MethodDelete, "/buckets/allowed", http.StatusUnauthorized, true},
{"matching-prefix DELETE allowed", http.MethodDelete, "/buckets/secret", http.StatusNoContent, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs, store := newTusTestServer(t, map[string]string{tusTestUploadID: "/buckets/secret/victim.bin"})
signingKey := tusTestWriteKey
if tt.method == http.MethodHead {
signingKey = tusTestReadKey
}
token := signFilerToken(t, signingKey, []string{tt.prefix}, nil)
req := httptest.NewRequest(tt.method, "/.tus/.uploads/"+tusTestUploadID, http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Tus-Resumable", TusVersion)
if tt.method == http.MethodPatch {
req.Header.Set("Content-Type", "application/offset+octet-stream")
req.Header.Set("Upload-Offset", "0")
}
rec := httptest.NewRecorder()
fs.tusHandler(rec, req)
if rec.Code != tt.expectStatus {
t.Fatalf("%s status = %d, want %d; body=%q", tt.method, rec.Code, tt.expectStatus, rec.Body.String())
}
_, err := store.FindEntry(context.Background(), util.FullPath(fs.tusSessionInfoPath(tusTestUploadID)))
if tt.expectExists && err != nil {
t.Fatalf("session removed after %s: %v", tt.method, err)
}
if !tt.expectExists && err == nil {
t.Fatalf("session still present after authorized %s", tt.method)
}
})
}
}
// TestFilerServer_tusHandler_RejectsAliasesAndInvalidMetadata covers the routing
// and metadata guards: a non-canonical or aliased upload id is rejected before
// any lookup, and a session whose stored id, target or size is unusable resolves
// to "not found" rather than being authorized or acted upon.
func TestFilerServer_tusHandler_RejectsAliasesAndInvalidMetadata(t *testing.T) {
tests := []struct {
name string
routeID string
stored TusSession
}{
{"trailing path alias", tusTestUploadID + "/extra", TusSession{ID: tusTestUploadID, TargetPath: "/buckets/secret/victim.bin", Size: 1}},
{"non-canonical route id", "not-a-uuid", TusSession{ID: tusTestUploadID, TargetPath: "/buckets/secret/victim.bin", Size: 1}},
{"stored id mismatch", tusTestUploadID, TusSession{ID: "00000000-0000-0000-0000-000000000000", TargetPath: "/buckets/secret/victim.bin", Size: 1}},
{"empty stored target", tusTestUploadID, TusSession{ID: tusTestUploadID, TargetPath: "", Size: 1}},
{"root stored target", tusTestUploadID, TusSession{ID: tusTestUploadID, TargetPath: "/", Size: 1}},
{"relative stored target", tusTestUploadID, TusSession{ID: tusTestUploadID, TargetPath: "buckets/secret/x.bin", Size: 1}},
{"oversize stored size", tusTestUploadID, TusSession{ID: tusTestUploadID, TargetPath: "/buckets/secret/victim.bin", Size: TusDefaultMaxSize + 1}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs, store := newTusTestServer(t, nil)
data, err := json.Marshal(&tt.stored)
if err != nil {
t.Fatalf("marshal session: %v", err)
}
if err := store.InsertEntry(context.Background(), &filer.Entry{
FullPath: util.FullPath(fs.tusSessionInfoPath(tusTestUploadID)),
Content: data,
}); err != nil {
t.Fatalf("seed session: %v", err)
}
req := httptest.NewRequest(http.MethodHead, "/.tus/.uploads/"+tt.routeID, nil)
req.Header.Set("Authorization", "Bearer "+signFilerToken(t, tusTestReadKey, nil, nil))
req.Header.Set("Tus-Resumable", TusVersion)
rec := httptest.NewRecorder()
fs.tusHandler(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("HEAD %s = %d, want %d; body=%q", tt.routeID, rec.Code, http.StatusNotFound, rec.Body.String())
}
})
}
}