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 +++++ modules/caddyhttp/server.go | 23 +- modules/caddyhttp/staticfiles/browse.go | 157 -------- modules/caddyhttp/staticfiles/browselisting.go | 245 ------------- modules/caddyhttp/staticfiles/browsetpl.go | 403 --------------------- modules/caddyhttp/staticfiles/matcher.go | 57 --- modules/caddyhttp/staticfiles/staticfiles.go | 398 --------------------- modules/caddyhttp/staticfiles/staticfiles_test.go | 78 ---- modules/caddyhttp/staticresp.go | 16 +- 14 files changed, 1389 insertions(+), 1347 deletions(-) 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 delete mode 100644 modules/caddyhttp/staticfiles/browse.go delete mode 100644 modules/caddyhttp/staticfiles/browselisting.go delete mode 100644 modules/caddyhttp/staticfiles/browsetpl.go delete mode 100644 modules/caddyhttp/staticfiles/matcher.go delete mode 100644 modules/caddyhttp/staticfiles/staticfiles.go delete mode 100644 modules/caddyhttp/staticfiles/staticfiles_test.go (limited to 'modules') 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 diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go index 5ab7693..fbbdae4 100644 --- a/modules/caddyhttp/server.go +++ b/modules/caddyhttp/server.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/http" + "strconv" "bitbucket.org/lightcodelabs/caddy2" "bitbucket.org/lightcodelabs/caddy2/modules/caddytls" @@ -41,15 +42,27 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { stack := s.Routes.BuildCompositeRoute(w, r) err := s.executeCompositeRoute(w, r, stack) if err != nil { - // add the error value to the request context so - // it can be accessed by error handlers + // add the raw error value to the request context + // so it can be accessed by error handlers c := context.WithValue(r.Context(), ErrorCtxKey, err) r = r.WithContext(c) - // TODO: add error values to Replacer + + // add error values to the replacer + repl.Set("http.error", err.Error()) + if handlerErr, ok := err.(HandlerError); ok { + repl.Set("http.error.status_code", strconv.Itoa(handlerErr.StatusCode)) + repl.Set("http.error.status_text", http.StatusText(handlerErr.StatusCode)) + repl.Set("http.error.message", handlerErr.Message) + repl.Set("http.error.trace", handlerErr.Trace) + repl.Set("http.error.id", handlerErr.ID) + } if len(s.Errors.Routes) == 0 { - // TODO: implement a default error handler? - log.Printf("[ERROR] %s", err) + // TODO: polish the default error handling + log.Printf("[ERROR] Handler: %s", err) + if handlerErr, ok := err.(HandlerError); ok { + w.WriteHeader(handlerErr.StatusCode) + } } else { errStack := s.Errors.Routes.BuildCompositeRoute(w, r) err := s.executeCompositeRoute(w, r, errStack) diff --git a/modules/caddyhttp/staticfiles/browse.go b/modules/caddyhttp/staticfiles/browse.go deleted file mode 100644 index 2bb130f..0000000 --- a/modules/caddyhttp/staticfiles/browse.go +++ /dev/null @@ -1,157 +0,0 @@ -package staticfiles - -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 (sf *StaticFiles) serveBrowse(dirPath string, w http.ResponseWriter, r *http.Request) error { - dir, err := sf.openFile(dirPath, w) - if err != nil { - return err - } - defer dir.Close() - - repl := r.Context().Value(caddy2.ReplacerCtxKey).(caddy2.Replacer) - - listing, err := sf.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) - } - - sf.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 = sf.browseWriteJSON(listing); err != nil { - return caddyhttp.Error(http.StatusInternalServerError, err) - } - w.Header().Set("Content-Type", "application/json; charset=utf-8") - } else { - if buf, err = sf.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 (sf *StaticFiles) 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, sf.Root) - - return sf.directoryListing(files, canGoUp, urlPath, repl), nil -} - -// browseApplyQueryParams applies query parameters to the listing. -// It mutates the listing and may set cookies. -func (sf *StaticFiles) 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 (sf *StaticFiles) browseWriteJSON(listing browseListing) (*bytes.Buffer, error) { - buf := new(bytes.Buffer) - err := json.NewEncoder(buf).Encode(listing.Items) - return buf, err -} - -func (sf *StaticFiles) browseWriteHTML(listing browseListing) (*bytes.Buffer, error) { - buf := new(bytes.Buffer) - err := sf.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/staticfiles/browselisting.go b/modules/caddyhttp/staticfiles/browselisting.go deleted file mode 100644 index 11e6b9c..0000000 --- a/modules/caddyhttp/staticfiles/browselisting.go +++ /dev/null @@ -1,245 +0,0 @@ -package staticfiles - -import ( - "net/url" - "os" - "path" - "sort" - "strconv" - "strings" - "time" - - "bitbucket.org/lightcodelabs/caddy2" - "github.com/dustin/go-humanize" -) - -func (sf *StaticFiles) directoryListing(files []os.FileInfo, canGoUp bool, urlPath string, repl caddy2.Replacer) browseListing { - filesToHide := sf.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/staticfiles/browsetpl.go b/modules/caddyhttp/staticfiles/browsetpl.go deleted file mode 100644 index ff2a1e1..0000000 --- a/modules/caddyhttp/staticfiles/browsetpl.go +++ /dev/null @@ -1,403 +0,0 @@ -package staticfiles - -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/staticfiles/matcher.go b/modules/caddyhttp/staticfiles/matcher.go deleted file mode 100644 index 9ce3f4c..0000000 --- a/modules/caddyhttp/staticfiles/matcher.go +++ /dev/null @@ -1,57 +0,0 @@ -package staticfiles - -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/staticfiles/staticfiles.go b/modules/caddyhttp/staticfiles/staticfiles.go deleted file mode 100644 index f9fd8d2..0000000 --- a/modules/caddyhttp/staticfiles/staticfiles.go +++ /dev/null @@ -1,398 +0,0 @@ -package staticfiles - -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.static_files", - New: func() (interface{}, error) { return new(StaticFiles), nil }, - }) -} - -// StaticFiles implements a static file server responder for Caddy. -type StaticFiles 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 (sf *StaticFiles) Provision(ctx caddy2.Context) error { - if sf.Fallback != nil { - err := sf.Fallback.Provision(ctx) - if err != nil { - return fmt.Errorf("setting up fallback routes: %v", err) - } - } - - if sf.IndexNames == nil { - sf.IndexNames = defaultIndexNames - } - - if sf.Browse != nil { - var tpl *template.Template - var err error - if sf.Browse.TemplateFile != "" { - tpl, err = template.ParseFiles(sf.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) - } - } - sf.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 (sf *StaticFiles) Validate() error { - switch sf.SelectionPolicy { - case "", - selectionPolicyFirstExisting, - selectionPolicyLargestSize, - selectionPolicySmallestSize, - selectionPolicyRecentlyMod: - default: - return fmt.Errorf("unknown selection policy %s", sf.SelectionPolicy) - } - return nil -} - -func (sf *StaticFiles) ServeHTTP(w http.ResponseWriter, r *http.Request) error { - repl := r.Context().Value(caddy2.ReplacerCtxKey).(caddy2.Replacer) - - // map the request to a filename - pathBefore := r.URL.Path - filename := sf.selectFile(r, repl) - if filename == "" { - // no files worked, so resort to fallback - if sf.Fallback != nil { - fallback := sf.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 && sf.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(sf.IndexNames) > 0 { - filesToHide := sf.transformHidePaths(repl) - - for _, indexPage := range sf.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 sf.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 sf.Browse != nil { - return sf.serveBrowse(filename, w, r) - } - return caddyhttp.Error(http.StatusNotFound, nil) - } - - // TODO: content negotiation (brotli sidecar files, etc...) - - // open the file - file, err := sf.openFile(filename, w) - if err != nil { - return err - } - defer file.Close() - - // TODO: Etag - - // 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 (sf *StaticFiles) 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 -// sf.Hide and returns a new list of the transformed values. -func (sf *StaticFiles) transformHidePaths(repl caddy2.Replacer) []string { - hide := make([]string, len(sf.Hide)) - for i := range sf.Hide { - hide[i] = repl.ReplaceAll(sf.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. -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 (sf *StaticFiles) selectFile(r *http.Request, repl caddy2.Replacer) string { - root := repl.ReplaceAll(sf.Root, "") - - if sf.Files == nil { - return sanitizedPathJoin(root, r.URL.Path) - } - - switch sf.SelectionPolicy { - case "", selectionPolicyFirstExisting: - filesToHide := sf.transformHidePaths(repl) - for _, f := range sf.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 sf.Files { - suffix := repl.ReplaceAll(f, "") - fullpath := sanitizedPathJoin(root, suffix) - 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 sf.Files { - suffix := repl.ReplaceAll(f, "") - fullpath := sanitizedPathJoin(root, suffix) - 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 sf.Files { - suffix := repl.ReplaceAll(f, "") - fullpath := sanitizedPathJoin(root, suffix) - 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 = (*StaticFiles)(nil) diff --git a/modules/caddyhttp/staticfiles/staticfiles_test.go b/modules/caddyhttp/staticfiles/staticfiles_test.go deleted file mode 100644 index f2e1c89..0000000 --- a/modules/caddyhttp/staticfiles/staticfiles_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package staticfiles - -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", - }, - } { - // 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) - } - } -} diff --git a/modules/caddyhttp/staticresp.go b/modules/caddyhttp/staticresp.go index 69ec45b..e07084a 100644 --- a/modules/caddyhttp/staticresp.go +++ b/modules/caddyhttp/staticresp.go @@ -3,6 +3,7 @@ package caddyhttp import ( "fmt" "net/http" + "strconv" "bitbucket.org/lightcodelabs/caddy2" ) @@ -16,10 +17,11 @@ func init() { // Static implements a simple responder for static responses. type Static struct { - StatusCode int `json:"status_code"` - Headers http.Header `json:"headers"` - Body string `json:"body"` - Close bool `json:"close"` + StatusCode int `json:"status_code"` + StatusCodeStr string `json:"status_code_str"` + Headers http.Header `json:"headers"` + Body string `json:"body"` + Close bool `json:"close"` } func (s Static) ServeHTTP(w http.ResponseWriter, r *http.Request) error { @@ -39,6 +41,12 @@ func (s Static) ServeHTTP(w http.ResponseWriter, r *http.Request) error { // write the headers with a status code statusCode := s.StatusCode + if statusCode == 0 && s.StatusCodeStr != "" { + intVal, err := strconv.Atoi(repl.ReplaceAll(s.StatusCodeStr, "")) + if err == nil { + statusCode = intVal + } + } if statusCode == 0 { statusCode = http.StatusOK } -- cgit v1.2.3