package static import ( "bytes" "errors" "fmt" "io/fs" "net/url" "os" pathpkg "path" "path/filepath" "slices" "strconv" "strings" "sync" "github.com/gofiber/utils/v2" "github.com/valyala/fasthttp" "github.com/gofiber/fiber/v3" ) var ErrInvalidPath = errors.New("invalid path") // sanitizePath validates and cleans the requested path. // It returns an error if the path attempts to traverse directories. func sanitizePath(p []byte, filesystem fs.FS) ([]byte, error) { var s string hasTrailingSlash := len(p) > 0 && p[len(p)-1] == '/' if bytes.IndexByte(p, '\\') >= 0 { b := make([]byte, len(p)) copy(b, p) for i := range b { if b[i] == '\\' { b[i] = '/' } } s = utils.UnsafeString(b) } else { s = utils.UnsafeString(p) } // repeatedly unescape until it no longer changes, catching errors for strings.IndexByte(s, '%') >= 0 { us, err := url.PathUnescape(s) if err != nil { return nil, ErrInvalidPath } if us == s { break } s = us } if strings.IndexByte(s, '\\') >= 0 { return nil, ErrInvalidPath } // reject any null bytes if strings.IndexByte(s, '\x00') >= 0 { return nil, ErrInvalidPath } normalized := filepath.ToSlash(s) if filesystem == nil && strings.HasPrefix(normalized, "//") { return nil, ErrInvalidPath } s = pathpkg.Clean("/" + normalized) trimmed := utils.TrimLeft(s, '/') if trimmed != "" { if slices.Contains(strings.Split(trimmed, "/"), "..") { return nil, ErrInvalidPath } } if filesystem == nil { normalizedClean := filepath.ToSlash(trimmed) if strings.HasPrefix(normalizedClean, "//") { return nil, ErrInvalidPath } if volume := filepath.VolumeName(normalizedClean); volume != "" { return nil, ErrInvalidPath } if len(normalizedClean) >= 2 && normalizedClean[1] == ':' { drive := normalizedClean[0] if (drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z') { return nil, ErrInvalidPath } } if strings.HasPrefix(filepath.ToSlash(s), "//") { return nil, ErrInvalidPath } } if filesystem != nil { s = trimmed if s == "" { return []byte("/"), nil } if !fs.ValidPath(s) { return nil, ErrInvalidPath } s = "/" + s } if hasTrailingSlash && len(s) > 1 && s[len(s)-1] != '/' { s += "/" } return utils.UnsafeBytes(s), nil } // New creates a new middleware handler. // The root argument specifies the root directory from which to serve static assets. // // Note: Root has to be string or fs.FS; otherwise, it will panic. func New(root string, cfg ...Config) fiber.Handler { config := configDefault(cfg...) var createFS sync.Once var fileHandler fasthttp.RequestHandler var cacheControlValue string var rootIsFile bool // adjustments for io/fs compatibility if config.FS != nil && root == "" { root = "." } return func(c fiber.Ctx) error { // Don't execute middleware if Next returns true if config.Next != nil && config.Next(c) { return c.Next() } // We only serve static assets on GET or HEAD methods method := c.Method() if method != fiber.MethodGet && method != fiber.MethodHead { return c.Next() } // Initialize FS createFS.Do(func() { prefix := c.Route().Path if check, err := isFile(root, config.FS); err == nil { rootIsFile = check } // Is prefix a partial wildcard? if before, _, found := strings.Cut(prefix, "*"); found { // /john* -> /john prefix = before } prefixLen := len(prefix) if prefixLen > 1 && prefix[prefixLen-1:] == "/" { // /john/ -> /john prefixLen-- } // For io/fs.FS, Root must be empty so fasthttp's pathToFilePath // returns clean relative paths without prefixing the root. // PathRewrite already handles file-root and subdirectory cases. fsRoot := root if config.FS != nil { fsRoot = "" } fileServer := &fasthttp.FS{ Root: fsRoot, FS: config.FS, AllowEmptyRoot: true, GenerateIndexPages: config.Browse, AcceptByteRange: config.ByteRange, Compress: config.Compress, CompressBrotli: config.Compress, // Brotli compression won't work without this CompressZstd: config.Compress, // Zstd compression won't work without this CompressedFileSuffixes: c.App().Config().CompressedFileSuffixes, CacheDuration: config.CacheDuration, SkipCache: config.CacheDuration < 0, IndexNames: config.IndexNames, PathNotFound: func(fctx *fasthttp.RequestCtx) { fctx.Response.SetStatusCode(fiber.StatusNotFound) }, } fileServer.PathRewrite = func(fctx *fasthttp.RequestCtx) []byte { path := fctx.Path() if len(path) >= prefixLen { checkFile, err := isFile(root, fileServer.FS) if err != nil { return path } // If the root is a file, we need to reset the path to "/" always. switch { case checkFile && fileServer.FS == nil: path = []byte("/") case checkFile && fileServer.FS != nil: path = utils.UnsafeBytes(root) default: path = path[prefixLen:] if len(path) == 0 || path[len(path)-1] != '/' { path = append(path, '/') } } } if len(path) > 0 && path[0] != '/' { path = append([]byte("/"), path...) } sanitized, err := sanitizePath(path, fileServer.FS) if err != nil { // return a guaranteed-missing path so fs responds with 404 return []byte("/__fiber_invalid__") } return sanitized } maxAge := config.MaxAge if maxAge > 0 { cacheControlValue = "public, max-age=" + strconv.Itoa(maxAge) } fileHandler = fileServer.NewRequestHandler() }) // Serve file fileHandler(c.RequestCtx()) // Sets the response Content-Disposition header to attachment if the Download option is true if config.Download { name := filepath.Base(c.Path()) if rootIsFile { name = filepath.Base(root) } c.Attachment(name) } // Return request if found and not forbidden status := c.RequestCtx().Response.StatusCode() if status != fiber.StatusNotFound && status != fiber.StatusForbidden { if cacheControlValue != "" { c.RequestCtx().Response.Header.Set(fiber.HeaderCacheControl, cacheControlValue) } if config.ModifyResponse != nil { return config.ModifyResponse(c) } return nil } // Return custom 404 handler if provided. if config.NotFoundHandler != nil { return config.NotFoundHandler(c) } // Reset response to default c.RequestCtx().SetContentType("") // Issue #420 c.RequestCtx().Response.SetStatusCode(fiber.StatusOK) c.RequestCtx().Response.SetBodyString("") // Next middleware return c.Next() } } // isFile checks if the root is a file. func isFile(root string, filesystem fs.FS) (bool, error) { var file fs.File var err error if filesystem != nil { file, err = filesystem.Open(root) if err != nil { return false, fmt.Errorf("static: %w", err) } defer func() { _ = file.Close() //nolint:errcheck // not needed }() } else { file, err = os.Open(filepath.Clean(root)) if err != nil { return false, fmt.Errorf("static: %w", err) } defer func() { _ = file.Close() //nolint:errcheck // not needed }() } stat, err := file.Stat() if err != nil { return false, fmt.Errorf("static: %w", err) } return stat.Mode().IsRegular(), nil }