@marcel91200 / caddy Mirror · read-only / commits / 5bd0b935
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 10, 23:45 UTC

fileserver: reject regular files addressed with a trailing separator (#8379)

* fileserver: don't serve regular file when path ends by slash With canonical URIs disabled, file_server trimmed the trailing slash before stat and served the file, while path matchers see "/secret.env/" and skip rules such as "path *.env". Return 404 (or pass through) instead. * fileserver: trim trailing OS separator before stat On Windows, SanitizedPathJoin appends a backslash for a path ending in "/", which the "/" trim left in place. Stat then failed on a regular file, so "/file/" returned 400 instead of the canonical redirect or a 404. * fileserver: keep trailing separator of a filesystem root Trimming it turned a root of `C:\` into `C:`, the current directory of the drive, and a root of `\` or `/` into an empty path, so requests for "/" listed or indexed the wrong directory, or failed. * fileserver: log request path on trailing slash 404 The logged filename has its trailing separator trimmed, so it didn't show the slash that caused the 404. * fileserver: check trailing slash on matcher path

Kévin Dunglas committed Nov 8, 2026 at 22:33 UTC 5bd0b9355d44863a588d8c422d5e5d28d5ffd70a
2 files changed +169 -2
modules/caddyhttp/fileserver/staticfiles.go
+19 -2
@@ -299,8 +299,13 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
299 return caddyhttp.Error(http.StatusNotFound, fmt.Errorf("filesystem not found"))
300 }
301
302 - // remove any trailing `/` as it breaks fs.ValidPath() in the stdlib
303 - filename := strings.TrimSuffix(caddyhttp.SanitizedPathJoin(root, r.URL.Path), "/")
302 + // remove any trailing separator as it breaks fs.ValidPath() in the stdlib,
303 + // and Windows rejects it on a regular file; keep it on a root such as `/` or
304 + // `C:\`, where removing it changes the target
305 + filename := caddyhttp.SanitizedPathJoin(root, r.URL.Path)
306 + if len(filename) > len(filepath.VolumeName(filename))+1 {
307 + filename = strings.TrimSuffix(filename, separator)
308 + }
309
310 if c := fsrv.logger.Check(zapcore.DebugLevel, "sanitized path join"); c != nil {
311 c.Write(
@@ -427,6 +432,18 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, next c
432 }
433 }
434
435 + // a trailing slash denotes a directory: path matchers don't see "/file/"
436 + // as "/file", so serving the file here would bypass them
437 + if !implicitIndexFile && strings.HasSuffix(caddyhttp.NormalizedPath(r.URL.Path, false), "/") {
438 + if c := fsrv.logger.Check(zapcore.DebugLevel, "path with trailing slash is not a directory"); c != nil {
439 + c.Write(
440 + zap.String("request_path", r.URL.Path),
441 + zap.String("filename", filename),
442 + )
443 + }
444 + return fsrv.notFound(w, r, next)
445 + }
446 +
447 var file fs.File
448 respHeader := w.Header()
449
modules/caddyhttp/fileserver/staticfiles_test.go
+150
@@ -18,6 +18,7 @@ import (
18 "bytes"
19 "compress/gzip"
20 "context"
21 + "encoding/json"
22 "errors"
23 "fmt"
24 "net/http"
@@ -445,3 +446,152 @@ func TestServeHTTPStatErrors(t *testing.T) {
446 }
447 }
448 }
449 +
450 +// A trailing slash denotes a directory, so path matchers don't treat
451 +// "/secret.env/" as "*.env"; the file server must not serve the file either.
452 +func TestServeHTTPTrailingSlashOnFile(t *testing.T) {
453 + root := t.TempDir()
454 + if err := os.WriteFile(filepath.Join(root, "secret.env"), []byte("secret"), 0o600); err != nil {
455 + t.Fatal(err)
456 + }
457 + if err := os.WriteFile(filepath.Join(root, "secret.env.gz"), gzipBytes(t, []byte("secret")), 0o600); err != nil {
458 + t.Fatal(err)
459 + }
460 + if err := os.Mkdir(filepath.Join(root, "dir"), 0o700); err != nil {
461 + t.Fatal(err)
462 + }
463 + if err := os.WriteFile(filepath.Join(root, "dir", "index.html"), []byte("index"), 0o600); err != nil {
464 + t.Fatal(err)
465 + }
466 +
467 + for i, tc := range []struct {
468 + path string
469 + canonical bool
470 + acceptEncoding string
471 + passThru bool
472 + wantStatus int
473 + wantLocation string
474 + wantNext bool
475 + }{
476 + {path: "/secret.env", wantStatus: http.StatusOK},
477 + {path: "/secret.env/", wantStatus: http.StatusNotFound},
478 + {path: "/secret.env/./", wantStatus: http.StatusNotFound},
479 + {path: "/secret.env//", wantStatus: http.StatusNotFound},
480 + {path: "/secret.env%2F", wantStatus: http.StatusNotFound},
481 + {path: "/secret.env%5c", wantStatus: http.StatusNotFound},
482 + {path: "/secret.env%5C", wantStatus: http.StatusNotFound},
483 + {path: `/secret.env\`, wantStatus: http.StatusNotFound},
484 + {path: "/secret.env%5c", canonical: true, wantStatus: http.StatusNotFound},
485 + {path: "/secret.env%5C", canonical: true, wantStatus: http.StatusNotFound},
486 + {path: `/secret.env\`, canonical: true, wantStatus: http.StatusNotFound},
487 + {path: "/secret.env/", acceptEncoding: "gzip", wantStatus: http.StatusNotFound},
488 + {path: "/secret.env/", passThru: true, wantNext: true},
489 + {path: "/secret.env/", canonical: true, wantStatus: http.StatusPermanentRedirect, wantLocation: "/secret.env"},
490 + {path: "/dir/", wantStatus: http.StatusOK},
491 + {path: "/dir", wantStatus: http.StatusOK},
492 + {path: "/dir/index.html/", wantStatus: http.StatusNotFound},
493 + } {
494 + fsrv := FileServer{
495 + Root: root,
496 + CanonicalURIs: &tc.canonical,
497 + PassThru: tc.passThru,
498 + PrecompressedOrder: []string{"gzip"},
499 + }
500 + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
501 + if err := fsrv.Provision(ctx); err != nil {
502 + t.Fatal(err)
503 + }
504 + fsrv.precompressors = map[string]encode.Precompressed{
505 + "gzip": testPrecompressed{encoding: "gzip", suffix: ".gz"},
506 + }
507 +
508 + r := httptest.NewRequest(http.MethodGet, tc.path, nil)
509 + r.Header.Set("Accept-Encoding", tc.acceptEncoding)
510 + reqCtx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
511 + reqCtx = context.WithValue(reqCtx, caddyhttp.OriginalRequestCtxKey, *r)
512 + r = r.WithContext(reqCtx)
513 +
514 + var calledNext bool
515 + next := caddyhttp.HandlerFunc(func(http.ResponseWriter, *http.Request) error {
516 + calledNext = true
517 + return nil
518 + })
519 +
520 + w := httptest.NewRecorder()
521 + err := fsrv.ServeHTTP(w, r, next)
522 + cancel()
523 +
524 + if tc.wantNext {
525 + if err != nil || !calledNext {
526 + t.Errorf("Test %d (%s): expected the next handler, got called=%t err=%v", i, tc.path, calledNext, err)
527 + }
528 + continue
529 + }
530 + status := w.Code
531 + if he, ok := errors.AsType[caddyhttp.HandlerError](err); ok {
532 + status = he.StatusCode
533 + } else if err != nil {
534 + t.Errorf("Test %d (%s): unexpected error: %v", i, tc.path, err)
535 + continue
536 + }
537 + if status != tc.wantStatus {
538 + t.Errorf("Test %d (%s): expected status %d, got %d", i, tc.path, tc.wantStatus, status)
539 + }
540 + if got := w.Header().Get("Location"); got != tc.wantLocation {
541 + t.Errorf("Test %d (%s): expected Location %q, got %q", i, tc.path, tc.wantLocation, got)
542 + }
543 + }
544 +}
545 +
546 +// The trailing separator of a filesystem root such as "/" or `C:\` must be
547 +// kept, else `C:` resolves to the current directory of the drive.
548 +func TestServeHTTPFilesystemRoot(t *testing.T) {
549 + wd, err := os.Getwd()
550 + if err != nil {
551 + t.Fatal(err)
552 + }
553 + volume := filepath.VolumeName(wd)
554 + // top-level directory leading to wd, listed only if the root is served
555 + topDir := strings.Split(strings.TrimPrefix(wd[len(volume):], separator), separator)[0]
556 +
557 + roots := []string{volume + separator}
558 + if runtime.GOOS == "windows" {
559 + roots = append(roots, separator)
560 + }
561 + for _, root := range roots {
562 + t.Run(root, func(t *testing.T) {
563 + fsrv := FileServer{Root: root, Browse: &Browse{}}
564 + ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
565 + defer cancel()
566 + if err := fsrv.Provision(ctx); err != nil {
567 + t.Fatal(err)
568 + }
569 +
570 + r := httptest.NewRequest(http.MethodGet, "/", nil)
571 + reqCtx := context.WithValue(r.Context(), caddy.ReplacerCtxKey, caddy.NewReplacer())
572 + reqCtx = context.WithValue(reqCtx, caddyhttp.OriginalRequestCtxKey, *r)
573 + r = r.WithContext(reqCtx)
574 + r.Header.Set("Accept", "application/json")
575 + w := httptest.NewRecorder()
576 + if err := fsrv.ServeHTTP(w, r, nil); err != nil {
577 + t.Fatal(err)
578 + }
579 + if w.Code != http.StatusOK {
580 + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
581 + }
582 +
583 + var items []struct {
584 + Name string `json:"name"`
585 + }
586 + if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
587 + t.Fatal(err)
588 + }
589 + for _, item := range items {
590 + if strings.TrimSuffix(item.Name, "/") == topDir {
591 + return
592 + }
593 + }
594 + t.Errorf("listing of %q doesn't contain %q: %s", root, topDir, w.Body.String())
595 + })
596 + }
597 +}