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