@marcel91200 / caddy Mirror · read-only / commits / 217a7858
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, 11:00 UTC

caddyhttp: normalize Windows backslashes in path matcher (#7763)

Vincent Yang committed May 22, 2026 at 01:28 UTC 217a78582465e33498276aff83d9aaeb63a2f88a
2 files changed +72 -16
modules/caddyhttp/matchers.go
+19 -6
@@ -435,12 +435,12 @@ func (m MatchPath) MatchWithError(r *http.Request) (bool, error) {
435 // can be used instead.
436 reqPath := strings.ToLower(r.URL.Path)
437
438 - // See #2917; Windows ignores trailing dots and spaces
439 - // when accessing files (sigh), potentially causing a
440 - // security risk (cry) if PHP files end up being served
441 - // as static files, exposing the source code, instead of
442 - // being matched by *.php to be treated as PHP scripts.
438 if runtime.GOOS == "windows" { // issue #5613
439 + // Windows treats backslashes as path separators and
440 + // ignores trailing dots and spaces when accessing files
441 + // (sigh), potentially causing a security risk (cry) if
442 + // protected files are not matched as intended.
443 + reqPath = strings.ReplaceAll(reqPath, `\`, "/")
444 reqPath = strings.TrimRight(reqPath, ". ")
445 }
446
@@ -478,7 +478,12 @@ func (m MatchPath) MatchWithError(r *http.Request) (bool, error) {
478 // the intent is to compare that part of the path in raw/escaped
479 // space; i.e. "%40"=="%40", not "@", and "%2F"=="%2F", not "/"
480 if strings.Contains(matchPattern, "%") {
481 - reqPathForPattern := CleanPath(r.URL.EscapedPath(), mergeSlashes)
481 + escapedPath := r.URL.EscapedPath()
482 + if runtime.GOOS == "windows" {
483 + escapedPath = windowsEscapedPathSeparatorRepl.Replace(escapedPath)
484 + matchPattern = windowsEscapedPathSeparatorRepl.Replace(matchPattern)
485 + }
486 + reqPathForPattern := CleanPath(escapedPath, mergeSlashes)
487 if m.matchPatternWithEscapeSequence(reqPathForPattern, matchPattern) {
488 return true, nil
489 }
@@ -643,6 +648,14 @@ func (MatchPath) matchPatternWithEscapeSequence(escapedPath, matchPath string) b
648 return matches
649 }
650
651 +// windowsEscapedPathSeparatorRepl normalizes Windows backslash separators
652 +// while preserving escaped-path matching semantics.
653 +var windowsEscapedPathSeparatorRepl = strings.NewReplacer(
654 + `\`, "%2f",
655 + "%5c", "%2f",
656 + "%5C", "%2f",
657 +)
658 +
659 // CELLibrary produces options that expose this matcher for use in CEL
660 // expression matchers.
661 //
modules/caddyhttp/matchers_test.go
+53 -10
@@ -461,18 +461,61 @@ func TestPathMatcherWindows(t *testing.T) {
461 return
462 }
463
464 - req := &http.Request{URL: &url.URL{Path: "/index.php . . .."}}
464 repl := caddy.NewReplacer()
466 - ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
467 - req = req.WithContext(ctx)
465
469 - match := MatchPath{"*.php"}
470 - matched, err := match.MatchWithError(req)
471 - if err != nil {
472 - t.Errorf("Expected no error, but got: %v", err)
473 - }
474 - if !matched {
475 - t.Errorf("Expected to match; should ignore trailing dots and spaces")
466 + for _, tc := range []struct {
467 + name string
468 + path string
469 + requestTarget string
470 + match MatchPath
471 + }{
472 + {
473 + name: "trailing dots and spaces",
474 + path: "/index.php . . ..",
475 + match: MatchPath{"*.php"},
476 + },
477 + {
478 + name: "encoded backslash path separator",
479 + requestTarget: `/private%5csecret.txt`,
480 + match: MatchPath{"/private/*"},
481 + },
482 + {
483 + name: "encoded backslash path separator with escaped wildcard",
484 + requestTarget: `/private%5csecret.txt`,
485 + match: MatchPath{"/private/%*"},
486 + },
487 + {
488 + name: "uppercase encoded backslash path separator with escaped wildcard",
489 + requestTarget: `/private%5Csecret.txt`,
490 + match: MatchPath{"/private/%*"},
491 + },
492 + {
493 + name: "encoded backslash in escaped pattern",
494 + requestTarget: `/private%5csecret.txt`,
495 + match: MatchPath{"/private%5c%*"},
496 + },
497 + } {
498 + t.Run(tc.name, func(t *testing.T) {
499 + u := &url.URL{Path: tc.path}
500 + if tc.requestTarget != "" {
501 + var err error
502 + u, err = url.ParseRequestURI(tc.requestTarget)
503 + if err != nil {
504 + t.Fatalf("Parsing request target: %v", err)
505 + }
506 + }
507 + req := &http.Request{URL: u}
508 + ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
509 + req = req.WithContext(ctx)
510 +
511 + matched, err := tc.match.MatchWithError(req)
512 + if err != nil {
513 + t.Errorf("Expected no error, but got: %v", err)
514 + }
515 + if !matched {
516 + t.Errorf("Expected %q to match %v", req.URL.Path, tc.match)
517 + }
518 + })
519 }
520 }
521