@marcel91200 / caddy Mirror · read-only / commits / ef187721
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, 04:45 UTC

Merge commit from fork

* caddyhttp: canonicalise paths after prefix stripping * caddyhttp: cover suffix stripping in path canonicalisation

Zen Dodd committed Sep 15, 2026 at 04:50 UTC ef1877210ed3fe106edf80b11d9b580d95f0b026
3 files changed +161 -3
caddytest/integration/rewrite_security_test.go new
+79
@@ -0,0 +1,79 @@
1 +package integration
2 +
3 +import (
4 + "fmt"
5 + "net/http"
6 + "os"
7 + "path/filepath"
8 + "testing"
9 +
10 + "github.com/caddyserver/caddy/v2/caddytest"
11 +)
12 +
13 +func TestStripRewritePreservesPathAuthorization(t *testing.T) {
14 + root := t.TempDir()
15 + for name, content := range map[string]string{
16 + "index.html": "ROOT INDEX SECRET",
17 + "bar": "INNER SECRET",
18 + "secret": "DOT SEGMENT SECRET",
19 + } {
20 + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0o600); err != nil {
21 + t.Fatal(err)
22 + }
23 + }
24 +
25 + tester := caddytest.NewTester(t)
26 + tester.InitServer(fmt.Sprintf(`
27 + {
28 + skip_install_trust
29 + admin localhost:2999
30 + http_port 9080
31 + grace_period 1ns
32 + }
33 + http://localhost:9080 {
34 + root * %q
35 +
36 + handle_path /foo* {
37 + basic_auth /* {
38 + alice $2a$14$DCYyqvUO/PWT.UifNF0rbuRkRfjrhHViOfVfJklmqrvWUoYcTiN2q
39 + }
40 + file_server
41 + }
42 +
43 + handle /api* {
44 + uri strip_prefix /api
45 + basic_auth /secret* {
46 + alice $2a$14$DCYyqvUO/PWT.UifNF0rbuRkRfjrhHViOfVfJklmqrvWUoYcTiN2q
47 + }
48 + file_server
49 + }
50 +
51 + handle /suffix* {
52 + uri strip_suffix /suffix
53 + basic_auth /* {
54 + alice $2a$14$DCYyqvUO/PWT.UifNF0rbuRkRfjrhHViOfVfJklmqrvWUoYcTiN2q
55 + }
56 + file_server
57 + }
58 + }
59 + `, filepath.ToSlash(root)), "caddyfile")
60 +
61 + for _, requestPath := range []string{
62 + "/foo",
63 + "/foo/",
64 + "/foobar",
65 + "/foo/index.html",
66 + "/api/secret",
67 + "/api../secret",
68 + "/api..%2fsecret",
69 + "/suffix",
70 + } {
71 + t.Run(requestPath, func(t *testing.T) {
72 + req, err := http.NewRequest(http.MethodGet, "http://localhost:9080"+requestPath, nil)
73 + if err != nil {
74 + t.Fatal(err)
75 + }
76 + tester.AssertResponseCode(req, http.StatusUnauthorized)
77 + })
78 + }
79 +}
modules/caddyhttp/rewrite/rewrite.go
+42 -2
@@ -286,18 +286,30 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
286 prefix = "/" + prefix
287 }
288 mergeSlashes := !strings.Contains(prefix, "//")
289 + stripped := false
290 changePath(r, func(escapedPath string) string {
291 escapedPath = caddyhttp.CleanPath(escapedPath, mergeSlashes)
291 - return trimPathPrefix(escapedPath, prefix)
292 + trimmed := trimPathPrefix(escapedPath, prefix)
293 + stripped = stripped || trimmed != escapedPath
294 + return trimmed
295 })
296 + if stripped {
297 + canonicalizePath(r)
298 + }
299 }
300 if rewr.StripPathSuffix != "" {
301 suffix := repl.ReplaceAll(rewr.StripPathSuffix, "")
302 mergeSlashes := !strings.Contains(suffix, "//")
303 + stripped := false
304 changePath(r, func(escapedPath string) string {
305 escapedPath = caddyhttp.CleanPath(escapedPath, mergeSlashes)
299 - return trimPathSuffix(escapedPath, suffix)
306 + trimmed := trimPathSuffix(escapedPath, suffix)
307 + stripped = stripped || trimmed != escapedPath
308 + return trimmed
309 })
310 + if stripped {
311 + canonicalizePath(r)
312 + }
313 }
314
315 // substring replacements in URI
@@ -591,6 +603,34 @@ func changePath(req *http.Request, newVal func(pathOrRawPath string) string) {
603 }
604 }
605
606 +// canonicalizePath anchors and cleans a stripped origin-form path while
607 +// preserving an alternate RawPath encoding when it still represents the
608 +// canonical Path. It is applied after prefix or suffix removal because stripping can
609 +// expose a relative path or dot segments that downstream handlers interpret
610 +// differently.
611 +func canonicalizePath(req *http.Request) {
612 + p := req.URL.Path
613 + if !strings.HasPrefix(p, "/") {
614 + p = "/" + p
615 + }
616 + cleaned := caddyhttp.CleanPath(defaultEscapedPath(p), false)
617 + p, _ = url.PathUnescape(cleaned) // defaultEscapedPath always returns valid escapes
618 +
619 + rawPath := req.URL.RawPath
620 + if rawPath != "" {
621 + if !strings.HasPrefix(rawPath, "/") {
622 + rawPath = "/" + rawPath
623 + }
624 + decoded, err := url.PathUnescape(rawPath)
625 + if err != nil || decoded != p || rawPath == defaultEscapedPath(p) {
626 + rawPath = ""
627 + }
628 + }
629 +
630 + req.URL.Path = p
631 + req.URL.RawPath = rawPath
632 +}
633 +
634 // defaultEscapedPath returns the canonical percent-encoding of p, matching
635 // what net/url.URL.EscapedPath() produces when RawPath is empty. It mirrors
636 // the comparison net/url.URL.setPath uses to decide whether RawPath is needed.
modules/caddyhttp/rewrite/rewrite_test.go
+40 -1
@@ -250,7 +250,7 @@ func TestRewrite(t *testing.T) {
250 {
251 rule: Rewrite{StripPathPrefix: "/prefix"},
252 input: newRequest(t, "GET", "/prefix"),
253 - expect: newRequest(t, "GET", ""),
253 + expect: newRequest(t, "GET", "/"),
254 },
255 {
256 rule: Rewrite{StripPathPrefix: "/prefix"},
@@ -315,6 +315,11 @@ func TestRewrite(t *testing.T) {
315 input: newRequest(t, "GET", "/foo/bar"),
316 expect: newRequest(t, "GET", "/foo/bar"),
317 },
318 + {
319 + rule: Rewrite{StripPathSuffix: "/suffix"},
320 + input: newRequest(t, "GET", "/suffix"),
321 + expect: newRequest(t, "GET", "/"),
322 + },
323 {
324 rule: Rewrite{StripPathSuffix: "suffix"},
325 input: newRequest(t, "GET", "/foo/bar/suffix"),
@@ -673,6 +678,40 @@ func TestQueryOpsReplaceScopedToKey(t *testing.T) {
678 }
679 }
680
681 +func TestStripPathPrefixCanonicalizesRemainder(t *testing.T) {
682 + for _, tc := range []struct {
683 + name string
684 + target string
685 + prefix string
686 + path string
687 + rawPath string
688 + }{
689 + {name: "empty remainder", target: "/mount", prefix: "/mount", path: "/"},
690 + {name: "mid-segment remainder", target: "/mountain", prefix: "/mount", path: "/ain"},
691 + {name: "parent segment", target: "/mount../document", prefix: "/mount", path: "/document"},
692 + {name: "current segment", target: "/mount./document/", prefix: "/mount", path: "/document/"},
693 + {name: "encoded parent segment", target: "/mount%2e%2e/document", prefix: "/mount", path: "/document"},
694 + {name: "encoded leading separator", target: "/mount%2Fdocument", prefix: "/mount", path: "/document"},
695 + {name: "alternate separator encoding", target: "/mount/a%2Fb", prefix: "/mount", path: "/a/b", rawPath: "/a%2Fb"},
696 + {name: "double-encoded parent segment", target: "/mount/%252e%252e/document", prefix: "/mount", path: "/%2e%2e/document"},
697 + {name: "non-UTF-8 byte", target: "/mount/%ff", prefix: "/mount", path: "/\xff", rawPath: "/%ff"},
698 + {name: "repeated separators", target: "/mount//a//b/", prefix: "/mount//", path: "/a//b/"},
699 + {name: "ordinary remainder", target: "/mount/document", prefix: "/mount", path: "/document"},
700 + } {
701 + t.Run(tc.name, func(t *testing.T) {
702 + r := newRequest(t, http.MethodGet, tc.target+"?keep=yes")
703 + Rewrite{StripPathPrefix: tc.prefix}.Rewrite(r, caddy.NewReplacer())
704 +
705 + if r.URL.Path != tc.path || r.URL.RawPath != tc.rawPath {
706 + t.Errorf("Path/RawPath = %q/%q; want %q/%q", r.URL.Path, r.URL.RawPath, tc.path, tc.rawPath)
707 + }
708 + if r.URL.RawQuery != "keep=yes" || r.RequestURI != r.URL.EscapedPath()+"?keep=yes" {
709 + t.Errorf("query or request target changed inconsistently: %q", r.RequestURI)
710 + }
711 + })
712 + }
713 +}
714 +
715 func newRequest(t *testing.T, method, uri string) *http.Request {
716 req, err := http.NewRequest(method, uri, nil)
717 if err != nil {