433
// related to differences between operating systems, applications,
434
// etc; if case-sensitive matching is needed, the regex matcher
435
// can be used instead.
436
- reqPath := strings.ToLower(r.URL.Path)
437
-
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
- }
436
+ reqPath := normalizeWindowsPath(strings.ToLower(r.URL.Path))
437
438
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
439
473
if runtime.GOOS == "windows" {
474
escapedPath = windowsEscapedPathSeparatorRepl.Replace(escapedPath)
475
matchPattern = windowsEscapedPathSeparatorRepl.Replace(matchPattern)
476
+ escapedPath = normalizeWindowsEscapedPath(escapedPath)
477
}
478
reqPathForPattern := CleanPath(escapedPath, mergeSlashes)
479
if m.matchPatternWithEscapeSequence(reqPathForPattern, matchPattern) {
662
"%5C", "%2f",
663
)
664
665
+// normalizeWindowsPath rewrites a decoded request path the way the Windows
666
+// filesystem resolves it, so that path matchers (both `path` and `path_regexp`)
667
+// cannot be bypassed on Windows: backslashes are treated as path separators,
668
+// and trailing dots and spaces are ignored on EVERY path component (not only at
669
+// the end of the whole path — e.g. a guard on `/private/` must also cover
670
+// `/private./secret`). Path navigation segments ("." and "..") are left intact
671
+// for cleanPath to resolve. It is a no-op on non-Windows systems. See #5613.
672
+func normalizeWindowsPath(p string) string {
673
+ if runtime.GOOS != "windows" {
674
+ return p
675
+ }
676
+ p = strings.ReplaceAll(p, `\`, "/")
677
+ segments := strings.Split(p, "/")
678
+ for i, s := range segments {
679
+ if s == "." || s == ".." {
680
+ continue
681
+ }
682
+ segments[i] = strings.TrimRight(s, ". ")
683
+ }
684
+ return strings.Join(segments, "/")
685
+}
686
+
687
+// normalizeWindowsEscapedPath applies the same Windows normalization as
688
+// normalizeWindowsPath, but in raw/escaped space (used when a matcher pattern
689
+// contains '%'), where path separators may be percent-encoded ("%2f") and a
690
+// component's trailing dots and spaces may be literal ("." / " ") or
691
+// percent-encoded ("%2e" / "%20"). Without this, a '%'-containing matcher such
692
+// as `/private%2f*` is bypassed on Windows by e.g. GET /private.%5csecret.txt,
693
+// because only backslash separators — not the per-component trailing dots and
694
+// spaces — were folded. The caller must have already folded backslash
695
+// separators to "%2f" via windowsEscapedPathSeparatorRepl. Navigation segments
696
+// ("." and "..") are left intact for CleanPath to resolve. Only the request
697
+// path is normalized, not the matcher pattern (mirroring normalizeWindowsPath).
698
+// It is a no-op on non-Windows systems. See #5613.
699
+func normalizeWindowsEscapedPath(p string) string {
700
+ if runtime.GOOS != "windows" {
701
+ return p
702
+ }
703
+ var sb strings.Builder
704
+ sb.Grow(len(p))
705
+ // walk the path, splitting on separators that are meaningful on Windows:
706
+ // the literal '/' and the encoded '%2f'/'%2F' (backslashes were already
707
+ // folded to "%2f" by the caller). Trim each component, preserving the
708
+ // separators exactly so escaped-space comparison still lines up.
709
+ start := 0
710
+ flush := func(end int) {
711
+ seg := p[start:end]
712
+ if seg != "." && seg != ".." {
713
+ seg = trimWindowsEscapedTrailingDotSpace(seg)
714
+ }
715
+ sb.WriteString(seg)
716
+ }
717
+ for i := 0; i < len(p); {
718
+ switch {
719
+ case p[i] == '/':
720
+ flush(i)
721
+ sb.WriteByte('/')
722
+ i++
723
+ start = i
724
+ case p[i] == '%' && i+3 <= len(p) && p[i+1] == '2' && (p[i+2] == 'f' || p[i+2] == 'F'):
725
+ flush(i)
726
+ sb.WriteString(p[i : i+3])
727
+ i += 3
728
+ start = i
729
+ default:
730
+ i++
731
+ }
732
+ }
733
+ flush(len(p))
734
+ return sb.String()
735
+}
736
+
737
+// trimWindowsEscapedTrailingDotSpace strips trailing dots and spaces — whether
738
+// literal ("." / " ") or percent-encoded ("%2e"/"%2E" / "%20") — from a single
739
+// escaped-space path component, matching how Windows ignores them.
740
+func trimWindowsEscapedTrailingDotSpace(s string) string {
741
+ for len(s) > 0 {
742
+ if c := s[len(s)-1]; c == '.' || c == ' ' {
743
+ s = s[:len(s)-1]
744
+ continue
745
+ }
746
+ if len(s) >= 3 {
747
+ if tail := s[len(s)-3:]; strings.EqualFold(tail, "%2e") || tail == "%20" {
748
+ s = s[:len(s)-3]
749
+ continue
750
+ }
751
+ }
752
+ return s
753
+ }
754
+ return s
755
+}
756
+
757
// CELLibrary produces options that expose this matcher for use in CEL
758
// expression matchers.
759
//
814
815
// Clean the path, merges doubled slashes, etc.
816
// This ensures maliciously crafted requests can't bypass
733
- // the path matcher. See #4407
734
- cleanedPath := cleanPath(r.URL.Path)
817
+ // the path matcher. See #4407 (path cleaning) and #5613
818
+ // (Windows backslash / trailing dot-and-space normalization,
819
+ // shared with the path matcher).
820
+ cleanedPath := cleanPath(normalizeWindowsPath(r.URL.Path))
821
822
return m.MatchRegexp.Match(cleanedPath, repl), nil
823
}