Files
seaweedfs/weed/server/filer_server_tus_jwt_test.go
T
Chris Lu 4f1f0dcb17 filer: require JWT authorization on TUS upload endpoints (#10249)
* filer: require JWT authorization on TUS upload endpoints

filerHandler and readonlyFilerHandler run every request through
maybeCheckJwtAuthorization, but the TUS routes were registered only
behind filerGuard.WhiteList, which is a pass-through for the filer (its
guard is built with an empty whitelist), and no TUS handler called the
JWT check. So with jwt.filer_signing.key set, normal PUT/POST/DELETE
were authorized while the TUS endpoints were not.

Run the same check in tusHandler before routing: HEAD uses the read key,
POST/PATCH/DELETE use the write key, and creation scopes a
prefix-restricted token against the resolved target path. OPTIONS stays
open for capability discovery.

* filer: enforce read-only and WORM rules on TUS completion

completeTusUpload wrote the final entry with CreateEntry directly,
skipping the read-only and WORM checks the normal write path applies.
Reject completion when the target prefix is read-only or the existing
entry at the target is WORM-enforced.

* filer: align TUS write path with the normal write path

Resolve the create target with a guaranteed leading slash so a
prefix-restricted token and the stored path stay absolute even if
TusBasePath were set with a trailing slash. Reject read-only prefixes at
session creation before any chunk is written, and map read-only and WORM
rejections at completion to 507 and 403 instead of a generic 500.

* filer: cover method-restricted TUS tokens in the auth test
2026-07-06 18:25:12 -07:00

112 lines
4.8 KiB
Go

package weed_server
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/seaweedfs/seaweedfs/weed/security"
)
// 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
}
func TestFilerServer_checkTusJwtAuthorization(t *testing.T) {
const writeKey = "write-secret"
const readKey = "read-secret"
fs := &FilerServer{
filerGuard: security.NewGuard(nil, writeKey, 0, readKey, 0),
option: &FilerOption{TusBasePath: "/.tus"},
}
tests := []struct {
name string
method string
path string
token string
expectAuthorized bool
}{
// The advisory: with a filer signing key configured, an unauthenticated
// TUS request must be rejected the same as a normal filer write.
{"create without token denied", http.MethodPost, "/.tus/buckets/secret/owned.txt", "", false},
{"patch without token denied", http.MethodPatch, "/.tus/.uploads/abc", "", false},
{"delete without token denied", http.MethodDelete, "/.tus/.uploads/abc", "", false},
{"head without token denied", http.MethodHead, "/.tus/.uploads/abc", "", false},
// A valid token for the right access level is accepted.
{"create with write token allowed", http.MethodPost, "/.tus/buckets/data/ok.txt", signFilerToken(t, writeKey, nil, nil), true},
{"patch with write token allowed", http.MethodPatch, "/.tus/.uploads/abc", signFilerToken(t, writeKey, nil, nil), true},
{"head with read token allowed", http.MethodHead, "/.tus/.uploads/abc", signFilerToken(t, readKey, nil, nil), true},
// HEAD is a read, so a write-only token must not authorize it and a read
// token must not authorize a write.
{"head with write token denied", http.MethodHead, "/.tus/.uploads/abc", signFilerToken(t, writeKey, nil, nil), false},
{"create with read token denied", http.MethodPost, "/.tus/buckets/data/ok.txt", signFilerToken(t, readKey, nil, nil), false},
// Prefix-restricted tokens are scoped against the resolved target path
// (URL minus the /.tus prefix), not the /.tus route.
{"create within allowed prefix", http.MethodPost, "/.tus/buckets/allowed/ok.txt", signFilerToken(t, writeKey, []string{"/buckets/allowed"}, nil), true},
{"create outside allowed prefix denied", http.MethodPost, "/.tus/buckets/secret/owned.txt", signFilerToken(t, writeKey, []string{"/buckets/allowed"}, nil), false},
// Method-restricted tokens are checked against the actual HTTP method.
{"patch with matching method allowed", http.MethodPatch, "/.tus/.uploads/abc", signFilerToken(t, writeKey, nil, []string{"POST", "PATCH", "DELETE"}), true},
{"patch outside allowed methods denied", http.MethodPatch, "/.tus/.uploads/abc", signFilerToken(t, writeKey, nil, []string{"POST"}), 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.checkTusJwtAuthorization(req); got != tt.expectAuthorized {
t.Errorf("checkTusJwtAuthorization(%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 write is
// rejected with 401 before any session is created.
func TestFilerServer_tusHandler_UnauthenticatedRejected(t *testing.T) {
fs := &FilerServer{
filerGuard: security.NewGuard(nil, "write-secret", 0, "read-secret", 0),
option: &FilerOption{TusBasePath: "/.tus"},
}
// 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)
}
}