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
rewrite: escape file matcher paths before rewriting (#7683)
* fix: escape file matcher paths in rewrites Preserve matched file paths containing literal '?' or '%' when try_files rewrites to http.matchers.file.relative. * test: cover nested escaped try_files rewrite paths * test: cover encoded slash try_files rewrite paths * fix: assert file matcher placeholder as string --------- Co-authored-by: cyphercodes <cyphercodes@users.noreply.github.com>
Rayan Salhab committed
May 12, 2026 at 02:16 UTC
a4a38c3e88952a361c6e44c5dc3200bba1497e15
2 files changed
+126
-6
modules/caddyhttp/fileserver/matcher_test.go
+100
@@ -28,6 +28,7 @@ import (
28
"github.com/caddyserver/caddy/v2"
29
"github.com/caddyserver/caddy/v2/internal/filesystems"
30
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
31
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite"
32
)
33
34
type testCase struct {
@@ -188,6 +189,105 @@ func fileMatcherTest(t *testing.T, i int, tc testCase) {
189
}
190
}
191
192
+func TestTryFilesRewriteEscapesMatchedPath(t *testing.T) {
193
+ root := t.TempDir()
194
+
195
+ tests := []struct {
196
+ name string
197
+ requestTarget string
198
+ filename string
199
+ extraFiles []string
200
+ wantPath string
201
+ wantRequestURI string
202
+ skipWindows bool
203
+ }{
204
+ {
205
+ name: "question mark in path",
206
+ requestTarget: "/%3F.html",
207
+ filename: "?.html",
208
+ wantPath: "/?.html",
209
+ wantRequestURI: "/%3F.html",
210
+ skipWindows: true,
211
+ },
212
+ {
213
+ name: "percent in path",
214
+ requestTarget: "/%25.html",
215
+ filename: "%.html",
216
+ wantPath: "/%.html",
217
+ wantRequestURI: "/%25.html",
218
+ },
219
+ {
220
+ name: "encoded question mark remains percent-encoded",
221
+ requestTarget: "/%253F.html",
222
+ filename: "%3F.html",
223
+ wantPath: "/%3F.html",
224
+ wantRequestURI: "/%253F.html",
225
+ },
226
+ {
227
+ name: "question mark in nested path",
228
+ requestTarget: "/nested/%3F.html",
229
+ filename: filepath.Join("nested", "?.html"),
230
+ wantPath: "/nested/?.html",
231
+ wantRequestURI: "/nested/%3F.html",
232
+ skipWindows: true,
233
+ },
234
+ {
235
+ name: "encoded slash in filename does not conflict with nesting",
236
+ requestTarget: "/nested%252Ffile.html",
237
+ filename: "nested%2Ffile.html",
238
+ extraFiles: []string{filepath.Join("nested", "file.html")},
239
+ wantPath: "/nested%2Ffile.html",
240
+ wantRequestURI: "/nested%252Ffile.html",
241
+ },
242
+ }
243
+
244
+ for _, tc := range tests {
245
+ t.Run(tc.name, func(t *testing.T) {
246
+ if tc.skipWindows && runtime.GOOS == "windows" {
247
+ t.Skip("Windows file names cannot contain question marks")
248
+ }
249
+
250
+ for _, name := range append([]string{tc.filename}, tc.extraFiles...) {
251
+ filename := filepath.Join(root, name)
252
+ if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
253
+ t.Fatalf("creating test file parent directory: %v", err)
254
+ }
255
+ if err := os.WriteFile(filename, []byte(name), 0o600); err != nil {
256
+ t.Fatalf("writing test file: %v", err)
257
+ }
258
+ }
259
+
260
+ m := &MatchFile{
261
+ fsmap: &filesystems.FileSystemMap{},
262
+ Root: root,
263
+ TryFiles: []string{"{http.request.uri.path}"},
264
+ }
265
+ req := httptest.NewRequest(http.MethodGet, "http://example.com"+tc.requestTarget, nil)
266
+ repl := caddyhttp.NewTestReplacer(req)
267
+
268
+ matched, err := m.MatchWithError(req)
269
+ if err != nil {
270
+ t.Fatalf("matching file: %v", err)
271
+ }
272
+ if !matched {
273
+ t.Fatalf("expected request %s to match %s", tc.requestTarget, tc.filename)
274
+ }
275
+
276
+ rewrite.Rewrite{URI: "{http.matchers.file.relative}"}.Rewrite(req, repl)
277
+
278
+ if req.URL.Path != tc.wantPath {
279
+ t.Errorf("rewritten path = %q, want %q", req.URL.Path, tc.wantPath)
280
+ }
281
+ if req.RequestURI != tc.wantRequestURI {
282
+ t.Errorf("rewritten request URI = %q, want %q", req.RequestURI, tc.wantRequestURI)
283
+ }
284
+ if req.URL.RawQuery != "" {
285
+ t.Errorf("rewritten raw query = %q, want empty", req.URL.RawQuery)
286
+ }
287
+ })
288
+ }
289
+}
290
+
291
func TestPHPFileMatcher(t *testing.T) {
292
for i, tc := range []struct {
293
path string
modules/caddyhttp/rewrite/rewrite.go
+26
-6
@@ -211,12 +211,7 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
211
var newPath, newQuery, newFrag string
212
213
if path != "" {
214
- // replace the `path` placeholder to escaped path
215
- pathPlaceholder := "{http.request.uri.path}"
216
- if strings.Contains(path, pathPlaceholder) {
217
- path = strings.ReplaceAll(path, pathPlaceholder, r.URL.EscapedPath())
218
- }
219
-
214
+ path = escapePathPlaceholders(path, r, repl)
215
newPath = repl.ReplaceAll(path, "")
216
}
217
@@ -300,6 +295,31 @@ func (rewr Rewrite) Rewrite(r *http.Request, repl *caddy.Replacer) bool {
295
return r.Method != oldMethod || r.RequestURI != oldURI
296
}
297
298
+func escapePathPlaceholders(path string, r *http.Request, repl *caddy.Replacer) string {
299
+ // Replace path-valued placeholders in escaped form before the URI is parsed,
300
+ // otherwise literal '?' and '%' bytes from the path can be interpreted as URI
301
+ // delimiters or percent-escape sequences during the rewrite.
302
+ pathPlaceholder := "{http.request.uri.path}"
303
+ if strings.Contains(path, pathPlaceholder) {
304
+ path = strings.ReplaceAll(path, pathPlaceholder, r.URL.EscapedPath())
305
+ }
306
+
307
+ fileMatchRelativePlaceholder := "{http.matchers.file.relative}"
308
+ if strings.Contains(path, fileMatchRelativePlaceholder) {
309
+ if val, ok := repl.Get("http.matchers.file.relative"); ok {
310
+ if relativePath, ok := val.(string); ok {
311
+ path = strings.ReplaceAll(path, fileMatchRelativePlaceholder, escapePathPreservingSlashes(relativePath))
312
+ }
313
+ }
314
+ }
315
+
316
+ return path
317
+}
318
+
319
+func escapePathPreservingSlashes(path string) string {
320
+ return strings.ReplaceAll(url.PathEscape(path), "%2F", "/")
321
+}
322
+
323
// buildQueryString takes an input query string and
324
// performs replacements on each component, returning
325
// the resulting query string. This function appends