From 0ee4378227cf3019dcbaddeb3977ddd28e58429a Mon Sep 17 00:00:00 2001
From: Matthew Holt <mholt@users.noreply.github.com>
Date: Fri, 11 Sep 2020 12:20:39 -0600
Subject: [PATCH] fileserver: Improve file hiding logic for directories and
 prefixes

Now, a filename to hide that is specified without a path separator will
count as hidden if it appears in any component of the file path (not
only the last component); semantically, this means hiding a file by only
its name (without any part of a path) will hide both files and folders,
e.g. hiding ".git" will hide "/.git" and also "/.git/foo".

We also do prefix matching so that hiding "/.git" will hide "/.git"
and "/.git/foo" but not "/.gitignore".

The remaining logic is a globular match like before.
---
 modules/caddyhttp/fileserver/staticfiles.go   | 39 ++++++----
 .../caddyhttp/fileserver/staticfiles_test.go  | 73 ++++++++++++++++++-
 2 files changed, 95 insertions(+), 17 deletions(-)

diff --git a/modules/caddyhttp/fileserver/staticfiles.go b/modules/caddyhttp/fileserver/staticfiles.go
index d6cf4d656..735352b5c 100644
--- a/modules/caddyhttp/fileserver/staticfiles.go
+++ b/modules/caddyhttp/fileserver/staticfiles.go
@@ -329,28 +329,37 @@ func sanitizedPathJoin(root, reqPath string) string {
 // 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)
+	var components []string
 
 	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) {
+			// if there is no separator in h, then we assume the user
+			// wants to hide any files or folders that match that
+			// name; thus we have to compare against each component
+			// of the filename, e.g. hiding "bar" would hide "/bar"
+			// as well as "/foo/bar/baz" but not "/barstool".
+			if len(components) == 0 {
+				components = strings.Split(filename, sep)
+			}
+			for _, c := range components {
+				if c == h {
+					return true
+				}
+			}
+		} else if strings.HasPrefix(filename, h) {
+			// otherwise, if there is a separator in h, and
+			// filename is exactly prefixed with h, then we
+			// can do a prefix match so that "/foo" matches
+			// "/foo/bar" but not "/foobar".
+			withoutPrefix := strings.TrimPrefix(filename, h)
+			if strings.HasPrefix(withoutPrefix, sep) {
 				return true
 			}
 		}
-		if hidden {
-			// file name or path matches hide pattern
+
+		// in the general case, a glob match will suffice
+		if hidden, _ := filepath.Match(h, filename); hidden {
 			return true
 		}
 	}
diff --git a/modules/caddyhttp/fileserver/staticfiles_test.go b/modules/caddyhttp/fileserver/staticfiles_test.go
index 73762c776..f074318f6 100644
--- a/modules/caddyhttp/fileserver/staticfiles_test.go
+++ b/modules/caddyhttp/fileserver/staticfiles_test.go
@@ -93,9 +93,78 @@ func TestSanitizedPathJoin(t *testing.T) {
 		}
 		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)
+			t.Errorf("Test %d: [%s %s] => %s (expected %s)",
+				i, tc.inputRoot, tc.inputPath, actual, tc.expect)
 		}
 	}
 }
 
-// TODO: test fileHidden
+func TestFileHidden(t *testing.T) {
+	for i, tc := range []struct {
+		inputHide []string
+		inputPath string
+		expect    bool
+	}{
+		{
+			inputHide: nil,
+			inputPath: "",
+			expect:    false,
+		},
+		{
+			inputHide: []string{".gitignore"},
+			inputPath: "/.gitignore",
+			expect:    true,
+		},
+		{
+			inputHide: []string{".git"},
+			inputPath: "/.gitignore",
+			expect:    false,
+		},
+		{
+			inputHide: []string{"/.git"},
+			inputPath: "/.gitignore",
+			expect:    false,
+		},
+		{
+			inputHide: []string{".git"},
+			inputPath: "/.git",
+			expect:    true,
+		},
+		{
+			inputHide: []string{".git"},
+			inputPath: "/.git/foo",
+			expect:    true,
+		},
+		{
+			inputHide: []string{".git"},
+			inputPath: "/foo/.git/bar",
+			expect:    true,
+		},
+		{
+			inputHide: []string{"/prefix"},
+			inputPath: "/prefix/foo",
+			expect:    true,
+		},
+		{
+			inputHide: []string{"/foo/*/bar"},
+			inputPath: "/foo/asdf/bar",
+			expect:    true,
+		},
+		{
+			inputHide: []string{"/foo"},
+			inputPath: "/foo",
+			expect:    true,
+		},
+		{
+			inputHide: []string{"/foo"},
+			inputPath: "/foobar",
+			expect:    false,
+		},
+	} {
+		actual := fileHidden(tc.inputPath, tc.inputHide)
+		if actual != tc.expect {
+			t.Errorf("Test %d: Is %s hidden in %v? Got %t but expected %t",
+				i, tc.inputPath, tc.inputHide, actual, tc.expect)
+		}
+	}
+}