From 38357c386c27437cf494891fafbafa920e0f490a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 20 Feb 2024 09:35:04 -0600 Subject: [PATCH 1/9] feat: disable directory listings for static files Static file server handles serving static asset files (js, css, etc). The default file server would also list all files in a directory. This has been changed to only serve files. --- site/site.go | 42 ++++++++++++++++++++++++++++++++++++++++-- site/site_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/site/site.go b/site/site.go index 0476565521ac9..10878f33a7b6d 100644 --- a/site/site.go +++ b/site/site.go @@ -102,7 +102,8 @@ func New(opts *Options) *Handler { // Set ETag header to the SHA1 hash of the file contents. name := filePath(r.URL.Path) if name == "" || name == "/" { - // Serve the directory listing. + // Serve the directory listing. This intentionally allows directory listings to + // be served. This file system should not contain anything sensitive. http.FileServer(opts.BinFS).ServeHTTP(rw, r) return } @@ -129,7 +130,15 @@ func New(opts *Options) *Handler { // If-Match and If-None-Match headers on the request properly. http.FileServer(opts.BinFS).ServeHTTP(rw, r) }))) - mux.Handle("/", http.FileServer(http.FS(opts.SiteFS))) + mux.Handle("/", http.FileServer( + http.FS( + // OnlyFiles is a wrapper around the file system that prevents directory + // listings. Directory listings are not required for the site file system, so we + // exclude it as a security measure. In practice, this file system comes from our + // open source code base, but this is considered a best practice for serving + // static files. + OnlyFiles(opts.SiteFS))), + ) buildInfo := codersdk.BuildInfoResponse{ ExternalURL: buildinfo.ExternalURL(), @@ -873,3 +882,32 @@ func applicationNameOrDefault(cfg codersdk.AppearanceConfig) string { } return "Coder" } + +// OnlyFiles returns a new fs.FS that only contains files. If a directory is +// requested, os.ErrNotExist is returned. This prevents directory listings from +// being served. +func OnlyFiles(fs fs.FS) fs.FS { + return justFilesSystem{FS: fs} +} + +type justFilesSystem struct { + FS fs.FS +} + +func (fs justFilesSystem) Open(name string) (fs.File, error) { + f, err := fs.FS.Open(name) + if err != nil { + return nil, err + } + + stat, err := f.Stat() + if err != nil { + return nil, err + } + + if stat.IsDir() { + return nil, os.ErrNotExist + } + + return f, nil +} diff --git a/site/site_test.go b/site/site_test.go index b240a065fea1b..c9b4767cf92d9 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -18,6 +18,7 @@ import ( "testing/fstest" "time" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -659,3 +660,27 @@ func TestRenderStaticErrorPageNoStatus(t *testing.T) { require.Contains(t, bodyStr, "Retry") require.Contains(t, bodyStr, d.DashboardURL) } + +func TestJustFilesSystem(t *testing.T) { + tfs := fstest.MapFS{ + "dir/foo.txt": &fstest.MapFile{ + Data: []byte("hello world"), + }, + "dir/bar.txt": &fstest.MapFile{ + Data: []byte("hello world"), + }, + } + + mux := chi.NewRouter() + mux.Mount("/onlyfiles/", http.StripPrefix("/onlyfiles", http.FileServer(http.FS(site.OnlyFiles(tfs))))) + mux.Mount("/all/", http.StripPrefix("/all", http.FileServer(http.FS(tfs)))) + + // The /all/ endpoint should serve the directory listing. + resp := httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("GET", "/all/dir/", nil)) + require.Equal(t, http.StatusOK, resp.Code, "all serves the directory") + + resp = httptest.NewRecorder() + mux.ServeHTTP(resp, httptest.NewRequest("GET", "/onlyfiles/dir/", nil)) + require.Equal(t, http.StatusNotFound, resp.Code, "onlyfiles does not serve the directory") +} From 19032b93b524a39ac7775f9c5aa5238315dc90e4 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 20 Feb 2024 09:52:04 -0600 Subject: [PATCH 2/9] make test parallel --- site/site_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/site/site_test.go b/site/site_test.go index c9b4767cf92d9..2acf303b2c13b 100644 --- a/site/site_test.go +++ b/site/site_test.go @@ -662,6 +662,8 @@ func TestRenderStaticErrorPageNoStatus(t *testing.T) { } func TestJustFilesSystem(t *testing.T) { + t.Parallel() + tfs := fstest.MapFS{ "dir/foo.txt": &fstest.MapFile{ Data: []byte("hello world"), From 83786734ab2d5679db52a74caa5a3ca6c6085067 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 20 Feb 2024 10:41:54 -0600 Subject: [PATCH 3/9] add comment --- site/site.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/site/site.go b/site/site.go index 10878f33a7b6d..bf1daa524c80e 100644 --- a/site/site.go +++ b/site/site.go @@ -905,6 +905,9 @@ func (fs justFilesSystem) Open(name string) (fs.File, error) { return nil, err } + // Returning a 404 here does prevent the http.FileServer from serving + // index.* files automatically. Coder handles this above as all index pages + // are considered template files. So we never relied on this behavior. if stat.IsDir() { return nil, os.ErrNotExist } From b723edf4d3f88fa638d646fd3b86727656cf2de1 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 20 Feb 2024 10:48:49 -0600 Subject: [PATCH 4/9] return 200 for / on unit tests --- site/site_slim.go | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/site/site_slim.go b/site/site_slim.go index 414da032fc26e..82cbd7dd4debf 100644 --- a/site/site_slim.go +++ b/site/site_slim.go @@ -4,12 +4,19 @@ package site import ( - "embed" "io/fs" + "testing/fstest" + "time" ) -var slim embed.FS - func FS() fs.FS { - return slim + // This is required to contain an index.html file for unit tests. + // Our unit tests frequently just hit `/` and expect to get a 200. + // So a valid index.html file should be expected to be served. + return fstest.MapFS{ + "index.html": &fstest.MapFile{ + Data: []byte("Slim build of Coder, does not contain the frontend static files."), + ModTime: time.Now(), + }, + } } From 7c124a1ae68fd43087d96d04a5293fcfafa458c5 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 20 Feb 2024 10:56:15 -0600 Subject: [PATCH 5/9] swagger expectations --- coderd/coderd_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index a5f91fe6fd362..652c4ebcf083c 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -317,7 +317,7 @@ func TestSwagger(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() - require.Equal(t, "
\n
\n", string(body)) + require.Contains(t, string(body), "Slim build of Coder") }) t.Run("doc.json disabled by default", func(t *testing.T) { t.Parallel() @@ -334,7 +334,7 @@ func TestSwagger(t *testing.T) { require.NoError(t, err) defer resp.Body.Close() - require.Equal(t, "
\n
\n", string(body)) + require.Contains(t, string(body), "Slim build of Coder") }) } From f76b6bf2cf3064130c6dbf98e9daa4ea71a25d0c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 20 Feb 2024 10:57:16 -0600 Subject: [PATCH 6/9] Swagger default to 404 --- coderd/coderd.go | 3 +++ coderd/coderd_test.go | 10 ++-------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 7084d76f56952..848ea9397a10c 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1067,6 +1067,9 @@ func New(options *Options) *API { // See globalHTTPSwaggerHandler comment as to why we use a package // global variable here. r.Get("/swagger/*", globalHTTPSwaggerHandler) + } else { + r.Get("/swagger", http.NotFound) + r.Get("/swagger/*", http.NotFound) } // Add CSP headers to all static assets and pages. CSP headers only affect diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index 652c4ebcf083c..a632b399e8dea 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -312,12 +312,9 @@ func TestSwagger(t *testing.T) { resp, err := requestWithRetries(ctx, t, client, http.MethodGet, swaggerEndpoint, nil) require.NoError(t, err) - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) defer resp.Body.Close() - require.Contains(t, string(body), "Slim build of Coder") + require.Equal(t, http.StatusNotFound, resp.StatusCode) }) t.Run("doc.json disabled by default", func(t *testing.T) { t.Parallel() @@ -329,12 +326,9 @@ func TestSwagger(t *testing.T) { resp, err := requestWithRetries(ctx, t, client, http.MethodGet, swaggerEndpoint+"/doc.json", nil) require.NoError(t, err) - - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) defer resp.Body.Close() - require.Contains(t, string(body), "Slim build of Coder") + require.Equal(t, http.StatusNotFound, resp.StatusCode) }) } From ea442bea928909889e8088761d4169d91956d315 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 20 Feb 2024 10:58:43 -0600 Subject: [PATCH 7/9] better disabled message --- coderd/coderd.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 848ea9397a10c..068bec51ea0b4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1068,8 +1068,13 @@ func New(options *Options) *API { // global variable here. r.Get("/swagger/*", globalHTTPSwaggerHandler) } else { - r.Get("/swagger", http.NotFound) - r.Get("/swagger/*", http.NotFound) + swaggerDisabled := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + httpapi.Write(context.Background(), rw, http.StatusNotFound, codersdk.Response{ + Message: "Swagger documentation is disabled.", + }) + }) + r.Get("/swagger", swaggerDisabled) + r.Get("/swagger/*", swaggerDisabled) } // Add CSP headers to all static assets and pages. CSP headers only affect From 80ed8b04f9e7ef5c204b1b1bfebc18f3c3aae016 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 20 Feb 2024 12:22:27 -0600 Subject: [PATCH 8/9] fix lint --- site/site.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/site.go b/site/site.go index bf1daa524c80e..01aead235cd1a 100644 --- a/site/site.go +++ b/site/site.go @@ -886,8 +886,8 @@ func applicationNameOrDefault(cfg codersdk.AppearanceConfig) string { // OnlyFiles returns a new fs.FS that only contains files. If a directory is // requested, os.ErrNotExist is returned. This prevents directory listings from // being served. -func OnlyFiles(fs fs.FS) fs.FS { - return justFilesSystem{FS: fs} +func OnlyFiles(files fs.FS) fs.FS { + return justFilesSystem{FS: files} } type justFilesSystem struct { From ccf0e2cf7f02fed59e0290b1603c520de0ccf85d Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 20 Feb 2024 13:21:28 -0600 Subject: [PATCH 9/9] Linting --- site/site.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/site.go b/site/site.go index 01aead235cd1a..4da69e6b3ad07 100644 --- a/site/site.go +++ b/site/site.go @@ -894,8 +894,8 @@ type justFilesSystem struct { FS fs.FS } -func (fs justFilesSystem) Open(name string) (fs.File, error) { - f, err := fs.FS.Open(name) +func (jfs justFilesSystem) Open(name string) (fs.File, error) { + f, err := jfs.FS.Open(name) if err != nil { return nil, err }