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 {