@marcel91200 / caddy Mirror · read-only / commits / c6180a08
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, 08:30 UTC

caddyhttp: fix path_regexp (MatchPathRE) Windows backslash bypass (#7858)

* caddyhttp: normalize Windows path in path_regexp matcher, shared with path matcher Apply the same Windows path normalization to MatchPathRE that MatchPath already had, and factor it into a shared normalizeWindowsPath helper so both matchers use one implementation. Also strip trailing dots and spaces per path component (not only at the end of the whole path), matching how Windows resolves paths; this fixes the same gap in the path matcher too. Adds regression tests for both matchers. See #5613. * caddyhttp: normalize trailing dots/spaces in escaped-path branch too The MatchPath escaped-path branch (taken when a matcher pattern contains '%') only folded backslash separators via windowsEscapedPathSeparatorRepl; it skipped the per-component trailing dot/space normalization now applied on the decoded branch. On Windows this left a bypass: a matcher such as `path /private%2f*` could be evaded by GET /private.%5csecret.txt, since `private.` and `private` resolve to the same directory on NTFS. Add normalizeWindowsEscapedPath, which trims trailing dots and spaces — literal ("." / " ") and percent-encoded ("%2e" / "%20") — from every component in raw/escaped space, splitting on both '/' and encoded '%2f' separators while preserving them, and leaving "." / ".." for CleanPath. Regression tests cover the literal and percent-encoded variants against a '%'-containing matcher. --------- Co-authored-by: thientd <thien.taduc@ninhthanh.com>

Ta Duc Thien committed Jul 11, 2026 at 05:37 UTC c6180a085203f0a89505eecdb4a7c4d0dd96ac2d
2 files changed +198 -12
modules/caddyhttp/matchers.go
+98 -12
@@ -433,16 +433,7 @@ func (m MatchPath) MatchWithError(r *http.Request) (bool, error) {
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
@@ -482,6 +473,7 @@ func (m MatchPath) MatchWithError(r *http.Request) (bool, error) {
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) {
@@ -670,6 +662,98 @@ var windowsEscapedPathSeparatorRepl = strings.NewReplacer(
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 //
@@ -730,8 +814,10 @@ func (m MatchPathRE) MatchWithError(r *http.Request) (bool, error) {
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 }
modules/caddyhttp/matchers_test.go
+100
@@ -509,6 +509,34 @@ func TestPathMatcherWindows(t *testing.T) {
509 requestTarget: `/private%5csecret.txt`,
510 match: MatchPath{"/private%5c%*"},
511 },
512 + {
513 + name: "trailing dot on a middle path component",
514 + path: "/private./secret.txt",
515 + match: MatchPath{"/private/*"},
516 + },
517 + {
518 + name: "trailing space on a middle path component",
519 + path: "/private /secret.txt",
520 + match: MatchPath{"/private/*"},
521 + },
522 + {
523 + // escaped-space matcher (pattern contains '%'): the trailing
524 + // dot/space normalization must also apply on the escaped-path
525 + // branch, otherwise /private.%5csecret.txt bypasses /private%2f*.
526 + name: "trailing dot before encoded backslash, escaped-space matcher",
527 + requestTarget: `/private.%5csecret.txt`,
528 + match: MatchPath{"/private%2f*"},
529 + },
530 + {
531 + name: "encoded trailing dot before encoded backslash, escaped-space matcher",
532 + requestTarget: `/private%2e%5csecret.txt`,
533 + match: MatchPath{"/private%2f*"},
534 + },
535 + {
536 + name: "encoded trailing space before encoded backslash, escaped-space matcher",
537 + requestTarget: `/private%20%5csecret.txt`,
538 + match: MatchPath{"/private%2f*"},
539 + },
540 } {
541 t.Run(tc.name, func(t *testing.T) {
542 u := &url.URL{Path: tc.path}
@@ -534,6 +562,78 @@ func TestPathMatcherWindows(t *testing.T) {
562 }
563 }
564
565 +func TestPathREMatcherWindows(t *testing.T) {
566 + // Windows treats backslashes as path separators and ignores trailing
567 + // dots and spaces per path component, so the path_regexp matcher must
568 + // normalize them the same way the path matcher does (see #5613);
569 + // otherwise a guard such as `path_regexp ^/private/` is bypassed by e.g.
570 + // GET /private\secret.txt (or %5c) or GET /private./secret.txt.
571 + if runtime.GOOS != "windows" {
572 + return
573 + }
574 +
575 + repl := caddy.NewReplacer()
576 +
577 + for _, tc := range []struct {
578 + name string
579 + path string
580 + requestTarget string
581 + match MatchPathRE
582 + }{
583 + {
584 + name: "literal backslash path separator",
585 + path: `/private\secret.txt`,
586 + match: MatchPathRE{MatchRegexp{Pattern: "^/private/"}},
587 + },
588 + {
589 + name: "encoded backslash path separator",
590 + requestTarget: `/private%5csecret.txt`,
591 + match: MatchPathRE{MatchRegexp{Pattern: "^/private/"}},
592 + },
593 + {
594 + name: "uppercase encoded backslash path separator",
595 + requestTarget: `/private%5Csecret.txt`,
596 + match: MatchPathRE{MatchRegexp{Pattern: "^/private/"}},
597 + },
598 + {
599 + name: "trailing dot on a middle path component",
600 + path: `/private./secret.txt`,
601 + match: MatchPathRE{MatchRegexp{Pattern: "^/private/"}},
602 + },
603 + {
604 + name: "trailing space on a middle path component",
605 + path: `/private /secret.txt`,
606 + match: MatchPathRE{MatchRegexp{Pattern: "^/private/"}},
607 + },
608 + } {
609 + t.Run(tc.name, func(t *testing.T) {
610 + if err := tc.match.Provision(caddy.Context{}); err != nil {
611 + t.Fatalf("Provisioning: %v", err)
612 + }
613 +
614 + u := &url.URL{Path: tc.path}
615 + if tc.requestTarget != "" {
616 + var err error
617 + u, err = url.ParseRequestURI(tc.requestTarget)
618 + if err != nil {
619 + t.Fatalf("Parsing request target: %v", err)
620 + }
621 + }
622 + req := &http.Request{URL: u}
623 + ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
624 + req = req.WithContext(ctx)
625 +
626 + matched, err := tc.match.MatchWithError(req)
627 + if err != nil {
628 + t.Errorf("Expected no error, but got: %v", err)
629 + }
630 + if !matched {
631 + t.Errorf("Expected %q to match %v", req.URL.Path, tc.match.Pattern)
632 + }
633 + })
634 + }
635 +}
636 +
637 func TestPathREMatcher(t *testing.T) {
638 for i, tc := range []struct {
639 match MatchPathRE