filer: scope JWT allowed_prefixes to path components (#9439)

The allowed_prefixes check used a literal byte-prefix match, so a token
scoped to /tenant1 also matched /tenant1234, /tenant1-old, and similar
sibling paths. Match on /-separated path components after path.Clean
normalisation instead.
This commit is contained in:
Chris Lu
2026-05-12 10:10:48 -07:00
committed by GitHub
parent bd687a2d7a
commit 05ed5c9ae8
2 changed files with 106 additions and 1 deletions
+78
View File
@@ -137,6 +137,54 @@ func TestFilerServer_maybeCheckJwtAuthorization_Scoped(t *testing.T) {
isWrite: false,
expectAuthorized: true,
},
{
name: "tenant prefix does not match sibling tenant",
token: genToken([]string{"/tenant1"}, nil),
method: "GET",
path: "/tenant1234/secret",
isWrite: false,
expectAuthorized: false,
},
{
name: "tenant prefix does not match dashed sibling",
token: genToken([]string{"/tenant1"}, nil),
method: "GET",
path: "/tenant1-old/secret",
isWrite: false,
expectAuthorized: false,
},
{
name: "tenant prefix matches own subtree",
token: genToken([]string{"/tenant1"}, nil),
method: "GET",
path: "/tenant1/ok.txt",
isWrite: false,
expectAuthorized: true,
},
{
name: "tenant prefix with trailing slash matches own subtree",
token: genToken([]string{"/tenant1/"}, nil),
method: "GET",
path: "/tenant1/ok.txt",
isWrite: false,
expectAuthorized: true,
},
{
name: "root prefix matches anywhere",
token: genToken([]string{"/"}, nil),
method: "GET",
path: "/tenant1234/secret",
isWrite: false,
expectAuthorized: true,
},
{
name: "dot-dot cannot escape allowed subtree",
token: genToken([]string{"/tenant1"}, nil),
method: "GET",
path: "/tenant1/../tenant2/secret",
isWrite: false,
expectAuthorized: false,
},
}
for _, tt := range tests {
@@ -151,3 +199,33 @@ func TestFilerServer_maybeCheckJwtAuthorization_Scoped(t *testing.T) {
})
}
}
func TestPathHasComponentPrefix(t *testing.T) {
tests := []struct {
name string
path string
prefix string
want bool
}{
{"exact match", "/tenant1", "/tenant1", true},
{"subtree match", "/tenant1/a/b", "/tenant1", true},
{"trailing slash prefix", "/tenant1/a", "/tenant1/", true},
{"sibling numeric suffix", "/tenant1234/secret", "/tenant1", false},
{"sibling dash suffix", "/tenant1-old/secret", "/tenant1", false},
{"sibling dot suffix", "/tenant1.bak/x", "/tenant1", false},
{"unrelated tree", "/other/x", "/tenant1", false},
{"root prefix matches root", "/", "/", true},
{"root prefix matches any", "/tenant1234/secret", "/", true},
{"empty prefix denies", "/tenant1/x", "", false},
{"dot-dot does not escape", "/tenant1/../tenant2/secret", "/tenant1", false},
{"dot-dot stays inside", "/tenant1/a/../b", "/tenant1", true},
{"double slashes normalised", "/tenant1//a", "/tenant1", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := pathHasComponentPrefix(tt.path, tt.prefix); got != tt.want {
t.Errorf("pathHasComponentPrefix(%q, %q) = %v, want %v", tt.path, tt.prefix, got, tt.want)
}
})
}
}
+28 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"path"
"strconv"
"strings"
"sync/atomic"
@@ -256,7 +257,7 @@ func (fs *FilerServer) maybeCheckJwtAuthorization(r *http.Request, isWrite bool)
if len(claims.AllowedPrefixes) > 0 {
hasPrefix := false
for _, prefix := range claims.AllowedPrefixes {
if strings.HasPrefix(r.URL.Path, prefix) {
if pathHasComponentPrefix(r.URL.Path, prefix) {
hasPrefix = true
break
}
@@ -283,6 +284,32 @@ func (fs *FilerServer) maybeCheckJwtAuthorization(r *http.Request, isWrite bool)
return true
}
// pathHasComponentPrefix reports whether reqPath is contained within the
// directory subtree denoted by prefix, treating both as "/"-separated
// path components. Both inputs are normalised with path.Clean to neutralise
// "." and ".." segments and collapse duplicate slashes. A prefix of "/"
// matches any path.
func pathHasComponentPrefix(reqPath, prefix string) bool {
if prefix == "" {
return false
}
cleanedPath := path.Clean(reqPath)
if cleanedPath == "." {
cleanedPath = "/"
}
cleanedPrefix := path.Clean(prefix)
if cleanedPrefix == "." {
cleanedPrefix = "/"
}
if cleanedPrefix == "/" {
return true
}
if cleanedPath == cleanedPrefix {
return true
}
return strings.HasPrefix(cleanedPath, cleanedPrefix+"/")
}
func (fs *FilerServer) filerHealthzHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Server", "SeaweedFS "+version.VERSION)
if _, err := fs.filer.Store.FindEntry(context.Background(), filer.TopicsDir); err != nil && err != filer_pb.ErrNotFound {