@marcel91200 / caddy Mirror · read-only / commits / a4a38c3e
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, 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