mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-03 10:37:35 +01:00
* filer: scope a prefix JWT to the directory a zip extract writes under Without a trailing slash, an extract request writes members beside the named path, in its parent directory, while the JWT check only saw the request path. A token scoped to /tenant1 could therefore extract into / by posting to /tenant1?extract=zip. Add the extract root to the paths a prefix-restricted token must cover. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * filer: do not keep an extracted archive under the name ".." A multipart file name of ".." passes path.Base unchanged, so extract.keep stored the archive as an entry literally named ".." in the target directory, a name that local sinks resolve to the parent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * leveldb3: keep a non-local bucket name out of the per-bucket folder findDB splits the bucket out of a /buckets/ path and uses it as a folder name under the store directory. The name cannot hold a slash and dot names already go to the default DB, so this only rejects an empty or non-local name, but it also lets static analysis see the folder cannot escape the store directory. * filer: clean the zip extract root before scoping and writing A percent-encoded dot segment survives the path cleaning in front of the mux, so a request path ending in "/" reached extractZipRoot with ".." in it. The JWT scope check cleans the path it compares, but the members were written under the raw root, so the two could disagree. * leveldb3: apply the bucket folder check to bucket create and delete events findDB already keeps dot and non-local bucket names out of the per-bucket folders, but OnBucketCreation and OnBucketDeletion used the name as is. A directory event for /buckets/.. opened a DB in the parent of the store directory, and its delete removed that parent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * leveldb3: keep a bucket delete event off the default DB A bucket named _main maps to the default DB in findDB, so deleting /buckets/_main closed the default DB and removed its folder, taking every non-bucket entry with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * leveldb3: keep bucket names with a path separator out of the store dir A bucket event named x/../_main passed the local-name check, and its delete removed the default DB folder once store.dir/x existed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * leveldb3: only reject the platform separator in a bucket name A backslash is an ordinary file name character on Unix, and a bucket folder named that way already exists for anyone who created one. Rejecting it on every platform would route those entries to the default DB after an upgrade. The slash can never appear in a bucket name here, and on Windows the backslash is the separator, so rejecting os.PathSeparator alongside the slash keeps the folder flat without changing Unix routing. * leveldb3: check for an escaped bucket db before its delete event --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
375 lines
12 KiB
Go
375 lines
12 KiB
Go
package weed_server
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/seaweedfs/seaweedfs/weed/filer"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/security"
|
|
"github.com/seaweedfs/seaweedfs/weed/stats"
|
|
util_http "github.com/seaweedfs/seaweedfs/weed/util/http"
|
|
"github.com/seaweedfs/seaweedfs/weed/util/version"
|
|
)
|
|
|
|
func (fs *FilerServer) filerHandler(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
|
|
inFlightGauge := stats.FilerInFlightRequestsGauge.WithLabelValues(r.Method)
|
|
inFlightGauge.Inc()
|
|
defer inFlightGauge.Dec()
|
|
|
|
statusRecorder := stats.NewStatusResponseWriter(w)
|
|
w = statusRecorder
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" {
|
|
if fs.option.AllowedOrigins == nil || len(fs.option.AllowedOrigins) == 0 || fs.option.AllowedOrigins[0] == "*" {
|
|
origin = "*"
|
|
} else {
|
|
originFound := false
|
|
for _, allowedOrigin := range fs.option.AllowedOrigins {
|
|
if origin == allowedOrigin {
|
|
originFound = true
|
|
}
|
|
}
|
|
if !originFound {
|
|
writeJsonError(w, r, http.StatusForbidden, errors.New("origin not allowed"))
|
|
return
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Access-Control-Expose-Headers", "*")
|
|
w.Header().Set("Access-Control-Allow-Headers", "*")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Access-Control-Allow-Methods", "PUT, POST, GET, DELETE, OPTIONS")
|
|
}
|
|
|
|
if r.Method == http.MethodOptions {
|
|
OptionsHandler(w, r, false)
|
|
return
|
|
}
|
|
|
|
requestMethod := r.Method
|
|
defer func(method *string) {
|
|
stats.FilerRequestCounter.WithLabelValues(*method, strconv.Itoa(statusRecorder.Status)).Inc()
|
|
stats.FilerRequestHistogram.WithLabelValues(*method).Observe(time.Since(start).Seconds())
|
|
}(&requestMethod)
|
|
|
|
isReadHttpCall := r.Method == http.MethodGet || r.Method == http.MethodHead
|
|
if !fs.maybeCheckJwtAuthorization(r, !isReadHttpCall) {
|
|
writeJsonError(w, r, http.StatusUnauthorized, errors.New("wrong jwt"))
|
|
return
|
|
}
|
|
|
|
// proxy to volume servers, after the gate: this is the one port operators
|
|
// expose, and the branch reaches any needle in the cluster by file id.
|
|
if r.URL.Path == "/" {
|
|
if fileId := r.URL.Query().Get(util_http.ProxyChunkIdParam); fileId != "" {
|
|
fs.proxyToVolumeServer(w, r, fileId)
|
|
stats.FilerHandlerCounter.WithLabelValues(stats.ChunkProxy).Inc()
|
|
// Name the deferred observation after the proxy rather than
|
|
// observing a second time, which would count the request twice.
|
|
requestMethod = stats.ChunkProxy
|
|
return
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Server", "SeaweedFS "+version.VERSION)
|
|
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead:
|
|
fs.GetOrHeadHandler(w, r)
|
|
case http.MethodDelete:
|
|
if _, ok := r.URL.Query()["tagging"]; ok {
|
|
fs.DeleteTaggingHandler(w, r)
|
|
} else {
|
|
fs.DeleteHandler(w, r)
|
|
}
|
|
case http.MethodPost, http.MethodPut:
|
|
// wait until in flight data is less than the limit
|
|
contentLength := getContentLength(r)
|
|
fs.inFlightDataLimitCond.L.Lock()
|
|
inFlightDataSize := atomic.LoadInt64(&fs.inFlightDataSize)
|
|
inFlightUploads := atomic.LoadInt64(&fs.inFlightUploads)
|
|
|
|
// Wait if either data size limit or file count limit is exceeded
|
|
for (fs.option.ConcurrentUploadLimit != 0 && inFlightDataSize > fs.option.ConcurrentUploadLimit) || (fs.option.ConcurrentFileUploadLimit != 0 && inFlightUploads >= fs.option.ConcurrentFileUploadLimit) {
|
|
if fs.option.ConcurrentUploadLimit != 0 && inFlightDataSize > fs.option.ConcurrentUploadLimit {
|
|
glog.V(4).Infof("wait because inflight data %d > %d", inFlightDataSize, fs.option.ConcurrentUploadLimit)
|
|
}
|
|
if fs.option.ConcurrentFileUploadLimit != 0 && inFlightUploads >= fs.option.ConcurrentFileUploadLimit {
|
|
glog.V(4).Infof("wait because inflight uploads %d >= %d", inFlightUploads, fs.option.ConcurrentFileUploadLimit)
|
|
}
|
|
fs.inFlightDataLimitCond.Wait()
|
|
inFlightDataSize = atomic.LoadInt64(&fs.inFlightDataSize)
|
|
inFlightUploads = atomic.LoadInt64(&fs.inFlightUploads)
|
|
}
|
|
fs.inFlightDataLimitCond.L.Unlock()
|
|
|
|
// Increment counters
|
|
newUploads := atomic.AddInt64(&fs.inFlightUploads, 1)
|
|
newSize := atomic.AddInt64(&fs.inFlightDataSize, contentLength)
|
|
// Update metrics
|
|
stats.FilerInFlightUploadCountGauge.Set(float64(newUploads))
|
|
stats.FilerInFlightUploadBytesGauge.Set(float64(newSize))
|
|
defer func() {
|
|
// Decrement counters
|
|
newUploads := atomic.AddInt64(&fs.inFlightUploads, -1)
|
|
newSize := atomic.AddInt64(&fs.inFlightDataSize, -contentLength)
|
|
// Update metrics
|
|
stats.FilerInFlightUploadCountGauge.Set(float64(newUploads))
|
|
stats.FilerInFlightUploadBytesGauge.Set(float64(newSize))
|
|
fs.inFlightDataLimitCond.Signal()
|
|
}()
|
|
|
|
if r.Method == http.MethodPut {
|
|
if _, ok := r.URL.Query()["tagging"]; ok {
|
|
fs.PutTaggingHandler(w, r)
|
|
} else {
|
|
fs.PostHandler(w, r, contentLength)
|
|
}
|
|
} else { // method == "POST"
|
|
fs.PostHandler(w, r, contentLength)
|
|
}
|
|
default:
|
|
requestMethod = "INVALID"
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func (fs *FilerServer) readonlyFilerHandler(w http.ResponseWriter, r *http.Request) {
|
|
|
|
start := time.Now()
|
|
statusRecorder := stats.NewStatusResponseWriter(w)
|
|
w = statusRecorder
|
|
|
|
glog.V(4).Infof("Request: %s %s", r.Method, r.URL.Path)
|
|
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" {
|
|
if fs.option.AllowedOrigins == nil || len(fs.option.AllowedOrigins) == 0 || fs.option.AllowedOrigins[0] == "*" {
|
|
origin = "*"
|
|
} else {
|
|
originFound := false
|
|
for _, allowedOrigin := range fs.option.AllowedOrigins {
|
|
if origin == allowedOrigin {
|
|
originFound = true
|
|
}
|
|
}
|
|
if !originFound {
|
|
writeJsonError(w, r, http.StatusForbidden, errors.New("origin not allowed"))
|
|
return
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Access-Control-Allow-Headers", "OPTIONS, GET, HEAD")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
}
|
|
requestMethod := r.Method
|
|
defer func(method *string) {
|
|
stats.FilerRequestCounter.WithLabelValues(*method, strconv.Itoa(statusRecorder.Status)).Inc()
|
|
stats.FilerRequestHistogram.WithLabelValues(*method).Observe(time.Since(start).Seconds())
|
|
}(&requestMethod)
|
|
// We handle OPTIONS first because it never should be authenticated
|
|
if r.Method == http.MethodOptions {
|
|
OptionsHandler(w, r, true)
|
|
return
|
|
}
|
|
|
|
if !fs.maybeCheckJwtAuthorization(r, false) {
|
|
writeJsonError(w, r, http.StatusUnauthorized, errors.New("wrong jwt"))
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Server", "SeaweedFS "+version.VERSION)
|
|
|
|
switch r.Method {
|
|
case http.MethodGet, http.MethodHead:
|
|
fs.GetOrHeadHandler(w, r)
|
|
default:
|
|
requestMethod = "INVALID"
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func OptionsHandler(w http.ResponseWriter, r *http.Request, isReadOnly bool) {
|
|
if isReadOnly {
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
} else {
|
|
w.Header().Set("Access-Control-Allow-Methods", "PUT, POST, GET, DELETE, OPTIONS")
|
|
w.Header().Set("Access-Control-Expose-Headers", "*")
|
|
}
|
|
w.Header().Set("Access-Control-Allow-Headers", "*")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
}
|
|
|
|
// maybeCheckJwtAuthorization returns true if access should be granted, false if it should be denied
|
|
func (fs *FilerServer) maybeCheckJwtAuthorization(r *http.Request, isWrite bool) bool {
|
|
return fs.checkJwtAuthorization(r, isWrite, jwtScopedRequestPaths(r))
|
|
}
|
|
|
|
// checkJwtAuthorization verifies the request carries a valid filer JWT for the
|
|
// requested access level and, for prefix-restricted tokens, that every path in
|
|
// scopedPaths falls within AllowedPrefixes.
|
|
func (fs *FilerServer) checkJwtAuthorization(r *http.Request, isWrite bool, scopedPaths []string) bool {
|
|
claims, ok := fs.authenticateFilerJwt(r, isWrite)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return authorizeFilerJwtPaths(r, claims, scopedPaths)
|
|
}
|
|
|
|
// authenticateFilerJwt verifies the JWT signature and method claims. A nil claims
|
|
// with ok true means authentication is disabled for this access level. Splitting
|
|
// authentication from path authorization lets a handler load an indirect resource
|
|
// (the TUS session target) only after the caller's credential is verified.
|
|
func (fs *FilerServer) authenticateFilerJwt(r *http.Request, isWrite bool) (*security.SeaweedFilerClaims, bool) {
|
|
|
|
var signingKey security.SigningKey
|
|
if isWrite {
|
|
signingKey = fs.filerGuard.SigningKey()
|
|
} else {
|
|
signingKey = fs.filerGuard.ReadSigningKey()
|
|
}
|
|
if len(signingKey) == 0 {
|
|
return nil, true
|
|
}
|
|
|
|
tokenStr := security.GetJwt(r)
|
|
if tokenStr == "" {
|
|
glog.V(1).Infof("missing jwt from %s", r.RemoteAddr)
|
|
return nil, false
|
|
}
|
|
|
|
token, err := security.DecodeJwt(signingKey, tokenStr, &security.SeaweedFilerClaims{})
|
|
if err != nil {
|
|
glog.V(1).Infof("jwt verification error from %s: %v", r.RemoteAddr, err)
|
|
return nil, false
|
|
}
|
|
if !token.Valid {
|
|
glog.V(1).Infof("jwt invalid from %s: %v", r.RemoteAddr, tokenStr)
|
|
return nil, false
|
|
}
|
|
|
|
claims, ok := token.Claims.(*security.SeaweedFilerClaims)
|
|
if !ok {
|
|
glog.V(1).Infof("jwt claims not of type *SeaweedFilerClaims from %s", r.RemoteAddr)
|
|
return nil, false
|
|
}
|
|
if claims.SessionId != "" {
|
|
glog.V(1).Infof("jwt is an STS session token, not a filer credential, from %s", r.RemoteAddr)
|
|
return nil, false
|
|
}
|
|
|
|
if len(claims.AllowedMethods) > 0 {
|
|
hasMethod := false
|
|
for _, method := range claims.AllowedMethods {
|
|
if method == r.Method {
|
|
hasMethod = true
|
|
break
|
|
}
|
|
}
|
|
if !hasMethod {
|
|
glog.V(1).Infof("jwt method not allowed from %s: %v", r.RemoteAddr, r.Method)
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
return claims, true
|
|
}
|
|
|
|
// authorizeFilerJwtPaths checks the resource scope after authentication. A
|
|
// prefix-restricted token must name at least one resource path; an empty list
|
|
// fails closed, so a caller that cannot resolve its target never falls open.
|
|
func authorizeFilerJwtPaths(r *http.Request, claims *security.SeaweedFilerClaims, scopedPaths []string) bool {
|
|
if claims == nil || len(claims.AllowedPrefixes) == 0 {
|
|
return true
|
|
}
|
|
if len(scopedPaths) == 0 {
|
|
glog.V(1).Infof("jwt resource path missing from %s", r.RemoteAddr)
|
|
return false
|
|
}
|
|
for _, p := range scopedPaths {
|
|
if !anyComponentPrefixMatches(claims.AllowedPrefixes, p) {
|
|
glog.V(1).Infof("jwt path not allowed from %s: %v", r.RemoteAddr, p)
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// jwtScopedRequestPaths returns every filer path a request touches that must be
|
|
// covered by the JWT AllowedPrefixes: the write target (r.URL.Path), the
|
|
// directory a zip extract writes under, and any copy/move source named by the
|
|
// cp.from / mv.from query parameters.
|
|
func jwtScopedRequestPaths(r *http.Request) []string {
|
|
paths := []string{r.URL.Path}
|
|
if (r.Method == http.MethodPost || r.Method == http.MethodPut) && isExtractZip(r) {
|
|
paths = append(paths, extractZipRoot(r)+"/")
|
|
}
|
|
if query := r.URL.Query(); query.Has("cp.from") || query.Has("mv.from") {
|
|
if from := query.Get("cp.from"); from != "" {
|
|
paths = append(paths, from)
|
|
}
|
|
if from := query.Get("mv.from"); from != "" {
|
|
paths = append(paths, from)
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// anyComponentPrefixMatches reports whether p is within any of the prefixes.
|
|
func anyComponentPrefixMatches(prefixes []string, p string) bool {
|
|
for _, prefix := range prefixes {
|
|
if pathHasComponentPrefix(p, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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 {
|
|
glog.Warningf("filerHealthzHandler FindEntry: %+v", err)
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
} else {
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}
|