static.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. package static
  2. import (
  3. "bytes"
  4. "errors"
  5. "fmt"
  6. "io/fs"
  7. "net/url"
  8. "os"
  9. pathpkg "path"
  10. "path/filepath"
  11. "slices"
  12. "strconv"
  13. "strings"
  14. "sync"
  15. "github.com/gofiber/utils/v2"
  16. "github.com/valyala/fasthttp"
  17. "github.com/gofiber/fiber/v3"
  18. )
  19. var ErrInvalidPath = errors.New("invalid path")
  20. // sanitizePath validates and cleans the requested path.
  21. // It returns an error if the path attempts to traverse directories.
  22. func sanitizePath(p []byte, filesystem fs.FS) ([]byte, error) {
  23. var s string
  24. hasTrailingSlash := len(p) > 0 && p[len(p)-1] == '/'
  25. if bytes.IndexByte(p, '\\') >= 0 {
  26. b := make([]byte, len(p))
  27. copy(b, p)
  28. for i := range b {
  29. if b[i] == '\\' {
  30. b[i] = '/'
  31. }
  32. }
  33. s = utils.UnsafeString(b)
  34. } else {
  35. s = utils.UnsafeString(p)
  36. }
  37. // repeatedly unescape until it no longer changes, catching errors
  38. for strings.IndexByte(s, '%') >= 0 {
  39. us, err := url.PathUnescape(s)
  40. if err != nil {
  41. return nil, ErrInvalidPath
  42. }
  43. if us == s {
  44. break
  45. }
  46. s = us
  47. }
  48. if strings.IndexByte(s, '\\') >= 0 {
  49. return nil, ErrInvalidPath
  50. }
  51. // reject any null bytes
  52. if strings.IndexByte(s, '\x00') >= 0 {
  53. return nil, ErrInvalidPath
  54. }
  55. normalized := filepath.ToSlash(s)
  56. if filesystem == nil && strings.HasPrefix(normalized, "//") {
  57. return nil, ErrInvalidPath
  58. }
  59. s = pathpkg.Clean("/" + normalized)
  60. trimmed := utils.TrimLeft(s, '/')
  61. if trimmed != "" {
  62. if slices.Contains(strings.Split(trimmed, "/"), "..") {
  63. return nil, ErrInvalidPath
  64. }
  65. }
  66. if filesystem == nil {
  67. normalizedClean := filepath.ToSlash(trimmed)
  68. if strings.HasPrefix(normalizedClean, "//") {
  69. return nil, ErrInvalidPath
  70. }
  71. if volume := filepath.VolumeName(normalizedClean); volume != "" {
  72. return nil, ErrInvalidPath
  73. }
  74. if len(normalizedClean) >= 2 && normalizedClean[1] == ':' {
  75. drive := normalizedClean[0]
  76. if (drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z') {
  77. return nil, ErrInvalidPath
  78. }
  79. }
  80. if strings.HasPrefix(filepath.ToSlash(s), "//") {
  81. return nil, ErrInvalidPath
  82. }
  83. }
  84. if filesystem != nil {
  85. s = trimmed
  86. if s == "" {
  87. return []byte("/"), nil
  88. }
  89. if !fs.ValidPath(s) {
  90. return nil, ErrInvalidPath
  91. }
  92. s = "/" + s
  93. }
  94. if hasTrailingSlash && len(s) > 1 && s[len(s)-1] != '/' {
  95. s += "/"
  96. }
  97. return utils.UnsafeBytes(s), nil
  98. }
  99. // New creates a new middleware handler.
  100. // The root argument specifies the root directory from which to serve static assets.
  101. //
  102. // Note: Root has to be string or fs.FS; otherwise, it will panic.
  103. func New(root string, cfg ...Config) fiber.Handler {
  104. config := configDefault(cfg...)
  105. var createFS sync.Once
  106. var fileHandler fasthttp.RequestHandler
  107. var cacheControlValue string
  108. var rootIsFile bool
  109. // adjustments for io/fs compatibility
  110. if config.FS != nil && root == "" {
  111. root = "."
  112. }
  113. return func(c fiber.Ctx) error {
  114. // Don't execute middleware if Next returns true
  115. if config.Next != nil && config.Next(c) {
  116. return c.Next()
  117. }
  118. // We only serve static assets on GET or HEAD methods
  119. method := c.Method()
  120. if method != fiber.MethodGet && method != fiber.MethodHead {
  121. return c.Next()
  122. }
  123. // Initialize FS
  124. createFS.Do(func() {
  125. prefix := c.Route().Path
  126. if check, err := isFile(root, config.FS); err == nil {
  127. rootIsFile = check
  128. }
  129. // Is prefix a partial wildcard?
  130. if before, _, found := strings.Cut(prefix, "*"); found {
  131. // /john* -> /john
  132. prefix = before
  133. }
  134. prefixLen := len(prefix)
  135. if prefixLen > 1 && prefix[prefixLen-1:] == "/" {
  136. // /john/ -> /john
  137. prefixLen--
  138. }
  139. // For io/fs.FS, Root must be empty so fasthttp's pathToFilePath
  140. // returns clean relative paths without prefixing the root.
  141. // PathRewrite already handles file-root and subdirectory cases.
  142. fsRoot := root
  143. if config.FS != nil {
  144. fsRoot = ""
  145. }
  146. fileServer := &fasthttp.FS{
  147. Root: fsRoot,
  148. FS: config.FS,
  149. AllowEmptyRoot: true,
  150. GenerateIndexPages: config.Browse,
  151. AcceptByteRange: config.ByteRange,
  152. Compress: config.Compress,
  153. CompressBrotli: config.Compress, // Brotli compression won't work without this
  154. CompressZstd: config.Compress, // Zstd compression won't work without this
  155. CompressedFileSuffixes: c.App().Config().CompressedFileSuffixes,
  156. CacheDuration: config.CacheDuration,
  157. SkipCache: config.CacheDuration < 0,
  158. IndexNames: config.IndexNames,
  159. PathNotFound: func(fctx *fasthttp.RequestCtx) {
  160. fctx.Response.SetStatusCode(fiber.StatusNotFound)
  161. },
  162. }
  163. fileServer.PathRewrite = func(fctx *fasthttp.RequestCtx) []byte {
  164. path := fctx.Path()
  165. if len(path) >= prefixLen {
  166. checkFile, err := isFile(root, fileServer.FS)
  167. if err != nil {
  168. return path
  169. }
  170. // If the root is a file, we need to reset the path to "/" always.
  171. switch {
  172. case checkFile && fileServer.FS == nil:
  173. path = []byte("/")
  174. case checkFile && fileServer.FS != nil:
  175. path = utils.UnsafeBytes(root)
  176. default:
  177. path = path[prefixLen:]
  178. if len(path) == 0 || path[len(path)-1] != '/' {
  179. path = append(path, '/')
  180. }
  181. }
  182. }
  183. if len(path) > 0 && path[0] != '/' {
  184. path = append([]byte("/"), path...)
  185. }
  186. sanitized, err := sanitizePath(path, fileServer.FS)
  187. if err != nil {
  188. // return a guaranteed-missing path so fs responds with 404
  189. return []byte("/__fiber_invalid__")
  190. }
  191. return sanitized
  192. }
  193. maxAge := config.MaxAge
  194. if maxAge > 0 {
  195. cacheControlValue = "public, max-age=" + strconv.Itoa(maxAge)
  196. }
  197. fileHandler = fileServer.NewRequestHandler()
  198. })
  199. // Serve file
  200. fileHandler(c.RequestCtx())
  201. // Sets the response Content-Disposition header to attachment if the Download option is true
  202. if config.Download {
  203. name := filepath.Base(c.Path())
  204. if rootIsFile {
  205. name = filepath.Base(root)
  206. }
  207. c.Attachment(name)
  208. }
  209. // Return request if found and not forbidden
  210. status := c.RequestCtx().Response.StatusCode()
  211. if status != fiber.StatusNotFound && status != fiber.StatusForbidden {
  212. if cacheControlValue != "" {
  213. c.RequestCtx().Response.Header.Set(fiber.HeaderCacheControl, cacheControlValue)
  214. }
  215. if config.ModifyResponse != nil {
  216. return config.ModifyResponse(c)
  217. }
  218. return nil
  219. }
  220. // Return custom 404 handler if provided.
  221. if config.NotFoundHandler != nil {
  222. return config.NotFoundHandler(c)
  223. }
  224. // Reset response to default
  225. c.RequestCtx().SetContentType("") // Issue #420
  226. c.RequestCtx().Response.SetStatusCode(fiber.StatusOK)
  227. c.RequestCtx().Response.SetBodyString("")
  228. // Next middleware
  229. return c.Next()
  230. }
  231. }
  232. // isFile checks if the root is a file.
  233. func isFile(root string, filesystem fs.FS) (bool, error) {
  234. var file fs.File
  235. var err error
  236. if filesystem != nil {
  237. file, err = filesystem.Open(root)
  238. if err != nil {
  239. return false, fmt.Errorf("static: %w", err)
  240. }
  241. defer func() {
  242. _ = file.Close() //nolint:errcheck // not needed
  243. }()
  244. } else {
  245. file, err = os.Open(filepath.Clean(root))
  246. if err != nil {
  247. return false, fmt.Errorf("static: %w", err)
  248. }
  249. defer func() {
  250. _ = file.Close() //nolint:errcheck // not needed
  251. }()
  252. }
  253. stat, err := file.Stat()
  254. if err != nil {
  255. return false, fmt.Errorf("static: %w", err)
  256. }
  257. return stat.Mode().IsRegular(), nil
  258. }