Skip to content

feat: disable directory listings for static files #12229

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -1067,6 +1067,14 @@ func New(options *Options) *API {
// See globalHTTPSwaggerHandler comment as to why we use a package
// global variable here.
r.Get("/swagger/*", globalHTTPSwaggerHandler)
} else {
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
Expand Down
10 changes: 2 additions & 8 deletions coderd/coderd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.Equal(t, "<pre>\n</pre>\n", string(body))
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("doc.json disabled by default", func(t *testing.T) {
t.Parallel()
Expand All @@ -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.Equal(t, "<pre>\n</pre>\n", string(body))
require.Equal(t, http.StatusNotFound, resp.StatusCode)
})
}

Expand Down
45 changes: 43 additions & 2 deletions site/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines -105 to 107
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels beneficial to keep this for listing what binaries are supported. Thoughts?

Screenshot from 2024-02-20 09-37-57

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it!

return
}
Expand All @@ -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)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Binary directory

})))
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(),
Expand Down Expand Up @@ -873,3 +882,35 @@ 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(files fs.FS) fs.FS {
return justFilesSystem{FS: files}
}

type justFilesSystem struct {
FS fs.FS
}

func (jfs justFilesSystem) Open(name string) (fs.File, error) {
f, err := jfs.FS.Open(name)
if err != nil {
return nil, err
}

stat, err := f.Stat()
if err != nil {
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
}

return f, nil
}
15 changes: 11 additions & 4 deletions site/site_slim.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
}
}
Comment on lines 12 to 22
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kylecarbs I had to add this so unit tests work. When they query / it was a 404 since / was a directory, which now is not allowed.

27 changes: 27 additions & 0 deletions site/site_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -659,3 +660,29 @@ func TestRenderStaticErrorPageNoStatus(t *testing.T) {
require.Contains(t, bodyStr, "Retry")
require.Contains(t, bodyStr, d.DashboardURL)
}

func TestJustFilesSystem(t *testing.T) {
t.Parallel()

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")
}