diff options
-rw-r--r-- | internal/serving/disk/helpers.go | 16 | ||||
-rw-r--r-- | internal/serving/disk/helpers_test.go | 32 | ||||
-rw-r--r-- | internal/serving/disk/reader.go | 45 | ||||
-rw-r--r-- | internal/serving/disk/serving.go | 3 | ||||
-rw-r--r-- | internal/serving/disk/symlink/path_test.go | 19 | ||||
-rw-r--r-- | internal/serving/disk/symlink/shims.go | 20 | ||||
-rw-r--r-- | internal/serving/disk/symlink/symlink.go | 11 | ||||
-rw-r--r-- | internal/vfs/local/local_test.go | 96 | ||||
-rw-r--r-- | internal/vfs/local/testdata/file | 1 | ||||
l--------- | internal/vfs/local/testdata/link | 1 | ||||
-rw-r--r-- | internal/vfs/local/vfs.go | 19 | ||||
-rw-r--r-- | internal/vfs/vfs.go | 55 | ||||
-rw-r--r-- | metrics/metrics.go | 7 |
13 files changed, 243 insertions, 82 deletions
diff --git a/internal/serving/disk/helpers.go b/internal/serving/disk/helpers.go index 271b63cc..e6d3f8ab 100644 --- a/internal/serving/disk/helpers.go +++ b/internal/serving/disk/helpers.go @@ -1,16 +1,14 @@ package disk import ( + "context" "io" "mime" "net/http" - "os" "path/filepath" "strconv" "strings" - "golang.org/x/sys/unix" - "gitlab.com/gitlab-org/gitlab-pages/internal/httputil" ) @@ -22,20 +20,16 @@ func endsWithoutHTMLExtension(path string) bool { return !strings.HasSuffix(path, ".html") } -func openNoFollow(path string) (*os.File, error) { - return os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW, 0) -} - // Detect file's content-type either by extension or mime-sniffing. // Implementation is adapted from Golang's `http.serveContent()` // See https://github.com/golang/go/blob/902fc114272978a40d2e65c2510a18e870077559/src/net/http/fs.go#L194 -func detectContentType(path string) (string, error) { +func (reader *Reader) detectContentType(ctx context.Context, path string) (string, error) { contentType := mime.TypeByExtension(filepath.Ext(path)) if contentType == "" { var buf [512]byte - file, err := os.Open(path) + file, err := reader.vfs.Open(ctx, path) if err != nil { return "", err } @@ -61,7 +55,7 @@ func acceptsGZip(r *http.Request) bool { return acceptedEncoding == "gzip" } -func handleGZip(w http.ResponseWriter, r *http.Request, fullPath string) string { +func (reader *Reader) handleGZip(ctx context.Context, w http.ResponseWriter, r *http.Request, fullPath string) string { if !acceptsGZip(r) { return fullPath } @@ -69,7 +63,7 @@ func handleGZip(w http.ResponseWriter, r *http.Request, fullPath string) string gzipPath := fullPath + ".gz" // Ensure the .gz file is not a symlink - fi, err := os.Lstat(gzipPath) + fi, err := reader.vfs.Lstat(ctx, gzipPath) if err != nil || !fi.Mode().IsRegular() { return fullPath } diff --git a/internal/serving/disk/helpers_test.go b/internal/serving/disk/helpers_test.go deleted file mode 100644 index eea51500..00000000 --- a/internal/serving/disk/helpers_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package disk - -import ( - "io/ioutil" - "os" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestOpenNoFollow(t *testing.T) { - tmpfile, err := ioutil.TempFile("", "link-test") - require.NoError(t, err) - defer tmpfile.Close() - - orig := tmpfile.Name() - softLink := orig + ".link" - defer os.Remove(orig) - - source, err := openNoFollow(orig) - require.NoError(t, err) - require.NotNil(t, source) - defer source.Close() - - err = os.Symlink(orig, softLink) - require.NoError(t, err) - defer os.Remove(softLink) - - link, err := openNoFollow(softLink) - require.Error(t, err) - require.Nil(t, link) -} diff --git a/internal/serving/disk/reader.go b/internal/serving/disk/reader.go index b8dce542..8c7ee3bf 100644 --- a/internal/serving/disk/reader.go +++ b/internal/serving/disk/reader.go @@ -1,10 +1,10 @@ package disk import ( + "context" "fmt" "io" "net/http" - "os" "path/filepath" "strconv" "strings" @@ -13,15 +13,19 @@ import ( "github.com/prometheus/client_golang/prometheus" "gitlab.com/gitlab-org/gitlab-pages/internal/serving" + "gitlab.com/gitlab-org/gitlab-pages/internal/serving/disk/symlink" + "gitlab.com/gitlab-org/gitlab-pages/internal/vfs" ) // Reader is a disk access driver type Reader struct { fileSizeMetric prometheus.Histogram + vfs vfs.VFS } func (reader *Reader) tryFile(h serving.Handler) error { - fullPath, err := reader.resolvePath(h.LookupPath.Path, h.SubPath) + ctx := h.Request.Context() + fullPath, err := reader.resolvePath(ctx, h.LookupPath.Path, h.SubPath) request := h.Request host := request.Host @@ -29,7 +33,7 @@ func (reader *Reader) tryFile(h serving.Handler) error { if locationError, _ := err.(*locationDirectoryError); locationError != nil { if endsWithSlash(urlPath) { - fullPath, err = reader.resolvePath(h.LookupPath.Path, h.SubPath, "index.html") + fullPath, err = reader.resolvePath(ctx, h.LookupPath.Path, h.SubPath, "index.html") } else { // TODO why are we doing that? In tests it redirects to HTTPS. This seems wrong, // issue about this: https://gitlab.com/gitlab-org/gitlab-pages/issues/273 @@ -46,23 +50,24 @@ func (reader *Reader) tryFile(h serving.Handler) error { } if locationError, _ := err.(*locationFileNoExtensionError); locationError != nil { - fullPath, err = reader.resolvePath(h.LookupPath.Path, strings.TrimSuffix(h.SubPath, "/")+".html") + fullPath, err = reader.resolvePath(ctx, h.LookupPath.Path, strings.TrimSuffix(h.SubPath, "/")+".html") } if err != nil { return err } - return reader.serveFile(h.Writer, h.Request, fullPath, h.LookupPath.HasAccessControl) + return reader.serveFile(ctx, h.Writer, h.Request, fullPath, h.LookupPath.HasAccessControl) } func (reader *Reader) tryNotFound(h serving.Handler) error { - page404, err := reader.resolvePath(h.LookupPath.Path, "404.html") + ctx := h.Request.Context() + page404, err := reader.resolvePath(ctx, h.LookupPath.Path, "404.html") if err != nil { return err } - err = reader.serveCustomFile(h.Writer, h.Request, http.StatusNotFound, page404) + err = reader.serveCustomFile(ctx, h.Writer, h.Request, http.StatusNotFound, page404) if err != nil { return err } @@ -71,7 +76,7 @@ func (reader *Reader) tryNotFound(h serving.Handler) error { // Resolve the HTTP request to a path on disk, converting requests for // directories to requests for index.html inside the directory if appropriate. -func (reader *Reader) resolvePath(publicPath string, subPath ...string) (string, error) { +func (reader *Reader) resolvePath(ctx context.Context, publicPath string, subPath ...string) (string, error) { // Ensure that publicPath always ends with "/" publicPath = strings.TrimSuffix(publicPath, "/") + "/" @@ -79,7 +84,7 @@ func (reader *Reader) resolvePath(publicPath string, subPath ...string) (string, // where we want to traverse full path as supplied by user // (including ..) testPath := publicPath + strings.Join(subPath, "/") - fullPath, err := filepath.EvalSymlinks(testPath) + fullPath, err := symlink.EvalSymlinks(ctx, reader.vfs, testPath) if err != nil { if endsWithoutHTMLExtension(testPath) { @@ -96,7 +101,7 @@ func (reader *Reader) resolvePath(publicPath string, subPath ...string) (string, return "", fmt.Errorf("%q should be in %q", fullPath, publicPath) } - fi, err := os.Lstat(fullPath) + fi, err := reader.vfs.Lstat(ctx, fullPath) if err != nil { return "", err } @@ -118,17 +123,17 @@ func (reader *Reader) resolvePath(publicPath string, subPath ...string) (string, return fullPath, nil } -func (reader *Reader) serveFile(w http.ResponseWriter, r *http.Request, origPath string, accessControl bool) error { - fullPath := handleGZip(w, r, origPath) +func (reader *Reader) serveFile(ctx context.Context, w http.ResponseWriter, r *http.Request, origPath string, accessControl bool) error { + fullPath := reader.handleGZip(ctx, w, r, origPath) - file, err := openNoFollow(fullPath) + file, err := reader.vfs.Open(ctx, fullPath) if err != nil { return err } defer file.Close() - fi, err := file.Stat() + fi, err := reader.vfs.Lstat(ctx, fullPath) if err != nil { return err } @@ -139,7 +144,7 @@ func (reader *Reader) serveFile(w http.ResponseWriter, r *http.Request, origPath w.Header().Set("Expires", time.Now().Add(10*time.Minute).Format(time.RFC1123)) } - contentType, err := detectContentType(origPath) + contentType, err := reader.detectContentType(ctx, origPath) if err != nil { return err } @@ -152,22 +157,22 @@ func (reader *Reader) serveFile(w http.ResponseWriter, r *http.Request, origPath return nil } -func (reader *Reader) serveCustomFile(w http.ResponseWriter, r *http.Request, code int, origPath string) error { - fullPath := handleGZip(w, r, origPath) +func (reader *Reader) serveCustomFile(ctx context.Context, w http.ResponseWriter, r *http.Request, code int, origPath string) error { + fullPath := reader.handleGZip(ctx, w, r, origPath) // Open and serve content of file - file, err := openNoFollow(fullPath) + file, err := reader.vfs.Open(ctx, fullPath) if err != nil { return err } defer file.Close() - fi, err := file.Stat() + fi, err := reader.vfs.Lstat(ctx, fullPath) if err != nil { return err } - contentType, err := detectContentType(origPath) + contentType, err := reader.detectContentType(ctx, origPath) if err != nil { return err } diff --git a/internal/serving/disk/serving.go b/internal/serving/disk/serving.go index f5ec0d78..b7501b80 100644 --- a/internal/serving/disk/serving.go +++ b/internal/serving/disk/serving.go @@ -3,12 +3,15 @@ package disk import ( "gitlab.com/gitlab-org/gitlab-pages/internal/httperrors" "gitlab.com/gitlab-org/gitlab-pages/internal/serving" + "gitlab.com/gitlab-org/gitlab-pages/internal/vfs" + "gitlab.com/gitlab-org/gitlab-pages/internal/vfs/local" "gitlab.com/gitlab-org/gitlab-pages/metrics" ) var disk = &Disk{ reader: Reader{ fileSizeMetric: metrics.DiskServingFileSize, + vfs: vfs.Instrumented(local.VFS{}, "disk"), }, } diff --git a/internal/serving/disk/symlink/path_test.go b/internal/serving/disk/symlink/path_test.go index 077b0ad2..4d590db5 100644 --- a/internal/serving/disk/symlink/path_test.go +++ b/internal/serving/disk/symlink/path_test.go @@ -2,17 +2,22 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package filepath_test +package symlink_test import ( + "context" "io/ioutil" "os" + "path/filepath" "runtime" "testing" - filepath "gitlab.com/gitlab-org/gitlab-pages/internal/serving/disk/symlink" + "gitlab.com/gitlab-org/gitlab-pages/internal/serving/disk/symlink" + "gitlab.com/gitlab-org/gitlab-pages/internal/vfs/local" ) +var fs = local.VFS{} + func chtmpdir(t *testing.T) (restore func()) { oldwd, err := os.Getwd() if err != nil { @@ -81,7 +86,7 @@ func simpleJoin(dir, path string) string { } func testEvalSymlinks(t *testing.T, path, want string) { - have, err := filepath.EvalSymlinks(path) + have, err := symlink.EvalSymlinks(context.Background(), fs, path) if err != nil { t.Errorf("EvalSymlinks(%q) error: %v", path, err) return @@ -108,7 +113,7 @@ func testEvalSymlinksAfterChdir(t *testing.T, wd, path, want string) { t.Fatal(err) } - have, err := filepath.EvalSymlinks(path) + have, err := symlink.EvalSymlinks(context.Background(), fs, path) if err != nil { t.Errorf("EvalSymlinks(%q) in %q directory error: %v", path, wd, err) return @@ -183,7 +188,7 @@ func TestEvalSymlinks(t *testing.T) { func TestEvalSymlinksIsNotExist(t *testing.T) { defer chtmpdir(t)() - _, err := filepath.EvalSymlinks("notexist") + _, err := symlink.EvalSymlinks(context.Background(), fs, "notexist") if !os.IsNotExist(err) { t.Errorf("expected the file is not found, got %v\n", err) } @@ -194,7 +199,7 @@ func TestEvalSymlinksIsNotExist(t *testing.T) { } defer os.Remove("link") - _, err = filepath.EvalSymlinks("link") + _, err = symlink.EvalSymlinks(context.Background(), fs, "link") if !os.IsNotExist(err) { t.Errorf("expected the file is not found, got %v\n", err) } @@ -251,7 +256,7 @@ func TestIssue13582(t *testing.T) { {link2, realFile}, } for i, test := range tests { - have, err := filepath.EvalSymlinks(test.path) + have, err := symlink.EvalSymlinks(context.Background(), fs, test.path) if err != nil { t.Fatal(err) } diff --git a/internal/serving/disk/symlink/shims.go b/internal/serving/disk/symlink/shims.go index 4b344180..d383b96b 100644 --- a/internal/serving/disk/symlink/shims.go +++ b/internal/serving/disk/symlink/shims.go @@ -1,13 +1,17 @@ -package filepath +package symlink -import stdlib "path/filepath" +import ( + "context" + "path/filepath" + + "gitlab.com/gitlab-org/gitlab-pages/internal/vfs" +) func volumeNameLen(s string) int { return 0 } -func IsAbs(path string) bool { return stdlib.IsAbs(path) } -func Clean(path string) string { return stdlib.Clean(path) } -func Join(elem ...string) string { return stdlib.Join(elem...) } -func VolumeName(path string) string { return stdlib.VolumeName(path) } -func EvalSymlinks(path string) (string, error) { return walkSymlinks(path) } +func IsAbs(path string) bool { return filepath.IsAbs(path) } +func Clean(path string) string { return filepath.Clean(path) } -const Separator = stdlib.Separator +func EvalSymlinks(ctx context.Context, fs vfs.VFS, path string) (string, error) { + return walkSymlinks(ctx, fs, path) +} diff --git a/internal/serving/disk/symlink/symlink.go b/internal/serving/disk/symlink/symlink.go index 335b315a..50714811 100644 --- a/internal/serving/disk/symlink/symlink.go +++ b/internal/serving/disk/symlink/symlink.go @@ -2,16 +2,19 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package filepath +package symlink import ( + "context" "errors" "os" "runtime" "syscall" + + "gitlab.com/gitlab-org/gitlab-pages/internal/vfs" ) -func walkSymlinks(path string) (string, error) { +func walkSymlinks(ctx context.Context, fs vfs.VFS, path string) (string, error) { volLen := volumeNameLen(path) pathSeparator := string(os.PathSeparator) @@ -80,7 +83,7 @@ func walkSymlinks(path string) (string, error) { // Resolve symlink. - fi, err := os.Lstat(dest) + fi, err := fs.Lstat(ctx, dest) if err != nil { return "", err } @@ -99,7 +102,7 @@ func walkSymlinks(path string) (string, error) { return "", errors.New("EvalSymlinks: too many links") } - link, err := os.Readlink(dest) + link, err := fs.Readlink(ctx, dest) if err != nil { return "", err } diff --git a/internal/vfs/local/local_test.go b/internal/vfs/local/local_test.go new file mode 100644 index 00000000..c41f96bc --- /dev/null +++ b/internal/vfs/local/local_test.go @@ -0,0 +1,96 @@ +package local + +import ( + "context" + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReadlink(t *testing.T) { + ctx := context.Background() + fs := VFS{} + + target, err := fs.Readlink(ctx, "testdata/link") + require.NoError(t, err) + require.Equal(t, "file", target) +} + +func TestReadlinkNotSymlink(t *testing.T) { + ctx := context.Background() + fs := VFS{} + + for _, path := range []string{"testdata", "testdata/file"} { + t.Run(path, func(t *testing.T) { + _, err := os.Lstat(path) + require.NoError(t, err, "sanity check: input must actually exist") + + _, err = fs.Readlink(ctx, path) + require.Error(t, err, "expect readlink to fail") + }) + } +} + +func TestLstat(t *testing.T) { + ctx := context.Background() + fs := VFS{} + + testCases := []struct { + path string + modePerm os.FileMode + modeType os.FileMode + }{ + {path: "testdata", modeType: os.ModeDir, modePerm: 0755}, + {path: "testdata/file", modeType: os.FileMode(0), modePerm: 0644}, + {path: "testdata/link", modeType: os.ModeSymlink}, // Permissions of symlinks are platform dependent + } + + for _, tc := range testCases { + t.Run(tc.path, func(t *testing.T) { + if tc.modePerm > 0 { + require.NoError(t, os.Chmod(tc.path, tc.modePerm), "preparation: deterministic permissions") + } + + fi, err := fs.Lstat(ctx, tc.path) + require.NoError(t, err, "lstat error") + + require.Equal(t, tc.modeType, fi.Mode()&os.ModeType, "file mode: type") + if tc.modePerm > 0 { + require.Equal(t, tc.modePerm, fi.Mode()&os.ModePerm, "file mode: permissions") + } + }) + } +} + +func TestOpen(t *testing.T) { + ctx := context.Background() + fs := VFS{} + + f, err := fs.Open(ctx, "testdata/file") + require.NoError(t, err, "open file") + + data, err := ioutil.ReadAll(f) + require.NoError(t, err, "read from file") + require.Equal(t, "hello\n", string(data), "file contents") + + require.NoError(t, f.Close(), "close file") +} + +func TestOpenDenySymlink(t *testing.T) { + ctx := context.Background() + fs := VFS{} + const symlinkPath = "testdata/link" + + fi, err := os.Stat(symlinkPath) + require.NoError(t, err, "stat link") + require.Equal(t, os.FileMode(0), fi.Mode()&os.ModeType, "sanity check: link target should be a regular file") + + fi, err = os.Lstat(symlinkPath) + require.NoError(t, err, "lstat link") + require.Equal(t, os.ModeSymlink, fi.Mode()&os.ModeType, "sanity check: testdata/link should be a symlink") + + _, err = fs.Open(ctx, symlinkPath) + require.Error(t, err, "opening symlink should fail (security mechanism)") +} diff --git a/internal/vfs/local/testdata/file b/internal/vfs/local/testdata/file new file mode 100644 index 00000000..ce013625 --- /dev/null +++ b/internal/vfs/local/testdata/file @@ -0,0 +1 @@ +hello diff --git a/internal/vfs/local/testdata/link b/internal/vfs/local/testdata/link new file mode 120000 index 00000000..1a010b1c --- /dev/null +++ b/internal/vfs/local/testdata/link @@ -0,0 +1 @@ +file
\ No newline at end of file diff --git a/internal/vfs/local/vfs.go b/internal/vfs/local/vfs.go new file mode 100644 index 00000000..7c6f3ba6 --- /dev/null +++ b/internal/vfs/local/vfs.go @@ -0,0 +1,19 @@ +package local + +import ( + "context" + "os" + + "golang.org/x/sys/unix" + + "gitlab.com/gitlab-org/gitlab-pages/internal/vfs" +) + +type VFS struct{} + +func (fs VFS) Lstat(ctx context.Context, name string) (os.FileInfo, error) { return os.Lstat(name) } +func (fs VFS) Readlink(ctx context.Context, name string) (string, error) { return os.Readlink(name) } + +func (fs VFS) Open(ctx context.Context, name string) (vfs.File, error) { + return os.OpenFile(name, os.O_RDONLY|unix.O_NOFOLLOW, 0) +} diff --git a/internal/vfs/vfs.go b/internal/vfs/vfs.go new file mode 100644 index 00000000..07c99b77 --- /dev/null +++ b/internal/vfs/vfs.go @@ -0,0 +1,55 @@ +package vfs + +import ( + "context" + "io" + "os" + "strconv" + + "gitlab.com/gitlab-org/gitlab-pages/metrics" +) + +// VFS abstracts the things Pages needs to serve a static site from disk. +type VFS interface { + Lstat(ctx context.Context, name string) (os.FileInfo, error) + Readlink(ctx context.Context, name string) (string, error) + Open(ctx context.Context, name string) (File, error) +} + +// File represents an open file, which will typically be the response body of a Pages request. +type File interface { + io.Reader + io.Seeker + io.Closer +} + +func Instrumented(fs VFS, name string) VFS { + return &InstrumentedVFS{fs: fs, name: name} +} + +type InstrumentedVFS struct { + fs VFS + name string +} + +func (i *InstrumentedVFS) increment(operation string, err error) { + metrics.VFSOperations.WithLabelValues(i.name, operation, strconv.FormatBool(err == nil)).Inc() +} + +func (i *InstrumentedVFS) Lstat(ctx context.Context, name string) (os.FileInfo, error) { + fi, err := i.fs.Lstat(ctx, name) + i.increment("Lstat", err) + return fi, err +} + +func (i *InstrumentedVFS) Readlink(ctx context.Context, name string) (string, error) { + target, err := i.fs.Readlink(ctx, name) + i.increment("Readlink", err) + return target, err +} + +func (i *InstrumentedVFS) Open(ctx context.Context, name string) (File, error) { + f, err := i.fs.Open(ctx, name) + i.increment("Open", err) + return f, err +} diff --git a/metrics/metrics.go b/metrics/metrics.go index 88636279..0792a41f 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -91,6 +91,12 @@ var ( Help: "The time (in seconds) taken to serve a file", Buckets: []float64{0.1, 0.5, 1, 2.5, 5, 10, 60, 180}, }) + + // VFSOperations metric for VFS operations (lstat, readlink, open) + VFSOperations = prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "gitlab_pages_vfs_operations_total", + Help: "The number of VFS operations", + }, []string{"vfs_name", "operation", "success"}) ) // MustRegister collectors with the Prometheus client @@ -110,5 +116,6 @@ func MustRegister() { ServerlessLatency, DiskServingFileSize, ServingTime, + VFSOperations, ) } |