Files
seaweedfs/weed/server/filer_server_tus_jwt_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

213 lines
8.9 KiB
Go

package weed_server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/filer"
"github.com/seaweedfs/seaweedfs/weed/security"
"github.com/seaweedfs/seaweedfs/weed/util"
)
const (
tusTestWriteKey = "write-secret"
tusTestReadKey = "read-secret"
tusTestUploadID = "9f6f0d4b-6556-48f6-b953-0d8fca1966f1"
)
// signFilerToken builds a signed filer JWT for tests.
func signFilerToken(t *testing.T, signingKey string, allowedPrefixes, allowedMethods []string) string {
t.Helper()
claims := security.SeaweedFilerClaims{
AllowedPrefixes: allowedPrefixes,
AllowedMethods: allowedMethods,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
},
}
str, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(signingKey))
if err != nil {
t.Fatalf("failed to sign token: %v", err)
}
return str
}
// newTusTestServer builds a FilerServer backed by an in-memory store seeded with
// TUS sessions (uploadID -> stored TargetPath), so the handler can resolve a
// session's target the way production does. The store is returned so a test can
// mutate or inspect the seeded session.
func newTusTestServer(t *testing.T, sessions map[string]string) (*FilerServer, *renameTestStore) {
t.Helper()
store := newRenameTestStore()
fs := &FilerServer{
filer: newRenameTestFiler(store),
filerGuard: security.NewGuard(nil, tusTestWriteKey, 0, tusTestReadKey, 0),
option: &FilerOption{TusBasePath: "/.tus"},
}
for uploadID, targetPath := range sessions {
seedTusSession(t, fs, store, TusSession{ID: uploadID, TargetPath: targetPath, Size: 1})
}
return fs, store
}
// seedTusSession writes a session directory and its .info metadata into the store.
func seedTusSession(t *testing.T, fs *FilerServer, store *renameTestStore, session TusSession) {
t.Helper()
data, err := json.Marshal(&session)
if err != nil {
t.Fatalf("marshal session %s: %v", session.ID, err)
}
dir := &filer.Entry{FullPath: util.FullPath(fs.tusSessionPath(session.ID)), Attr: filer.Attr{Mode: os.ModeDir | 0755}}
info := &filer.Entry{FullPath: util.FullPath(fs.tusSessionInfoPath(session.ID)), Content: data}
for _, entry := range []*filer.Entry{dir, info} {
if err := store.InsertEntry(context.Background(), entry); err != nil {
t.Fatalf("seed session %s: %v", session.ID, err)
}
}
}
func TestFilerServer_checkJwtAuthorization(t *testing.T) {
fs := &FilerServer{
filerGuard: security.NewGuard(nil, tusTestWriteKey, 0, tusTestReadKey, 0),
option: &FilerOption{TusBasePath: "/.tus"},
}
victim := []string{"/buckets/secret/victim.bin"}
uploadPath := "/.tus/.uploads/" + tusTestUploadID
tests := []struct {
name string
method string
path string
token string
scopedPaths []string
expectAuthorized bool
}{
// With a filer signing key configured, an unauthenticated request is denied.
{"post without token denied", http.MethodPost, "/.tus/buckets/secret/owned.txt", "", []string{"/buckets/secret/owned.txt"}, false},
{"patch without token denied", http.MethodPatch, uploadPath, "", victim, false},
{"head without token denied", http.MethodHead, uploadPath, "", victim, false},
// A valid token for the right access level is accepted.
{"post write token allowed", http.MethodPost, "/.tus/buckets/data/ok.txt", signFilerToken(t, tusTestWriteKey, nil, nil), []string{"/buckets/data/ok.txt"}, true},
{"head read token allowed", http.MethodHead, uploadPath, signFilerToken(t, tusTestReadKey, nil, nil), victim, true},
// HEAD is a read, so a write token must not authorize it, nor a read token a write.
{"head write token denied", http.MethodHead, uploadPath, signFilerToken(t, tusTestWriteKey, nil, nil), victim, false},
{"post read token denied", http.MethodPost, "/.tus/buckets/data/ok.txt", signFilerToken(t, tusTestReadKey, nil, nil), []string{"/buckets/data/ok.txt"}, false},
// Prefix-restricted tokens are scoped against the resolved target path.
{"within allowed prefix", http.MethodPost, "/.tus/buckets/allowed/ok.txt", signFilerToken(t, tusTestWriteKey, []string{"/buckets/allowed"}, nil), []string{"/buckets/allowed/ok.txt"}, true},
{"outside allowed prefix denied", http.MethodPost, "/.tus/buckets/secret/owned.txt", signFilerToken(t, tusTestWriteKey, []string{"/buckets/allowed"}, nil), []string{"/buckets/secret/owned.txt"}, false},
// Fail closed: a prefix-restricted token with no resolved resource is denied.
{"restricted token without resource denied", http.MethodPatch, uploadPath, signFilerToken(t, tusTestWriteKey, []string{"/buckets/allowed"}, nil), nil, false},
// Method-restricted tokens are checked against the actual HTTP method.
{"matching method allowed", http.MethodPatch, uploadPath, signFilerToken(t, tusTestWriteKey, nil, []string{"POST", "PATCH", "DELETE"}), victim, true},
{"method not allowed denied", http.MethodPatch, uploadPath, signFilerToken(t, tusTestWriteKey, nil, []string{"POST"}), victim, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, nil)
if tt.token != "" {
req.Header.Set("Authorization", "Bearer "+tt.token)
}
if got := fs.checkJwtAuthorization(req, tt.method != http.MethodHead, tt.scopedPaths); got != tt.expectAuthorized {
t.Errorf("checkJwtAuthorization(%s %s) = %v, want %v", tt.method, tt.path, got, tt.expectAuthorized)
}
})
}
}
// TestFilerServer_tusHandler_UnauthenticatedRejected exercises the full handler
// entry point: OPTIONS discovery stays open, but an unauthenticated request is
// rejected with 401 before any session is created or looked up.
func TestFilerServer_tusHandler_UnauthenticatedRejected(t *testing.T) {
fs, _ := newTusTestServer(t, map[string]string{tusTestUploadID: "/buckets/secret/victim.bin"})
// OPTIONS is capability discovery and must not require a token.
optionsReq := httptest.NewRequest(http.MethodOptions, "/.tus/buckets/secret/owned.txt", nil)
optionsRec := httptest.NewRecorder()
fs.tusHandler(optionsRec, optionsReq)
if optionsRec.Code != http.StatusOK {
t.Errorf("OPTIONS without token = %d, want %d", optionsRec.Code, http.StatusOK)
}
// POST without a token must be rejected before touching the filer store.
postReq := httptest.NewRequest(http.MethodPost, "/.tus/buckets/secret/owned.txt", nil)
postReq.Header.Set("Tus-Resumable", TusVersion)
postReq.Header.Set("Upload-Length", "5")
postRec := httptest.NewRecorder()
fs.tusHandler(postRec, postReq)
if postRec.Code != http.StatusUnauthorized {
t.Errorf("unauthenticated POST = %d, want %d", postRec.Code, http.StatusUnauthorized)
}
// A missing credential is rejected before the upload id triggers a metadata
// lookup, so an existing session is not an unauthenticated resource oracle.
headReq := httptest.NewRequest(http.MethodHead, "/.tus/.uploads/"+tusTestUploadID, nil)
headReq.Header.Set("Tus-Resumable", TusVersion)
headRec := httptest.NewRecorder()
fs.tusHandler(headRec, headReq)
if headRec.Code != http.StatusUnauthorized {
t.Errorf("unauthenticated HEAD = %d, want %d", headRec.Code, http.StatusUnauthorized)
}
}
// TestFilerServer_refreshTusSessionChunks_RevalidatesPinnedSession verifies a
// pinned session cannot complete after it is deleted or its target is replaced
// between authorization and completion.
func TestFilerServer_refreshTusSessionChunks_RevalidatesPinnedSession(t *testing.T) {
pin := func(t *testing.T) (*FilerServer, *renameTestStore, *TusSession) {
t.Helper()
fs, store := newTusTestServer(t, nil)
seedTusSession(t, fs, store, TusSession{
ID: tusTestUploadID,
TargetPath: "/buckets/secret/victim.bin",
Size: 1,
CreatedAt: time.Unix(1700000000, 123),
})
session, err := fs.readTusSessionInfo(context.Background(), tusTestUploadID)
if err != nil {
t.Fatalf("pin session: %v", err)
}
return fs, store, session
}
t.Run("deleted before completion", func(t *testing.T) {
fs, store, session := pin(t)
if err := store.DeleteEntry(context.Background(), util.FullPath(fs.tusSessionInfoPath(tusTestUploadID))); err != nil {
t.Fatalf("delete session info: %v", err)
}
if err := fs.refreshTusSessionChunks(context.Background(), session); err == nil {
t.Fatal("refresh succeeded after the session was deleted")
}
})
t.Run("replaced before completion", func(t *testing.T) {
fs, store, session := pin(t)
replaced := *session
replaced.TargetPath = "/buckets/other/replacement.bin"
data, err := json.Marshal(&replaced)
if err != nil {
t.Fatalf("marshal replacement: %v", err)
}
if err := store.InsertEntry(context.Background(), &filer.Entry{
FullPath: util.FullPath(fs.tusSessionInfoPath(tusTestUploadID)),
Content: data,
}); err != nil {
t.Fatalf("replace session info: %v", err)
}
if err := fs.refreshTusSessionChunks(context.Background(), session); err == nil {
t.Fatal("refresh succeeded after the session target was replaced")
}
})
}