From a9698728506c580bc38db2e122a5e6ef07f85ce6 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Mon, 20 May 2019 21:21:33 -0600 Subject: Default error handler; rename StaticFiles -> FileServer --- modules/caddyhttp/fileserver/browse.go | 157 +++++++++ modules/caddyhttp/fileserver/browselisting.go | 245 +++++++++++++ modules/caddyhttp/fileserver/browsetpl.go | 403 ++++++++++++++++++++++ modules/caddyhttp/fileserver/matcher.go | 57 ++++ modules/caddyhttp/fileserver/staticfiles.go | 416 +++++++++++++++++++++++ modules/caddyhttp/fileserver/staticfiles_test.go | 81 +++++ 6 files changed, 1359 insertions(+) create mode 100644 modules/caddyhttp/fileserver/browse.go create mode 100644 modules/caddyhttp/fileserver/browselisting.go create mode 100644 modules/caddyhttp/fileserver/browsetpl.go create mode 100644 modules/caddyhttp/fileserver/matcher.go create mode 100644 modules/caddyhttp/fileserver/staticfiles.go create mode 100644 modules/caddyhttp/fileserver/staticfiles_test.go (limited to 'modules/caddyhttp/fileserver') diff --git a/modules/caddyhttp/fileserver/browse.go b/modules/caddyhttp/fileserver/browse.go new file mode 100644 index 0000000..ed5ad42 --- /dev/null +++ b/modules/caddyhttp/fileserver/browse.go @@ -0,0 +1,157 @@ +package fileserver + +import ( + "bytes" + "encoding/json" + "html/template" + "net/http" + "os" + "path" + "strings" + + "bitbucket.org/lightcodelabs/caddy2" + "bitbucket.org/lightcodelabs/caddy2/modules/caddyhttp" +) + +// Browse configures directory browsing. +type Browse struct { + TemplateFile string `json:"template_file"` + + template *template.Template +} + +func (fsrv *FileServer) serveBrowse(dirPath string, w http.ResponseWriter, r *http.Request) error { + dir, err := fsrv.openFile(dirPath, w) + if err != nil { + return err + } + defer dir.Close() + + repl := r.Context().Value(caddy2.ReplacerCtxKey).(caddy2.Replacer) + + listing, err := fsrv.loadDirectoryContents(dir, r.URL.Path, repl) + switch { + case os.IsPermission(err): + return caddyhttp.Error(http.StatusForbidden, err) + case os.IsNotExist(err): + return caddyhttp.Error(http.StatusNotFound, err) + case err != nil: + return caddyhttp.Error(http.StatusInternalServerError, err) + } + + fsrv.browseApplyQueryParams(w, r, &listing) + + // write response as either JSON or HTML + var buf *bytes.Buffer + acceptHeader := strings.ToLower(strings.Join(r.Header["Accept"], ",")) + if strings.Contains(acceptHeader, "application/json") { + if buf, err = fsrv.browseWriteJSON(listing); err != nil { + return caddyhttp.Error(http.StatusInternalServerError, err) + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + } else { + if buf, err = fsrv.browseWriteHTML(listing); err != nil { + return caddyhttp.Error(http.StatusInternalServerError, err) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + } + buf.WriteTo(w) + + return nil + + // TODO: Sigh... do we have to put this here? + // // Browsing navigation gets messed up if browsing a directory + // // that doesn't end in "/" (which it should, anyway) + // u := *r.URL + // if u.Path == "" { + // u.Path = "/" + // } + // if u.Path[len(u.Path)-1] != '/' { + // u.Path += "/" + // http.Redirect(w, r, u.String(), http.StatusMovedPermanently) + // return http.StatusMovedPermanently, nil + // } + + // return b.ServeListing(w, r, requestedFilepath, bc) +} + +func (fsrv *FileServer) loadDirectoryContents(dir *os.File, urlPath string, repl caddy2.Replacer) (browseListing, error) { + files, err := dir.Readdir(-1) + if err != nil { + return browseListing{}, err + } + + // determine if user can browse up another folder + curPathDir := path.Dir(strings.TrimSuffix(urlPath, "/")) + canGoUp := strings.HasPrefix(curPathDir, fsrv.Root) + + return fsrv.directoryListing(files, canGoUp, urlPath, repl), nil +} + +// browseApplyQueryParams applies query parameters to the listing. +// It mutates the listing and may set cookies. +func (fsrv *FileServer) browseApplyQueryParams(w http.ResponseWriter, r *http.Request, listing *browseListing) { + sortParam := r.URL.Query().Get("sort") + orderParam := r.URL.Query().Get("order") + limitParam := r.URL.Query().Get("limit") + + // first figure out what to sort by + switch sortParam { + case "": + sortParam = sortByNameDirFirst + if sortCookie, sortErr := r.Cookie("sort"); sortErr == nil { + sortParam = sortCookie.Value + } + case sortByName, sortByNameDirFirst, sortBySize, sortByTime: + http.SetCookie(w, &http.Cookie{Name: "sort", Value: sortParam, Secure: r.TLS != nil}) + } + + // then figure out the order + switch orderParam { + case "": + orderParam = "asc" + if orderCookie, orderErr := r.Cookie("order"); orderErr == nil { + orderParam = orderCookie.Value + } + case "asc", "desc": + http.SetCookie(w, &http.Cookie{Name: "order", Value: orderParam, Secure: r.TLS != nil}) + } + + // finally, apply the sorting and limiting + listing.applySortAndLimit(sortParam, orderParam, limitParam) +} + +func (fsrv *FileServer) browseWriteJSON(listing browseListing) (*bytes.Buffer, error) { + buf := new(bytes.Buffer) + err := json.NewEncoder(buf).Encode(listing.Items) + return buf, err +} + +func (fsrv *FileServer) browseWriteHTML(listing browseListing) (*bytes.Buffer, error) { + buf := new(bytes.Buffer) + err := fsrv.Browse.template.Execute(buf, listing) + return buf, err +} + +// isSymlink return true if f is a symbolic link +func isSymlink(f os.FileInfo) bool { + return f.Mode()&os.ModeSymlink != 0 +} + +// isSymlinkTargetDir return true if f's symbolic link target +// is a directory. Return false if not a symbolic link. +// TODO: Re-implement +func isSymlinkTargetDir(f os.FileInfo, urlPath string) bool { + // if !isSymlink(f) { + // return false + // } + + // // TODO: Ensure path is sanitized + // target:= path.Join(root, urlPath, f.Name())) + // targetInfo, err := os.Stat(target) + // if err != nil { + // return false + // } + // return targetInfo.IsDir() + return false +} diff --git a/modules/caddyhttp/fileserver/browselisting.go b/modules/caddyhttp/fileserver/browselisting.go new file mode 100644 index 0000000..e17b7ae --- /dev/null +++ b/modules/caddyhttp/fileserver/browselisting.go @@ -0,0 +1,245 @@ +package fileserver + +import ( + "net/url" + "os" + "path" + "sort" + "strconv" + "strings" + "time" + + "bitbucket.org/lightcodelabs/caddy2" + "github.com/dustin/go-humanize" +) + +func (fsrv *FileServer) directoryListing(files []os.FileInfo, canGoUp bool, urlPath string, repl caddy2.Replacer) browseListing { + filesToHide := fsrv.transformHidePaths(repl) + + var ( + fileInfos []fileInfo + dirCount, fileCount int + ) + + for _, f := range files { + name := f.Name() + + if fileHidden(name, filesToHide) { + continue + } + + isDir := f.IsDir() || isSymlinkTargetDir(f, urlPath) + + if isDir { + name += "/" + dirCount++ + } else { + fileCount++ + } + + u := url.URL{Path: "./" + name} // prepend with "./" to fix paths with ':' in the name + + fileInfos = append(fileInfos, fileInfo{ + IsDir: isDir, + IsSymlink: isSymlink(f), + Name: f.Name(), + Size: f.Size(), + URL: u.String(), + ModTime: f.ModTime().UTC(), + Mode: f.Mode(), + }) + } + + return browseListing{ + Name: path.Base(urlPath), + Path: urlPath, + CanGoUp: canGoUp, + Items: fileInfos, + NumDirs: dirCount, + NumFiles: fileCount, + } +} + +type browseListing struct { + // The name of the directory (the last element of the path). + Name string + + // The full path of the request. + Path string + + // Whether the parent directory is browseable. + CanGoUp bool + + // The items (files and folders) in the path. + Items []fileInfo + + // The number of directories in the listing. + NumDirs int + + // The number of files (items that aren't directories) in the listing. + NumFiles int + + // Sort column used + Sort string + + // Sorting order + Order string + + // If ≠0 then Items have been limited to that many elements. + ItemsLimitedTo int +} + +// Breadcrumbs returns l.Path where every element maps +// the link to the text to display. +func (l browseListing) Breadcrumbs() []crumb { + var result []crumb + + if len(l.Path) == 0 { + return result + } + + // skip trailing slash + lpath := l.Path + if lpath[len(lpath)-1] == '/' { + lpath = lpath[:len(lpath)-1] + } + + parts := strings.Split(lpath, "/") + for i := range parts { + txt := parts[i] + if i == 0 && parts[i] == "" { + txt = "/" + } + lnk := strings.Repeat("../", len(parts)-i-1) + result = append(result, crumb{Link: lnk, Text: txt}) + } + + return result +} + +func (l *browseListing) applySortAndLimit(sortParam, orderParam, limitParam string) { + l.Sort = sortParam + l.Order = orderParam + + if l.Order == "desc" { + switch l.Sort { + case sortByName: + sort.Sort(sort.Reverse(byName(*l))) + case sortByNameDirFirst: + sort.Sort(sort.Reverse(byNameDirFirst(*l))) + case sortBySize: + sort.Sort(sort.Reverse(bySize(*l))) + case sortByTime: + sort.Sort(sort.Reverse(byTime(*l))) + } + } else { + switch l.Sort { + case sortByName: + sort.Sort(byName(*l)) + case sortByNameDirFirst: + sort.Sort(byNameDirFirst(*l)) + case sortBySize: + sort.Sort(bySize(*l)) + case sortByTime: + sort.Sort(byTime(*l)) + } + } + + if limitParam != "" { + limit, _ := strconv.Atoi(limitParam) + if limit > 0 && limit <= len(l.Items) { + l.Items = l.Items[:limit] + l.ItemsLimitedTo = limit + } + } +} + +// crumb represents part of a breadcrumb menu, +// pairing a link with the text to display. +type crumb struct { + Link, Text string +} + +// fileInfo contains serializable information +// about a file or directory. +type fileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + URL string `json:"url"` + ModTime time.Time `json:"mod_time"` + Mode os.FileMode `json:"mode"` + IsDir bool `json:"is_dir"` + IsSymlink bool `json:"is_symlink"` +} + +// HumanSize returns the size of the file as a +// human-readable string in IEC format (i.e. +// power of 2 or base 1024). +func (fi fileInfo) HumanSize() string { + return humanize.IBytes(uint64(fi.Size)) +} + +// HumanModTime returns the modified time of the file +// as a human-readable string given by format. +func (fi fileInfo) HumanModTime(format string) string { + return fi.ModTime.Format(format) +} + +type byName browseListing +type byNameDirFirst browseListing +type bySize browseListing +type byTime browseListing + +func (l byName) Len() int { return len(l.Items) } +func (l byName) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] } + +func (l byName) Less(i, j int) bool { + return strings.ToLower(l.Items[i].Name) < strings.ToLower(l.Items[j].Name) +} + +func (l byNameDirFirst) Len() int { return len(l.Items) } +func (l byNameDirFirst) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] } + +func (l byNameDirFirst) Less(i, j int) bool { + // sort by name if both are dir or file + if l.Items[i].IsDir == l.Items[j].IsDir { + return strings.ToLower(l.Items[i].Name) < strings.ToLower(l.Items[j].Name) + } + // sort dir ahead of file + return l.Items[i].IsDir +} + +func (l bySize) Len() int { return len(l.Items) } +func (l bySize) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] } + +func (l bySize) Less(i, j int) bool { + const directoryOffset = -1 << 31 // = -math.MinInt32 + + iSize, jSize := l.Items[i].Size, l.Items[j].Size + + // directory sizes depend on the file system; to + // provide a consistent experience, put them up front + // and sort them by name + if l.Items[i].IsDir { + iSize = directoryOffset + } + if l.Items[j].IsDir { + jSize = directoryOffset + } + if l.Items[i].IsDir && l.Items[j].IsDir { + return strings.ToLower(l.Items[i].Name) < strings.ToLower(l.Items[j].Name) + } + + return iSize < jSize +} + +func (l byTime) Len() int { return len(l.Items) } +func (l byTime) Swap(i, j int) { l.Items[i], l.Items[j] = l.Items[j], l.Items[i] } +func (l byTime) Less(i, j int) bool { return l.Items[i].ModTime.Before(l.Items[j].ModTime) } + +const ( + sortByName = "name" + sortByNameDirFirst = "name_dir_first" + sortBySize = "size" + sortByTime = "time" +) diff --git a/modules/caddyhttp/fileserver/browsetpl.go b/modules/caddyhttp/fileserver/browsetpl.go new file mode 100644 index 0000000..aebf146 --- /dev/null +++ b/modules/caddyhttp/fileserver/browsetpl.go @@ -0,0 +1,403 @@ +package fileserver + +const defaultBrowseTemplate = ` + + + {{html .Name}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ {{range $i, $crumb := .Breadcrumbs}}{{html $crumb.Text}}{{if ne $i 0}}/{{end}}{{end}} +

+
+
+
+
+ {{.NumDirs}} director{{if eq 1 .NumDirs}}y{{else}}ies{{end}} + {{.NumFiles}} file{{if ne 1 .NumFiles}}s{{end}} + {{- if ne 0 .ItemsLimitedTo}} + (of which only {{.ItemsLimitedTo}} are displayed) + {{- end}} + +
+
+
+ + + + + + + + + + + + {{- if .CanGoUp}} + + + + + + + + {{- end}} + {{- range .Items}} + + + + {{- if .IsDir}} + + {{- else}} + + {{- end}} + + + + {{- end}} + +
+ {{- if and (eq .Sort "namedirfirst") (ne .Order "desc")}} + + {{- else if and (eq .Sort "namedirfirst") (ne .Order "asc")}} + + {{- else}} + + {{- end}} + + {{- if and (eq .Sort "name") (ne .Order "desc")}} + Name + {{- else if and (eq .Sort "name") (ne .Order "asc")}} + Name + {{- else}} + Name + {{- end}} + + {{- if and (eq .Sort "size") (ne .Order "desc")}} + Size + {{- else if and (eq .Sort "size") (ne .Order "asc")}} + Size + {{- else}} + Size + {{- end}} + + {{- if and (eq .Sort "time") (ne .Order "desc")}} + Modified + {{- else if and (eq .Sort "time") (ne .Order "asc")}} + Modified + {{- else}} + Modified + {{- end}} +
+ + Go up + +
+ + {{- if .IsDir}} + + {{- else}} + + {{- end}} + {{html .Name}} + + {{.HumanSize}}
+
+
+ + + +` diff --git a/modules/caddyhttp/fileserver/matcher.go b/modules/caddyhttp/fileserver/matcher.go new file mode 100644 index 0000000..fd994d0 --- /dev/null +++ b/modules/caddyhttp/fileserver/matcher.go @@ -0,0 +1,57 @@ +package fileserver + +import ( + "net/http" + "os" + "path/filepath" + + "bitbucket.org/lightcodelabs/caddy2" + "bitbucket.org/lightcodelabs/caddy2/modules/caddyhttp" +) + +func init() { + caddy2.RegisterModule(caddy2.Module{ + Name: "http.matchers.file", + New: func() (interface{}, error) { return new(FileMatcher), nil }, + }) +} + +// FileMatcher is a matcher that can match requests +// based on the local file system. +// TODO: Not sure how to do this well; we'd need the ability to +// hide files, etc... +// TODO: Also consider a feature to match directory that +// contains a certain filename (use filepath.Glob), useful +// if wanting to map directory-URI requests where the dir +// has index.php to PHP backends, for example (although this +// can effectively be done with rehandling already) +type FileMatcher struct { + Root string `json:"root"` + Path string `json:"path"` + Flags []string `json:"flags"` +} + +// Match matches the request r against m. +func (m FileMatcher) Match(r *http.Request) bool { + // TODO: sanitize path + fullPath := filepath.Join(m.Root, m.Path) + var match bool + if len(m.Flags) > 0 { + match = true + fi, err := os.Stat(fullPath) + for _, f := range m.Flags { + switch f { + case "EXIST": + match = match && os.IsNotExist(err) + case "DIR": + match = match && err == nil && fi.IsDir() + default: + match = false + } + } + } + return match +} + +// Interface guard +var _ caddyhttp.RequestMatcher = (*FileMatcher)(nil) diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go new file mode 100644 index 0000000..e859abe --- /dev/null +++ b/modules/caddyhttp/fileserver/staticfiles.go @@ -0,0 +1,416 @@ +package fileserver + +import ( + "fmt" + "html/template" + weakrand "math/rand" + "net/http" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "bitbucket.org/lightcodelabs/caddy2" + "bitbucket.org/lightcodelabs/caddy2/modules/caddyhttp" +) + +func init() { + weakrand.Seed(time.Now().UnixNano()) + + caddy2.RegisterModule(caddy2.Module{ + Name: "http.responders.file_server", + New: func() (interface{}, error) { return new(FileServer), nil }, + }) +} + +// FileServer implements a static file server responder for Caddy. +type FileServer struct { + Root string `json:"root"` // default is current directory + Hide []string `json:"hide"` + IndexNames []string `json:"index_names"` + Files []string `json:"files"` // all relative to the root; default is request URI path + Rehandle bool `json:"rehandle"` // issue a rehandle (internal redirect) if request is rewritten + SelectionPolicy string `json:"selection_policy"` + Fallback caddyhttp.RouteList `json:"fallback"` + Browse *Browse `json:"browse"` + // TODO: Etag + // TODO: Content negotiation +} + +// Provision sets up the static files responder. +func (fsrv *FileServer) Provision(ctx caddy2.Context) error { + if fsrv.Fallback != nil { + err := fsrv.Fallback.Provision(ctx) + if err != nil { + return fmt.Errorf("setting up fallback routes: %v", err) + } + } + + if fsrv.IndexNames == nil { + fsrv.IndexNames = defaultIndexNames + } + + if fsrv.Browse != nil { + var tpl *template.Template + var err error + if fsrv.Browse.TemplateFile != "" { + tpl, err = template.ParseFiles(fsrv.Browse.TemplateFile) + if err != nil { + return fmt.Errorf("parsing browse template file: %v", err) + } + } else { + tpl, err = template.New("default_listing").Parse(defaultBrowseTemplate) + if err != nil { + return fmt.Errorf("parsing default browse template: %v", err) + } + } + fsrv.Browse.template = tpl + } + + return nil +} + +const ( + selectionPolicyFirstExisting = "first_existing" + selectionPolicyLargestSize = "largest_size" + selectionPolicySmallestSize = "smallest_size" + selectionPolicyRecentlyMod = "most_recently_modified" +) + +// Validate ensures that sf has a valid configuration. +func (fsrv *FileServer) Validate() error { + switch fsrv.SelectionPolicy { + case "", + selectionPolicyFirstExisting, + selectionPolicyLargestSize, + selectionPolicySmallestSize, + selectionPolicyRecentlyMod: + default: + return fmt.Errorf("unknown selection policy %s", fsrv.SelectionPolicy) + } + return nil +} + +func (fsrv *FileServer) ServeHTTP(w http.ResponseWriter, r *http.Request) error { + repl := r.Context().Value(caddy2.ReplacerCtxKey).(caddy2.Replacer) + + filesToHide := fsrv.transformHidePaths(repl) + + // map the request to a filename + pathBefore := r.URL.Path + filename := fsrv.selectFile(r, repl, filesToHide) + if filename == "" { + // no files worked, so resort to fallback + if fsrv.Fallback != nil { + fallback := fsrv.Fallback.BuildCompositeRoute(w, r) + return fallback.ServeHTTP(w, r) + } + return caddyhttp.Error(http.StatusNotFound, nil) + } + + // if the ultimate destination has changed, submit + // this request for a rehandling (internal redirect) + // if configured to do so + if r.URL.Path != pathBefore && fsrv.Rehandle { + return caddyhttp.ErrRehandle + } + + // get information about the file + info, err := os.Stat(filename) + if err != nil { + err = mapDirOpenError(err, filename) + if os.IsNotExist(err) { + return caddyhttp.Error(http.StatusNotFound, err) + } else if os.IsPermission(err) { + return caddyhttp.Error(http.StatusForbidden, err) + } + // TODO: treat this as resource exhaustion like with os.Open? Or unnecessary here? + return caddyhttp.Error(http.StatusInternalServerError, err) + } + + // if the request mapped to a directory, see if + // there is an index file we can serve + if info.IsDir() && len(fsrv.IndexNames) > 0 { + for _, indexPage := range fsrv.IndexNames { + indexPath := sanitizedPathJoin(filename, indexPage) + if fileHidden(indexPath, filesToHide) { + // pretend this file doesn't exist + continue + } + + indexInfo, err := os.Stat(indexPath) + if err != nil { + continue + } + + // we found an index file that might work, + // so rewrite the request path and, if + // configured, do an internal redirect + r.URL.Path = path.Join(r.URL.Path, indexPage) + if fsrv.Rehandle { + return caddyhttp.ErrRehandle + } + + info = indexInfo + filename = indexPath + break + } + } + + // if still referencing a directory, delegate + // to browse or return an error + if info.IsDir() { + if fsrv.Browse != nil && !fileHidden(filename, filesToHide) { + return fsrv.serveBrowse(filename, w, r) + } + return caddyhttp.Error(http.StatusNotFound, nil) + } + + // TODO: content negotiation (brotli sidecar files, etc...) + + // one last check to ensure the file isn't hidden (we might + // have changed the filename from when we last checked) + if fileHidden(filename, filesToHide) { + return caddyhttp.Error(http.StatusNotFound, nil) + } + + // open the file + file, err := fsrv.openFile(filename, w) + if err != nil { + return err + } + defer file.Close() + + // TODO: Etag + + // TODO: Disable content-type sniffing by setting a content-type... + + // let the standard library do what it does best; note, however, + // that errors generated by ServeContent are written immediately + // to the response, so we cannot handle them (but errors here + // are rare) + http.ServeContent(w, r, info.Name(), info.ModTime(), file) + + return nil +} + +// openFile opens the file at the given filename. If there was an error, +// the response is configured to inform the client how to best handle it +// and a well-described handler error is returned (do not wrap the +// returned error value). +func (fsrv *FileServer) openFile(filename string, w http.ResponseWriter) (*os.File, error) { + file, err := os.Open(filename) + if err != nil { + err = mapDirOpenError(err, filename) + if os.IsNotExist(err) { + return nil, caddyhttp.Error(http.StatusNotFound, err) + } else if os.IsPermission(err) { + return nil, caddyhttp.Error(http.StatusForbidden, err) + } + // maybe the server is under load and ran out of file descriptors? + // have client wait arbitrary seconds to help prevent a stampede + backoff := weakrand.Intn(maxBackoff-minBackoff) + minBackoff + w.Header().Set("Retry-After", strconv.Itoa(backoff)) + return nil, caddyhttp.Error(http.StatusServiceUnavailable, err) + } + return file, nil +} + +// mapDirOpenError maps the provided non-nil error from opening name +// to a possibly better non-nil error. In particular, it turns OS-specific errors +// about opening files in non-directories into os.ErrNotExist. See golang/go#18984. +// Adapted from the Go standard library; originally written by Nathaniel Caza. +// https://go-review.googlesource.com/c/go/+/36635/ +// https://go-review.googlesource.com/c/go/+/36804/ +func mapDirOpenError(originalErr error, name string) error { + if os.IsNotExist(originalErr) || os.IsPermission(originalErr) { + return originalErr + } + + parts := strings.Split(name, string(filepath.Separator)) + for i := range parts { + if parts[i] == "" { + continue + } + fi, err := os.Stat(strings.Join(parts[:i+1], string(filepath.Separator))) + if err != nil { + return originalErr + } + if !fi.IsDir() { + return os.ErrNotExist + } + } + + return originalErr +} + +// transformHidePaths performs replacements for all the elements of +// fsrv.Hide and returns a new list of the transformed values. +func (fsrv *FileServer) transformHidePaths(repl caddy2.Replacer) []string { + hide := make([]string, len(fsrv.Hide)) + for i := range fsrv.Hide { + hide[i] = repl.ReplaceAll(fsrv.Hide[i], "") + } + return hide +} + +// sanitizedPathJoin performs filepath.Join(root, reqPath) that +// is safe against directory traversal attacks. It uses logic +// similar to that in the Go standard library, specifically +// in the implementation of http.Dir. The root is assumed to +// be a trusted path, but reqPath is not. +func sanitizedPathJoin(root, reqPath string) string { + // TODO: Caddy 1 uses this: + // prevent absolute path access on Windows, e.g. http://localhost:5000/C:\Windows\notepad.exe + // if runtime.GOOS == "windows" && len(reqPath) > 0 && filepath.IsAbs(reqPath[1:]) { + // TODO. + // } + + // TODO: whereas std lib's http.Dir.Open() uses this: + // if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) { + // return nil, errors.New("http: invalid character in file path") + // } + + // TODO: see https://play.golang.org/p/oh77BiVQFti for another thing to consider + + if root == "" { + root = "." + } + return filepath.Join(root, filepath.FromSlash(path.Clean("/"+reqPath))) +} + +// selectFile uses the specified selection policy (or first_existing +// by default) to map the request r to a filename. The full path to +// the file is returned if one is found; otherwise, an empty string +// is returned. +func (fsrv *FileServer) selectFile(r *http.Request, repl caddy2.Replacer, filesToHide []string) string { + root := repl.ReplaceAll(fsrv.Root, "") + + if fsrv.Files == nil { + return sanitizedPathJoin(root, r.URL.Path) + } + + switch fsrv.SelectionPolicy { + case "", selectionPolicyFirstExisting: + filesToHide := fsrv.transformHidePaths(repl) + for _, f := range fsrv.Files { + suffix := repl.ReplaceAll(f, "") + fullpath := sanitizedPathJoin(root, suffix) + if !fileHidden(fullpath, filesToHide) && fileExists(fullpath) { + r.URL.Path = suffix + return fullpath + } + } + + case selectionPolicyLargestSize: + var largestSize int64 + var largestFilename string + var largestSuffix string + for _, f := range fsrv.Files { + suffix := repl.ReplaceAll(f, "") + fullpath := sanitizedPathJoin(root, suffix) + if fileHidden(fullpath, filesToHide) { + continue + } + info, err := os.Stat(fullpath) + if err == nil && info.Size() > largestSize { + largestSize = info.Size() + largestFilename = fullpath + largestSuffix = suffix + } + } + r.URL.Path = largestSuffix + return largestFilename + + case selectionPolicySmallestSize: + var smallestSize int64 + var smallestFilename string + var smallestSuffix string + for _, f := range fsrv.Files { + suffix := repl.ReplaceAll(f, "") + fullpath := sanitizedPathJoin(root, suffix) + if fileHidden(fullpath, filesToHide) { + continue + } + info, err := os.Stat(fullpath) + if err == nil && (smallestSize == 0 || info.Size() < smallestSize) { + smallestSize = info.Size() + smallestFilename = fullpath + smallestSuffix = suffix + } + } + r.URL.Path = smallestSuffix + return smallestFilename + + case selectionPolicyRecentlyMod: + var recentDate time.Time + var recentFilename string + var recentSuffix string + for _, f := range fsrv.Files { + suffix := repl.ReplaceAll(f, "") + fullpath := sanitizedPathJoin(root, suffix) + if fileHidden(fullpath, filesToHide) { + continue + } + info, err := os.Stat(fullpath) + if err == nil && + (recentDate.IsZero() || info.ModTime().After(recentDate)) { + recentDate = info.ModTime() + recentFilename = fullpath + recentSuffix = suffix + } + } + r.URL.Path = recentSuffix + return recentFilename + } + + return "" +} + +// fileExists returns true if file exists. +func fileExists(file string) bool { + _, err := os.Stat(file) + return !os.IsNotExist(err) +} + +// fileHidden returns true if filename is hidden +// according to the hide list. +func fileHidden(filename string, hide []string) bool { + nameOnly := filepath.Base(filename) + sep := string(filepath.Separator) + + // see if file is hidden + for _, h := range hide { + // assuming h is a glob/shell-like pattern, + // use it to compare the whole file path; + // but if there is no separator in h, then + // just compare against the file's name + compare := filename + if !strings.Contains(h, sep) { + compare = nameOnly + } + + hidden, err := filepath.Match(h, compare) + if err != nil { + // malformed pattern; fallback by checking prefix + if strings.HasPrefix(filename, h) { + return true + } + } + if hidden { + // file name or path matches hide pattern + return true + } + } + + return false +} + +var defaultIndexNames = []string{"index.html"} + +const minBackoff, maxBackoff = 2, 5 + +// Interface guard +var _ caddyhttp.Handler = (*FileServer)(nil) diff --git a/modules/caddyhttp/fileserver/staticfiles_test.go b/modules/caddyhttp/fileserver/staticfiles_test.go new file mode 100644 index 0000000..2a99c71 --- /dev/null +++ b/modules/caddyhttp/fileserver/staticfiles_test.go @@ -0,0 +1,81 @@ +package fileserver + +import ( + "net/url" + "testing" +) + +func TestSanitizedPathJoin(t *testing.T) { + // For easy reference: + // %2E = . + // %2F = / + // %5C = \ + for i, tc := range []struct { + inputRoot string + inputPath string + expect string + }{ + { + inputPath: "", + expect: ".", + }, + { + inputPath: "/", + expect: ".", + }, + { + inputPath: "/foo", + expect: "foo", + }, + { + inputPath: "/foo/bar", + expect: "foo/bar", + }, + { + inputRoot: "/a", + inputPath: "/foo/bar", + expect: "/a/foo/bar", + }, + { + inputPath: "/foo/../bar", + expect: "bar", + }, + { + inputRoot: "/a/b", + inputPath: "/foo/../bar", + expect: "/a/b/bar", + }, + { + inputRoot: "/a/b", + inputPath: "/..%2fbar", + expect: "/a/b/bar", + }, + { + inputRoot: "/a/b", + inputPath: "/%2e%2e%2fbar", + expect: "/a/b/bar", + }, + { + inputRoot: "/a/b", + inputPath: "/%2e%2e%2f%2e%2e%2f", + expect: "/a/b", + }, + // TODO: test windows paths... on windows... sigh. + } { + // we don't *need* to use an actual parsed URL, but it + // adds some authenticity to the tests since real-world + // values will be coming in from URLs; thus, the test + // corpus can contain paths as encoded by clients, which + // more closely emulates the actual attack vector + u, err := url.Parse("http://test:9999" + tc.inputPath) + if err != nil { + t.Fatalf("Test %d: invalid URL: %v", i, err) + } + actual := sanitizedPathJoin(tc.inputRoot, u.Path) + if actual != tc.expect { + t.Errorf("Test %d: [%s %s] => %s (expected %s)", i, tc.inputRoot, tc.inputPath, actual, tc.expect) + } + } +} + +// TODO: test fileHidden -- cgit v1.2.3