Files
seaweedfs/weed/server/filer_server_tus_idor_test.go
T
Chris Lu fa549e9c83 filer: harden TUS session authorization against cross-prefix access (#10315)
* filer: authorize TUS existing-session verbs against the validated stored target

Scope-check on TUS HEAD/PATCH/DELETE only populated a resource path for POST,
so a prefix-restricted token that learned another tenant's session id could act
on that session and land content at a TargetPath its own AllowedPrefixes forbid.

Split the filer JWT check into authenticateFilerJwt (signature and method) and
authorizeFilerJwtPaths (resource scope), and make the scope check fail closed:
a prefix-restricted token with no resolved resource path is denied instead of
authorized on signature alone. The TUS handler now authenticates first, reads
and validates the session once, authorizes the stored TargetPath, then operates
on that single pinned snapshot. readTusSessionInfo rejects a session whose id,
target or size is unusable, and getTusSession is split so the authorization
lookup no longer lists chunks.

* filer: reject non-canonical TUS upload ids

The uploads route took the first path component as the session id, so a trailing
path or other non-canonical spelling aliased one session under several URLs.
Require the id to be a canonical UUID, the only form the server mints, both at
routing and when reading a session's metadata, so one URL maps to one resource.

* filer: revalidate the pinned TUS session before completing an upload

Completion re-read chunks but not the session identity, so a PATCH finishing
after a concurrent DELETE or metadata replacement could still land at the id's
stored path. Before completing, confirm the session still exists and its target,
size and creation time are unchanged from the authorized snapshot; otherwise the
completion fails instead of writing to a path the request never authorized.

* filer: log TUS session lookup failures before returning not-found

readTusSessionInfo and loadTusSessionChunks failures answered "not found" with
no log line, so a transient filer or listing error was indistinguishable from a
genuinely missing session. Log the lookup at V(1) (a missing session is common
and benign) and the chunk-load error at Errorf (the session already resolved).
2026-07-11 22:45:28 -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: TusMaxSize + 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())
}
})
}
}