@marcel91200 / caddy Mirror · read-only
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 01:00 UTC

master
go 924 lines 32.8 KB
Raw
1 // Copyright 2015 Matthew Holt and The Caddy Authors
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 // http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14
15 package fileserver
16
17 import (
18 "bytes"
19 "errors"
20 "fmt"
21 "io"
22 "io/fs"
23 weakrand "math/rand/v2"
24 "mime"
25 "net/http"
26 "os"
27 "path"
28 "path/filepath"
29 "runtime"
30 "slices"
31 "strconv"
32 "strings"
33 "syscall"
34 "time"
35 "unicode"
36
37 "go.uber.org/zap"
38 "go.uber.org/zap/zapcore"
39
40 "github.com/caddyserver/caddy/v2"
41 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
42 "github.com/caddyserver/caddy/v2/modules/caddyhttp/encode"
43 )
44
45 func init() {
46 caddy.RegisterModule(FileServer{})
47 }
48
49 // FileServer implements a handler that serves static files.
50 //
51 // The path of the file to serve is constructed by joining the site root
52 // and the sanitized request path. Any and all files within the root and
53 // links with targets outside the site root may therefore be accessed.
54 // For example, with a site root of `/www`, requests to `/foo/bar.txt`
55 // will serve the file at `/www/foo/bar.txt`.
56 //
57 // The request path is sanitized using the Go standard library's
58 // path.Clean() function (https://pkg.go.dev/path#Clean) before being
59 // joined to the root. Request paths must be valid and well-formed.
60 //
61 // For requests that access directories instead of regular files,
62 // Caddy will attempt to serve an index file if present. For example,
63 // a request to `/dir/` will attempt to serve `/dir/index.html` if
64 // it exists. The index file names to try are configurable. If a
65 // requested directory does not have an index file, Caddy writes a
66 // 404 response. Alternatively, file browsing can be enabled with
67 // the "browse" parameter which shows a list of files when directories
68 // are requested if no index file is present. If "browse" is enabled,
69 // Caddy may serve a JSON array of the directory listing when the `Accept`
70 // header mentions `application/json` with the following structure:
71 //
72 // [{
73 // "name": "",
74 // "size": 0,
75 // "url": "",
76 // "mod_time": "",
77 // "mode": 0,
78 // "is_dir": false,
79 // "is_symlink": false
80 // }]
81 //
82 // with the `url` being relative to the request path and `mod_time` in the RFC 3339 format
83 // with sub-second precision. For any other value for the `Accept` header, the
84 // respective browse template is executed with `Content-Type: text/html`.
85 //
86 // By default, this handler will canonicalize URIs so that requests to
87 // directories end with a slash, but requests to regular files do not.
88 // This is enforced with HTTP redirects automatically and can be disabled.
89 // Canonicalization redirects are not issued, however, if a URI rewrite
90 // modified the last component of the path (the filename).
91 //
92 // This handler sets the Etag and Last-Modified headers for static files.
93 // It does not perform MIME sniffing to determine Content-Type based on
94 // contents, but does use the extension (if known); see the Go docs for
95 // details: https://pkg.go.dev/mime#TypeByExtension
96 //
97 // The file server properly handles requests with If-Match,
98 // If-Unmodified-Since, If-Modified-Since, If-None-Match, Range, and
99 // If-Range headers. It includes the file's modification time in the
100 // Last-Modified header of the response.
101 type FileServer struct {
102 // The file system implementation to use. By default, Caddy uses the local
103 // disk file system.
104 //
105 // if a non default filesystem is used, it must be first be registered in the globals section.
106 FileSystem string `json:"fs,omitempty"`
107
108 // The path to the root of the site. Default is `{http.vars.root}` if set,
109 // or current working directory otherwise. This should be a trusted value.
110 //
111 // Note that a site root is not a sandbox. Although the file server does
112 // sanitize the request URI to prevent directory traversal, files (including
113 // links) within the site root may be directly accessed based on the request
114 // path. Files and folders within the root should be secure and trustworthy.
115 Root string `json:"root,omitempty"`
116
117 // A list of files or folders to hide; the file server will pretend as if
118 // they don't exist. Accepts globular patterns like `*.ext` or `/foo/*/bar`
119 // as well as placeholders. Because site roots can be dynamic, this list
120 // uses file system paths, not request paths. To clarify, the base of
121 // relative paths is the current working directory, NOT the site root.
122 //
123 // Entries without a path separator (`/` or `\` depending on OS) will match
124 // any file or directory of that name regardless of its path. To hide only a
125 // specific file with a name that may not be unique, always use a path
126 // separator. For example, to hide all files or folder trees named "hidden",
127 // put "hidden" in the list. To hide only ./hidden, put "./hidden" in the list.
128 //
129 // When possible, all paths are resolved to their absolute form before
130 // comparisons are made. For maximum clarity and explicitness, use complete,
131 // absolute paths; or, for greater portability, use relative paths instead.
132 //
133 // Entries without glob characters are compared ignoring letter case, since
134 // case-insensitive filesystems open differently cased paths as the same
135 // file; glob patterns are compared case-sensitively. Hide is a best-effort
136 // filter: glob patterns, Unicode normalization variants, symlinks and hard
137 // links can still reach hidden files, so hide should not be treated as a
138 // security boundary for sensitive paths.
139 Hide []string `json:"hide,omitempty"`
140
141 // The names of files to try as index files if a folder is requested.
142 // Default: index.html, index.txt.
143 IndexNames []string `json:"index_names,omitempty"`
144
145 // Enables file listings if a directory was requested and no index
146 // file is present.
147 Browse *Browse `json:"browse,omitempty"`
148
149 // Use redirects to enforce trailing slashes for directories, or to
150 // remove trailing slash from URIs for files. Default is true.
151 //
152 // Canonicalization will not happen if the last element of the request's
153 // path (the filename) is changed in an internal rewrite, to avoid
154 // clobbering the explicit rewrite with implicit behavior.
155 CanonicalURIs *bool `json:"canonical_uris,omitempty"`
156
157 // Override the status code written when successfully serving a file.
158 // Particularly useful when explicitly serving a file as display for
159 // an error, like a 404 page. A placeholder may be used. By default,
160 // the status code will typically be 200, or 206 for partial content.
161 StatusCode caddyhttp.WeakString `json:"status_code,omitempty"`
162
163 // If pass-thru mode is enabled and a requested file is not found,
164 // it will invoke the next handler in the chain instead of returning
165 // a 404 error. By default, this is false (disabled).
166 PassThru bool `json:"pass_thru,omitempty"`
167
168 // Selection of encoders to use to check for precompressed files.
169 PrecompressedRaw caddy.ModuleMap `json:"precompressed,omitempty" caddy:"namespace=http.precompressed"`
170
171 // If the client has no strong preference (q-factor), choose these encodings in order.
172 // If no order specified here, the first encoding from the Accept-Encoding header
173 // that both client and server support is used
174 PrecompressedOrder []string `json:"precompressed_order,omitempty"`
175 precompressors map[string]encode.Precompressed
176
177 // List of file extensions to try to read Etags from.
178 // If set, file Etags will be read from sidecar files
179 // with any of these suffixes, instead of generating
180 // our own Etag.
181 // Keep in mind that the Etag values in the files have to be quoted as per RFC7232.
182 // See https://datatracker.ietf.org/doc/html/rfc7232#section-2.3 for a few examples.
183 EtagFileExtensions []string `json:"etag_file_extensions,omitempty"`
184
185 fsmap caddy.FileSystems
186
187 logger *zap.Logger
188 }
189
190 // CaddyModule returns the Caddy module information.
191 func (FileServer) CaddyModule() caddy.ModuleInfo {
192 return caddy.ModuleInfo{
193 ID: "http.handlers.file_server",
194 New: func() caddy.Module { return new(FileServer) },
195 }
196 }
197
198 // Provision sets up the static files responder.
199 func (fsrv *FileServer) Provision(ctx caddy.Context) error {
200 fsrv.logger = ctx.Logger()
201
202 fsrv.fsmap = ctx.FileSystems()
203
204 if fsrv.FileSystem == "" {
205 fsrv.FileSystem = "{http.vars.fs}"
206 }
207
208 if fsrv.Root == "" {
209 fsrv.Root = "{http.vars.root}"
210 }
211
212 if fsrv.IndexNames == nil {
213 fsrv.IndexNames = defaultIndexNames
214 }
215
216 // a relative data or config directory sits under the working directory and
217 // holds private keys or the last saved configuration, so hide it
218 for _, dir := range []string{caddy.AppDataDir(), caddy.AppConfigDir()} {
219 if filepath.IsAbs(dir) {
220 continue
221 }
222 abs, err := caddy.FastAbs(dir)
223 if err != nil {
224 continue
225 }
226 if !slices.Contains(fsrv.Hide, abs) {
227 fsrv.Hide = append(fsrv.Hide, abs)
228 }
229 }
230
231 // for hide paths that are static (i.e. no placeholders), we can transform them into
232 // absolute paths before the server starts for very slight performance improvement
233 for i, h := range fsrv.Hide {
234 if !strings.Contains(h, "{") && strings.Contains(h, separator) {
235 if abs, err := caddy.FastAbs(h); err == nil {
236 fsrv.Hide[i] = abs
237 }
238 }
239 }
240
241 // support precompressed sidecar files
242 mods, err := ctx.LoadModule(fsrv, "PrecompressedRaw")
243 if err != nil {
244 return fmt.Errorf("loading encoder modules: %v", err)
245 }
246 for modName, modIface := range mods.(map[string]any) {
247 p, ok := modIface.(encode.Precompressed)
248 if !ok {
249 return fmt.Errorf("module %s is not precompressor", modName)
250 }
251 ae := p.AcceptEncoding()
252 if ae == "" {
253 return fmt.Errorf("precompressor does not specify an Accept-Encoding value")
254 }
255 suffix := p.Suffix()
256 if suffix == "" {
257 return fmt.Errorf("precompressor does not specify a Suffix value")
258 }
259 if _, ok := fsrv.precompressors[ae]; ok {
260 return fmt.Errorf("precompressor already added: %s", ae)
261 }
262 if fsrv.precompressors == nil {
263 fsrv.precompressors = make(map[string]encode.Precompressed)
264 }
265 fsrv.precompressors[ae] = p
266 }
267
268 if fsrv.Browse != nil {
269 // check sort options
270 for idx, sortOption := range fsrv.Browse.SortOptions {
271 switch idx {
272 case 0:
273 if sortOption != sortByName && sortOption != sortByNameDirFirst && sortOption != sortBySize && sortOption != sortByTime {
274 return fmt.Errorf("the first option must be one of the following: %s, %s, %s, %s, but got %s", sortByName, sortByNameDirFirst, sortBySize, sortByTime, sortOption)
275 }
276 case 1:
277 if sortOption != sortOrderAsc && sortOption != sortOrderDesc {
278 return fmt.Errorf("the second option must be one of the following: %s, %s, but got %s", sortOrderAsc, sortOrderDesc, sortOption)
279 }
280 default:
281 return fmt.Errorf("only max 2 sort options are allowed, but got %d", idx+1)
282 }
283 }
284 }
285
286 return nil
287 }
288
289 func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
290 repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
291
292 if runtime.GOOS == "windows" {
293 // reject paths with Alternate Data Streams (ADS)
294 if strings.Contains(r.URL.Path, ":") {
295 return caddyhttp.Error(http.StatusBadRequest, fmt.Errorf("illegal ADS path"))
296 }
297 // reject paths with "8.3" short names
298 if caddyhttp.HasWindowsShortName(r.URL.Path) {
299 return caddyhttp.Error(http.StatusBadRequest, fmt.Errorf("illegal short name"))
300 }
301 // both of those could bypass file hiding or possibly leak information even if the file is not hidden
302 }
303
304 filesToHide := fsrv.transformHidePaths(repl)
305
306 root := repl.ReplaceAll(fsrv.Root, ".")
307 fsName := repl.ReplaceAll(fsrv.FileSystem, "")
308
309 fileSystem, ok := fsrv.fsmap.Get(fsName)
310 if !ok {
311 return caddyhttp.Error(http.StatusNotFound, fmt.Errorf("filesystem not found"))
312 }
313
314 // remove any trailing separator as it breaks fs.ValidPath() in the stdlib,
315 // and Windows rejects it on a regular file; keep it on a root such as `/` or
316 // `C:\`, where removing it changes the target
317 filename := caddyhttp.SanitizedPathJoin(root, r.URL.Path)
318 if len(filename) > len(filepath.VolumeName(filename))+1 {
319 filename = strings.TrimSuffix(filename, separator)
320 }
321
322 if c := fsrv.logger.Check(zapcore.DebugLevel, "sanitized path join"); c != nil {
323 c.Write(
324 zap.String("site_root", root),
325 zap.String("fs", fsName),
326 zap.String("request_path", r.URL.Path),
327 zap.String("result", filename),
328 )
329 }
330
331 // get information about the file
332 info, err := fs.Stat(fileSystem, filename)
333 if err != nil {
334 err = fsrv.mapDirOpenError(fileSystem, err, filename)
335 if errors.Is(err, fs.ErrNotExist) {
336 return fsrv.notFound(w, r, next)
337 } else if errors.Is(err, fs.ErrInvalid) {
338 return caddyhttp.Error(http.StatusBadRequest, err)
339 } else if errors.Is(err, fs.ErrPermission) {
340 return caddyhttp.Error(http.StatusForbidden, err)
341 }
342 return caddyhttp.Error(http.StatusInternalServerError, err)
343 }
344
345 // if the request mapped to a directory, see if
346 // there is an index file we can serve
347 var implicitIndexFile bool
348 if info.IsDir() && len(fsrv.IndexNames) > 0 {
349 for _, indexPage := range fsrv.IndexNames {
350 indexPage := repl.ReplaceAll(indexPage, "")
351 indexPath := caddyhttp.SanitizedPathJoin(filename, indexPage)
352 if fileHidden(indexPath, filesToHide) {
353 // pretend this file doesn't exist
354 if c := fsrv.logger.Check(zapcore.DebugLevel, "hiding index file"); c != nil {
355 c.Write(
356 zap.String("filename", indexPath),
357 zap.Strings("files_to_hide", filesToHide),
358 )
359 }
360 continue
361 }
362
363 indexInfo, err := fs.Stat(fileSystem, indexPath)
364 if err != nil {
365 continue
366 }
367
368 // don't rewrite the request path to append
369 // the index file, because we might need to
370 // do a canonical-URL redirect below based
371 // on the URL as-is
372
373 // we've chosen to use this index file,
374 // so replace the last file info and path
375 // with that of the index file
376 info = indexInfo
377 filename = indexPath
378 implicitIndexFile = true
379 if c := fsrv.logger.Check(zapcore.DebugLevel, "located index file"); c != nil {
380 c.Write(zap.String("filename", filename))
381 }
382 break
383 }
384 }
385
386 // if still referencing a directory, delegate
387 // to browse or return an error
388 if info.IsDir() {
389 if c := fsrv.logger.Check(zapcore.DebugLevel, "no index file in directory"); c != nil {
390 c.Write(
391 zap.String("path", filename),
392 zap.Strings("index_filenames", fsrv.IndexNames),
393 )
394 }
395 if fsrv.Browse != nil && !fileHidden(filename, filesToHide) {
396 return fsrv.serveBrowse(fileSystem, root, filename, w, r, next)
397 }
398 return fsrv.notFound(w, r, next)
399 }
400
401 // one last check to ensure the file isn't hidden (we might
402 // have changed the filename from when we last checked)
403 if fileHidden(filename, filesToHide) {
404 if c := fsrv.logger.Check(zapcore.DebugLevel, "hiding file"); c != nil {
405 c.Write(
406 zap.String("filename", filename),
407 zap.Strings("files_to_hide", filesToHide),
408 )
409 }
410 return fsrv.notFound(w, r, next)
411 }
412
413 // if URL canonicalization is enabled, we need to enforce trailing
414 // slash convention: if a directory, trailing slash; if a file, no
415 // trailing slash - not enforcing this can break relative hrefs
416 // in HTML (see https://github.com/caddyserver/caddy/issues/2741)
417 if fsrv.CanonicalURIs == nil || *fsrv.CanonicalURIs {
418 // Only redirect if the last element of the path (the filename) was not
419 // rewritten; if the admin wanted to rewrite to the canonical path, they
420 // would have, and we have to be very careful not to introduce unwanted
421 // redirects and especially redirect loops!
422 // See https://github.com/caddyserver/caddy/issues/4205.
423 origReq := r.Context().Value(caddyhttp.OriginalRequestCtxKey).(http.Request)
424 if path.Base(origReq.URL.Path) == path.Base(r.URL.Path) {
425 if implicitIndexFile && !strings.HasSuffix(origReq.URL.Path, "/") {
426 to := origReq.URL.Path + "/"
427 if c := fsrv.logger.Check(zapcore.DebugLevel, "redirecting to canonical URI (adding trailing slash for directory"); c != nil {
428 c.Write(
429 zap.String("from_path", origReq.URL.Path),
430 zap.String("to_path", to),
431 )
432 }
433 return redirect(w, r, to)
434 } else if !implicitIndexFile && strings.HasSuffix(origReq.URL.Path, "/") {
435 to := origReq.URL.Path[:len(origReq.URL.Path)-1]
436 if c := fsrv.logger.Check(zapcore.DebugLevel, "redirecting to canonical URI (removing trailing slash for file"); c != nil {
437 c.Write(
438 zap.String("from_path", origReq.URL.Path),
439 zap.String("to_path", to),
440 )
441 }
442 return redirect(w, r, to)
443 }
444 }
445 }
446
447 // a trailing slash denotes a directory: path matchers don't see "/file/"
448 // as "/file", so serving the file here would bypass them
449 if !implicitIndexFile && strings.HasSuffix(caddyhttp.NormalizedPath(r.URL.Path, false), "/") {
450 if c := fsrv.logger.Check(zapcore.DebugLevel, "path with trailing slash is not a directory"); c != nil {
451 c.Write(
452 zap.String("request_path", r.URL.Path),
453 zap.String("filename", filename),
454 )
455 }
456 return fsrv.notFound(w, r, next)
457 }
458
459 var file fs.File
460 respHeader := w.Header()
461
462 // etag is usually unset, but if the user knows what they're doing, let them override it
463 etag := respHeader.Get("Etag")
464
465 // static file responses are often compressed, either on-the-fly
466 // or with precompressed sidecar files; in any case, the headers
467 // should contain "Vary: Accept-Encoding" even when not compressed
468 // so caches can craft a reliable key (according to REDbot results)
469 // see #5849
470 respHeader.Add("Vary", "Accept-Encoding")
471
472 // check for precompressed files
473 for _, ae := range encode.AcceptedEncodings(r, fsrv.PrecompressedOrder) {
474 precompress, ok := fsrv.precompressors[ae]
475 if !ok {
476 continue
477 }
478 compressedFilename := filename + precompress.Suffix()
479 if fileHidden(compressedFilename, filesToHide) {
480 continue
481 }
482 compressedInfo, err := fs.Stat(fileSystem, compressedFilename)
483 if err != nil || compressedInfo.IsDir() {
484 if c := fsrv.logger.Check(zapcore.DebugLevel, "precompressed file not accessible"); c != nil {
485 c.Write(zap.String("filename", compressedFilename), zap.Error(err))
486 }
487 continue
488 }
489 if c := fsrv.logger.Check(zapcore.DebugLevel, "opening compressed sidecar file"); c != nil {
490 c.Write(zap.String("filename", compressedFilename), zap.Error(err))
491 }
492 file, err = fsrv.openFile(fileSystem, compressedFilename, w)
493 if err != nil {
494 if c := fsrv.logger.Check(zapcore.WarnLevel, "opening precompressed file failed"); c != nil {
495 c.Write(zap.String("filename", compressedFilename), zap.Error(err))
496 }
497 if caddyErr, ok := err.(caddyhttp.HandlerError); ok && caddyErr.StatusCode == http.StatusServiceUnavailable {
498 return err
499 }
500 file = nil
501 continue
502 }
503 defer file.Close()
504 respHeader.Set("Content-Encoding", ae)
505
506 // stdlib won't set Content-Length for non-range requests if Content-Encoding is set.
507 // see: https://github.com/caddyserver/caddy/issues/7040
508 // Setting the Range header manually will result in 206 Partial Content.
509 // see: https://github.com/caddyserver/caddy/issues/7250
510 if r.Header.Get("Range") == "" {
511 respHeader.Set("Content-Length", strconv.FormatInt(compressedInfo.Size(), 10))
512 }
513
514 // try to get the etag from pre computed files if an etag suffix list was provided
515 if etag == "" && fsrv.EtagFileExtensions != nil {
516 etag, err = fsrv.getEtagFromFile(fileSystem, compressedFilename)
517 if err != nil {
518 return err
519 }
520 }
521
522 // don't assign info = compressedInfo because sidecars are kind
523 // of transparent; however we do need to set the Etag:
524 // https://caddy.community/t/gzipped-sidecar-file-wrong-same-etag/16793
525 if etag == "" {
526 etag = calculateEtag(compressedInfo)
527 }
528
529 break
530 }
531
532 // no precompressed file found, use the actual file
533 if file == nil {
534 if c := fsrv.logger.Check(zapcore.DebugLevel, "opening file"); c != nil {
535 c.Write(zap.String("filename", filename))
536 }
537
538 // open the file
539 file, err = fsrv.openFile(fileSystem, filename, w)
540 if err != nil {
541 if herr, ok := err.(caddyhttp.HandlerError); ok &&
542 herr.StatusCode == http.StatusNotFound {
543 return fsrv.notFound(w, r, next)
544 }
545 return err // error is already structured
546 }
547 defer file.Close()
548 // try to get the etag from pre computed files if an etag suffix list was provided
549 if etag == "" && fsrv.EtagFileExtensions != nil {
550 etag, err = fsrv.getEtagFromFile(fileSystem, filename)
551 if err != nil {
552 return err
553 }
554 }
555 if etag == "" {
556 etag = calculateEtag(info)
557 }
558 }
559
560 // at this point, we're serving a file; Go std lib supports only
561 // GET and HEAD, which is sensible for a static file server - reject
562 // any other methods (see issue #5166)
563 if r.Method != http.MethodGet && r.Method != http.MethodHead {
564 // if we're in an error context, then it doesn't make sense
565 // to repeat the error; just continue because we're probably
566 // trying to write an error page response (see issue #5703)
567 if _, ok := r.Context().Value(caddyhttp.ErrorCtxKey).(error); !ok {
568 respHeader.Add("Allow", "GET, HEAD")
569 return caddyhttp.Error(http.StatusMethodNotAllowed, nil)
570 }
571 }
572
573 // set the Etag - note that a conditional If-None-Match request is handled
574 // by http.ServeContent below, which checks against this Etag value
575 if etag != "" {
576 respHeader.Set("Etag", etag)
577 }
578
579 if respHeader.Get("Content-Type") == "" {
580 mtyp := mime.TypeByExtension(filepath.Ext(filename))
581 if mtyp == "" {
582 // do not allow Go to sniff the content-type; see https://www.youtube.com/watch?v=8t8JYpt0egE
583 respHeader["Content-Type"] = nil
584 } else {
585 respHeader.Set("Content-Type", mtyp)
586 }
587 }
588
589 var statusCodeOverride int
590
591 // if this handler exists in an error context (i.e. is part of a
592 // handler chain that is supposed to handle a previous error),
593 // we should set status code to the one from the error instead
594 // of letting http.ServeContent set the default (usually 200)
595 if reqErr, ok := r.Context().Value(caddyhttp.ErrorCtxKey).(error); ok {
596 statusCodeOverride = http.StatusInternalServerError
597 if handlerErr, ok := reqErr.(caddyhttp.HandlerError); ok {
598 if handlerErr.StatusCode > 0 {
599 statusCodeOverride = handlerErr.StatusCode
600 }
601 }
602 }
603
604 // if a status code override is configured, run the replacer on it
605 if codeStr := fsrv.StatusCode.String(); codeStr != "" {
606 statusCodeOverride, err = strconv.Atoi(repl.ReplaceAll(codeStr, ""))
607 if err != nil {
608 return caddyhttp.Error(http.StatusInternalServerError, err)
609 }
610 }
611
612 // if we do have an override from the previous two parts, then
613 // we wrap the response writer to intercept the WriteHeader call
614 if statusCodeOverride > 0 {
615 w = statusOverrideResponseWriter{ResponseWriter: w, code: statusCodeOverride}
616 }
617
618 // let the standard library do what it does best; note, however,
619 // that errors generated by ServeContent are written immediately
620 // to the response, so we cannot handle them (but errors there
621 // are rare)
622 //
623 // There are a few file modification times that aren't useful
624 // to send in Last-Modified headers, but the golang http library only
625 // omits Last-Modified headers for the Unix epoch time. So, force
626 // the modification time to the epoch time if it's not useful.
627 zeroTime := time.Time{}
628 modTime := info.ModTime()
629 if !usefulModTime(modTime) {
630 modTime = zeroTime
631 }
632 http.ServeContent(w, r, info.Name(), modTime, file.(io.ReadSeeker))
633
634 return nil
635 }
636
637 // openFile opens the file at the given filename. If there was an error,
638 // the response is configured to inform the client how to best handle it
639 // and a well-described handler error is returned (do not wrap the
640 // returned error value).
641 func (fsrv *FileServer) openFile(fileSystem fs.FS, filename string, w http.ResponseWriter) (fs.File, error) {
642 file, err := fileSystem.Open(filename)
643 if err != nil {
644 err = fsrv.mapDirOpenError(fileSystem, err, filename)
645 if errors.Is(err, fs.ErrNotExist) {
646 if c := fsrv.logger.Check(zapcore.DebugLevel, "file not found"); c != nil {
647 c.Write(zap.String("filename", filename), zap.Error(err))
648 }
649 return nil, caddyhttp.Error(http.StatusNotFound, err)
650 } else if errors.Is(err, fs.ErrPermission) {
651 if c := fsrv.logger.Check(zapcore.DebugLevel, "permission denied"); c != nil {
652 c.Write(zap.String("filename", filename), zap.Error(err))
653 }
654 return nil, caddyhttp.Error(http.StatusForbidden, err)
655 }
656 // maybe the server is under load and ran out of file descriptors?
657 // have client wait arbitrary seconds to help prevent a stampede
658 //nolint:gosec
659 backoff := weakrand.IntN(maxBackoff-minBackoff) + minBackoff
660 w.Header().Set("Retry-After", strconv.Itoa(backoff))
661 if c := fsrv.logger.Check(zapcore.DebugLevel, "retry after backoff"); c != nil {
662 c.Write(zap.String("filename", filename), zap.Int("backoff", backoff), zap.Error(err))
663 }
664 return nil, caddyhttp.Error(http.StatusServiceUnavailable, err)
665 }
666 return file, nil
667 }
668
669 // mapDirOpenError maps the provided non-nil error from opening name
670 // to a possibly better non-nil error. In particular, it turns OS-specific errors
671 // about opening files in non-directories into os.ErrNotExist. See golang/go#18984.
672 // Adapted from the Go standard library; originally written by Nathaniel Caza.
673 // https://go-review.googlesource.com/c/go/+/36635/
674 // https://go-review.googlesource.com/c/go/+/36804/
675 func (fsrv *FileServer) mapDirOpenError(fileSystem fs.FS, originalErr error, name string) error {
676 if errors.Is(originalErr, fs.ErrNotExist) || errors.Is(originalErr, fs.ErrPermission) {
677 return originalErr
678 }
679
680 // a regular file used as a directory, e.g. /index.php/foo
681 if errors.Is(originalErr, syscall.ENOTDIR) {
682 return fs.ErrNotExist
683 }
684
685 if _, ok := errors.AsType[*fs.PathError](originalErr); ok {
686 return fs.ErrInvalid
687 }
688
689 parts := strings.Split(name, separator)
690 for i := range parts {
691 if parts[i] == "" {
692 continue
693 }
694 fi, err := fs.Stat(fileSystem, strings.Join(parts[:i+1], separator))
695 if err != nil {
696 return originalErr
697 }
698 if !fi.IsDir() {
699 return fs.ErrNotExist
700 }
701 }
702
703 return originalErr
704 }
705
706 // transformHidePaths performs replacements for all the elements of fsrv.Hide and
707 // makes them absolute paths (if they contain a path separator), then returns a
708 // new list of the transformed values.
709 func (fsrv *FileServer) transformHidePaths(repl *caddy.Replacer) []string {
710 hide := make([]string, len(fsrv.Hide))
711 for i := range fsrv.Hide {
712 hide[i] = repl.ReplaceAll(fsrv.Hide[i], "")
713 if strings.Contains(hide[i], separator) {
714 abs, err := caddy.FastAbs(hide[i])
715 if err == nil {
716 hide[i] = abs
717 }
718 }
719 }
720 return hide
721 }
722
723 // fileHidden returns true if filename is hidden according to the hide list.
724 // filename must be a relative or absolute file system path, not a request
725 // URI path. It is expected that all the paths in the hide list are absolute
726 // paths or are singular filenames (without a path separator).
727 func fileHidden(filename string, hide []string) bool {
728 if len(hide) == 0 {
729 return false
730 }
731
732 // all path comparisons use the complete absolute path if possible
733 filenameAbs, err := caddy.FastAbs(filename)
734 if err == nil {
735 filename = filenameAbs
736 }
737
738 var components []string
739 var folded string
740
741 for _, h := range hide {
742 if !hasGlobMeta(h) {
743 // case-insensitive filesystems open other spellings of a literal
744 // entry as the same file
745 if folded == "" {
746 folded = foldCase(filename)
747 }
748 if literalHidden(foldCase(h), folded) {
749 return true
750 }
751 continue
752 }
753
754 if !strings.Contains(h, separator) {
755 // if there is no separator in h, then we assume the user
756 // wants to hide any files or folders that match that
757 // name; thus we have to compare against each component
758 // of the filename, e.g. hiding "bar" would hide "/bar"
759 // as well as "/foo/bar/baz" but not "/barstool".
760 if len(components) == 0 {
761 components = strings.Split(filename, separator)
762 }
763 for _, c := range components {
764 if hidden, _ := filepath.Match(h, c); hidden {
765 return true
766 }
767 }
768 } else if after, ok := strings.CutPrefix(filename, h); ok {
769 // if there is a separator in h, and filename is exactly
770 // prefixed with h, then we can do a prefix match so that
771 // "/foo" matches "/foo/bar" but not "/foobar".
772 withoutPrefix := after
773 if strings.HasPrefix(withoutPrefix, separator) {
774 return true
775 }
776 }
777
778 // in the general case, a glob match will suffice
779 if hidden, _ := filepath.Match(h, filename); hidden {
780 return true
781 }
782 }
783
784 return false
785 }
786
787 // literalHidden reports whether filename is the literal entry h or is inside
788 // it, or, if h has no separator, has a path component named h.
789 func literalHidden(h, filename string) bool {
790 if !strings.Contains(h, separator) {
791 return slices.Contains(strings.Split(filename, separator), h)
792 }
793 after, ok := strings.CutPrefix(filename, h)
794 return ok && (after == "" || strings.HasPrefix(after, separator))
795 }
796
797 // hasGlobMeta reports whether filepath.Match treats h as a pattern.
798 func hasGlobMeta(h string) bool {
799 magic := `*?[\`
800 if runtime.GOOS == "windows" {
801 magic = `*?[`
802 }
803 return strings.ContainsAny(h, magic)
804 }
805
806 // foldCase maps the letter cases that case-insensitive filesystems treat as
807 // the same name to one form.
808 func foldCase(s string) string {
809 return strings.Map(func(r rune) rune { return unicode.ToLower(unicode.ToUpper(r)) }, s)
810 }
811
812 // notFound returns a 404 error or, if pass-thru is enabled,
813 // it calls the next handler in the chain.
814 func (fsrv *FileServer) notFound(w http.ResponseWriter, r *http.Request, next caddyhttp.Handler) error {
815 if fsrv.PassThru {
816 return next.ServeHTTP(w, r)
817 }
818 return caddyhttp.Error(http.StatusNotFound, nil)
819 }
820
821 // Indicates whether a file's modification time is useful for validator
822 // generation purposes (i.e. inclusion in ETag and Last-Modified headers).
823 // See issues #5548 and #7730.
824 func usefulModTime(modTime time.Time) bool {
825 mtimeunix := modTime.Unix()
826 return mtimeunix != 0 && mtimeunix != 1
827 }
828
829 // calculateEtag computes an entity tag using a strong validator
830 // without consuming the contents of the file. It requires the
831 // file info contain the correct size and modification time.
832 // It strives to implement the semantics regarding ETags as defined
833 // by RFC 9110 section 8.8.3 and 8.8.1. See
834 // https://www.rfc-editor.org/rfc/rfc9110.html#section-8.8.3.
835 //
836 // As our implementation uses file modification timestamp and size,
837 // note the following from RFC 9110 section 8.8.1: "A representation's
838 // modification time, if defined with only one-second resolution,
839 // might be a weak validator if it is possible for the representation to
840 // be modified twice during a single second and retrieved between those
841 // modifications." The ext4 file system, which underpins the vast majority
842 // of Caddy deployments, stores mod times with millisecond precision,
843 // which we consider precise enough to qualify as a strong validator.
844 func calculateEtag(d os.FileInfo) string {
845 mtime := d.ModTime()
846 if !usefulModTime(mtime) {
847 return ""
848 }
849 var sb strings.Builder
850 sb.WriteRune('"')
851 sb.WriteString(strconv.FormatInt(mtime.UnixNano(), 36))
852 sb.WriteRune('-')
853 sb.WriteString(strconv.FormatInt(d.Size(), 36))
854 sb.WriteRune('"')
855 return sb.String()
856 }
857
858 // Finds the first corresponding etag file for a given file in the file system and return its content
859 func (fsrv *FileServer) getEtagFromFile(fileSystem fs.FS, filename string) (string, error) {
860 for _, suffix := range fsrv.EtagFileExtensions {
861 etagFilename := filename + suffix
862 etag, err := fs.ReadFile(fileSystem, etagFilename)
863 if errors.Is(err, fs.ErrNotExist) {
864 continue
865 }
866 if err != nil {
867 return "", fmt.Errorf("cannot read etag from file %s: %v", etagFilename, err)
868 }
869
870 // Etags should not contain newline characters
871 etag = bytes.ReplaceAll(etag, []byte("\n"), []byte{})
872
873 return string(etag), nil
874 }
875 return "", nil
876 }
877
878 // redirect performs a redirect to a given path. The 'toPath' parameter
879 // MUST be solely a path, and MUST NOT include a query.
880 func redirect(w http.ResponseWriter, r *http.Request, toPath string) error {
881 for strings.HasPrefix(toPath, "//") {
882 // prevent path-based open redirects
883 toPath = strings.TrimPrefix(toPath, "/")
884 }
885 // preserve the query string if present
886 if r.URL.RawQuery != "" {
887 toPath += "?" + r.URL.RawQuery
888 }
889 http.Redirect(w, r, toPath, http.StatusPermanentRedirect) //nolint:gosec // toPath is a same-origin path and leading // is stripped above
890 return nil
891 }
892
893 // statusOverrideResponseWriter intercepts WriteHeader calls
894 // to instead write the HTTP status code we want instead
895 // of the one http.ServeContent will use by default (usually 200)
896 type statusOverrideResponseWriter struct {
897 http.ResponseWriter
898 code int
899 }
900
901 // WriteHeader intercepts calls by the stdlib to WriteHeader
902 // to instead write the HTTP status code we want.
903 func (wr statusOverrideResponseWriter) WriteHeader(int) {
904 wr.ResponseWriter.WriteHeader(wr.code)
905 }
906
907 // Unwrap returns the underlying ResponseWriter, necessary for
908 // http.ResponseController to work correctly.
909 func (wr statusOverrideResponseWriter) Unwrap() http.ResponseWriter {
910 return wr.ResponseWriter
911 }
912
913 var defaultIndexNames = []string{"index.html", "index.txt"}
914
915 const (
916 minBackoff, maxBackoff = 2, 5
917 separator = string(filepath.Separator)
918 )
919
920 // Interface guards
921 var (
922 _ caddy.Provisioner = (*FileServer)(nil)
923 _ caddyhttp.MiddlewareHandler = (*FileServer)(nil)
924 )