From e12c62e60b3f794630aed2fae37c4c6973e63bf4 Mon Sep 17 00:00:00 2001
From: Matthew Holt <mholt@users.noreply.github.com>
Date: Mon, 9 Sep 2019 08:21:45 -0600
Subject: file_server: Enforce URL canonicalization (closes #2741)

---
 modules/caddyhttp/fileserver/staticfiles.go | 48 ++++++++++++++++++++++++-----
 1 file changed, 40 insertions(+), 8 deletions(-)

diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go
index cdac453..3e4cccc 100644
--- a/modules/caddyhttp/fileserver/staticfiles.go
+++ b/modules/caddyhttp/fileserver/staticfiles.go
@@ -41,10 +41,11 @@ func init() {
 
 // FileServer implements a static file server responder for Caddy.
 type FileServer struct {
-	Root       string   `json:"root,omitempty"` // default is current directory
-	Hide       []string `json:"hide,omitempty"`
-	IndexNames []string `json:"index_names,omitempty"`
-	Browse     *Browse  `json:"browse,omitempty"`
+	Root          string   `json:"root,omitempty"` // default is current directory
+	Hide          []string `json:"hide,omitempty"`
+	IndexNames    []string `json:"index_names,omitempty"`
+	Browse        *Browse  `json:"browse,omitempty"`
+	CanonicalURIs *bool    `json:"canonical_uris,omitempty"`
 }
 
 // CaddyModule returns the Caddy module information.
@@ -57,6 +58,10 @@ func (FileServer) CaddyModule() caddy.ModuleInfo {
 
 // Provision sets up the static files responder.
 func (fsrv *FileServer) Provision(ctx caddy.Context) error {
+	if fsrv.Root == "" {
+		fsrv.Root = "{http.vars.root}"
+	}
+
 	if fsrv.IndexNames == nil {
 		fsrv.IndexNames = defaultIndexNames
 	}
@@ -105,6 +110,7 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, _ cadd
 
 	// if the request mapped to a directory, see if
 	// there is an index file we can serve
+	var implicitIndexFile bool
 	if info.IsDir() && len(fsrv.IndexNames) > 0 {
 		for _, indexPage := range fsrv.IndexNames {
 			indexPath := sanitizedPathJoin(filename, indexPage)
@@ -118,12 +124,17 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, _ cadd
 				continue
 			}
 
-			// we found an index file that might work,
-			// so rewrite the request path
-			r.URL.Path = path.Join(r.URL.Path, indexPage)
+			// don't rewrite the request path to append
+			// the index file, because we might need to
+			// do a canonical-URL redirect below based
+			// on the URL as-is
 
+			// we've chosen to use this index file,
+			// so replace the last file info and path
+			// with that of the index file
 			info = indexInfo
 			filename = indexPath
+			implicitIndexFile = true
 			break
 		}
 	}
@@ -145,10 +156,22 @@ func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request, _ cadd
 		return caddyhttp.Error(http.StatusNotFound, nil)
 	}
 
+	// if URL canonicalization is enabled, we need to enforce trailing
+	// slash convention: if a directory, trailing slash; if a file, no
+	// trailing slash - not enforcing this can break relative hrefs
+	// in HTML (see https://github.com/caddyserver/caddy/issues/2741)
+	if fsrv.CanonicalURIs == nil || *fsrv.CanonicalURIs {
+		if implicitIndexFile && !strings.HasSuffix(r.URL.Path, "/") {
+			return redirect(w, r, r.URL.Path+"/")
+		} else if !implicitIndexFile && strings.HasSuffix(r.URL.Path, "/") {
+			return redirect(w, r, r.URL.Path[:len(r.URL.Path)-1])
+		}
+	}
+
 	// open the file
 	file, err := fsrv.openFile(filename, w)
 	if err != nil {
-		return err
+		return err // error is already structured
 	}
 	defer file.Close()
 
@@ -305,6 +328,15 @@ func calculateEtag(d os.FileInfo) string {
 	return `"` + t + s + `"`
 }
 
+func redirect(w http.ResponseWriter, r *http.Request, to string) error {
+	for strings.HasPrefix(to, "//") {
+		// prevent path-based open redirects
+		to = strings.TrimPrefix(to, "/")
+	}
+	http.Redirect(w, r, to, http.StatusPermanentRedirect)
+	return nil
+}
+
 var defaultIndexNames = []string{"index.html", "index.txt"}
 
 var bufPool = sync.Pool{
-- 
cgit v1.2.3