Skip to content

Commit 07cccf9

Browse files
authored
feat: disable directory listings for static files (#12229)
* 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.
1 parent 2dac342 commit 07cccf9

File tree

5 files changed

+91
-14
lines changed

5 files changed

+91
-14
lines changed

coderd/coderd.go

+8
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,14 @@ func New(options *Options) *API {
10671067
// See globalHTTPSwaggerHandler comment as to why we use a package
10681068
// global variable here.
10691069
r.Get("/swagger/*", globalHTTPSwaggerHandler)
1070+
} else {
1071+
swaggerDisabled := http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
1072+
httpapi.Write(context.Background(), rw, http.StatusNotFound, codersdk.Response{
1073+
Message: "Swagger documentation is disabled.",
1074+
})
1075+
})
1076+
r.Get("/swagger", swaggerDisabled)
1077+
r.Get("/swagger/*", swaggerDisabled)
10701078
}
10711079

10721080
// Add CSP headers to all static assets and pages. CSP headers only affect

coderd/coderd_test.go

+2-8
Original file line numberDiff line numberDiff line change
@@ -312,12 +312,9 @@ func TestSwagger(t *testing.T) {
312312

313313
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, swaggerEndpoint, nil)
314314
require.NoError(t, err)
315-
316-
body, err := io.ReadAll(resp.Body)
317-
require.NoError(t, err)
318315
defer resp.Body.Close()
319316

320-
require.Equal(t, "<pre>\n</pre>\n", string(body))
317+
require.Equal(t, http.StatusNotFound, resp.StatusCode)
321318
})
322319
t.Run("doc.json disabled by default", func(t *testing.T) {
323320
t.Parallel()
@@ -329,12 +326,9 @@ func TestSwagger(t *testing.T) {
329326

330327
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, swaggerEndpoint+"/doc.json", nil)
331328
require.NoError(t, err)
332-
333-
body, err := io.ReadAll(resp.Body)
334-
require.NoError(t, err)
335329
defer resp.Body.Close()
336330

337-
require.Equal(t, "<pre>\n</pre>\n", string(body))
331+
require.Equal(t, http.StatusNotFound, resp.StatusCode)
338332
})
339333
}
340334

site/site.go

+43-2
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ func New(opts *Options) *Handler {
102102
// Set ETag header to the SHA1 hash of the file contents.
103103
name := filePath(r.URL.Path)
104104
if name == "" || name == "/" {
105-
// Serve the directory listing.
105+
// Serve the directory listing. This intentionally allows directory listings to
106+
// be served. This file system should not contain anything sensitive.
106107
http.FileServer(opts.BinFS).ServeHTTP(rw, r)
107108
return
108109
}
@@ -129,7 +130,15 @@ func New(opts *Options) *Handler {
129130
// If-Match and If-None-Match headers on the request properly.
130131
http.FileServer(opts.BinFS).ServeHTTP(rw, r)
131132
})))
132-
mux.Handle("/", http.FileServer(http.FS(opts.SiteFS)))
133+
mux.Handle("/", http.FileServer(
134+
http.FS(
135+
// OnlyFiles is a wrapper around the file system that prevents directory
136+
// listings. Directory listings are not required for the site file system, so we
137+
// exclude it as a security measure. In practice, this file system comes from our
138+
// open source code base, but this is considered a best practice for serving
139+
// static files.
140+
OnlyFiles(opts.SiteFS))),
141+
)
133142

134143
buildInfo := codersdk.BuildInfoResponse{
135144
ExternalURL: buildinfo.ExternalURL(),
@@ -873,3 +882,35 @@ func applicationNameOrDefault(cfg codersdk.AppearanceConfig) string {
873882
}
874883
return "Coder"
875884
}
885+
886+
// OnlyFiles returns a new fs.FS that only contains files. If a directory is
887+
// requested, os.ErrNotExist is returned. This prevents directory listings from
888+
// being served.
889+
func OnlyFiles(files fs.FS) fs.FS {
890+
return justFilesSystem{FS: files}
891+
}
892+
893+
type justFilesSystem struct {
894+
FS fs.FS
895+
}
896+
897+
func (jfs justFilesSystem) Open(name string) (fs.File, error) {
898+
f, err := jfs.FS.Open(name)
899+
if err != nil {
900+
return nil, err
901+
}
902+
903+
stat, err := f.Stat()
904+
if err != nil {
905+
return nil, err
906+
}
907+
908+
// Returning a 404 here does prevent the http.FileServer from serving
909+
// index.* files automatically. Coder handles this above as all index pages
910+
// are considered template files. So we never relied on this behavior.
911+
if stat.IsDir() {
912+
return nil, os.ErrNotExist
913+
}
914+
915+
return f, nil
916+
}

site/site_slim.go

+11-4
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@
44
package site
55

66
import (
7-
"embed"
87
"io/fs"
8+
"testing/fstest"
9+
"time"
910
)
1011

11-
var slim embed.FS
12-
1312
func FS() fs.FS {
14-
return slim
13+
// This is required to contain an index.html file for unit tests.
14+
// Our unit tests frequently just hit `/` and expect to get a 200.
15+
// So a valid index.html file should be expected to be served.
16+
return fstest.MapFS{
17+
"index.html": &fstest.MapFile{
18+
Data: []byte("Slim build of Coder, does not contain the frontend static files."),
19+
ModTime: time.Now(),
20+
},
21+
}
1522
}

site/site_test.go

+27
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"testing/fstest"
1919
"time"
2020

21+
"github.com/go-chi/chi/v5"
2122
"github.com/google/uuid"
2223
"github.com/stretchr/testify/assert"
2324
"github.com/stretchr/testify/require"
@@ -659,3 +660,29 @@ func TestRenderStaticErrorPageNoStatus(t *testing.T) {
659660
require.Contains(t, bodyStr, "Retry")
660661
require.Contains(t, bodyStr, d.DashboardURL)
661662
}
663+
664+
func TestJustFilesSystem(t *testing.T) {
665+
t.Parallel()
666+
667+
tfs := fstest.MapFS{
668+
"dir/foo.txt": &fstest.MapFile{
669+
Data: []byte("hello world"),
670+
},
671+
"dir/bar.txt": &fstest.MapFile{
672+
Data: []byte("hello world"),
673+
},
674+
}
675+
676+
mux := chi.NewRouter()
677+
mux.Mount("/onlyfiles/", http.StripPrefix("/onlyfiles", http.FileServer(http.FS(site.OnlyFiles(tfs)))))
678+
mux.Mount("/all/", http.StripPrefix("/all", http.FileServer(http.FS(tfs))))
679+
680+
// The /all/ endpoint should serve the directory listing.
681+
resp := httptest.NewRecorder()
682+
mux.ServeHTTP(resp, httptest.NewRequest("GET", "/all/dir/", nil))
683+
require.Equal(t, http.StatusOK, resp.Code, "all serves the directory")
684+
685+
resp = httptest.NewRecorder()
686+
mux.ServeHTTP(resp, httptest.NewRequest("GET", "/onlyfiles/dir/", nil))
687+
require.Equal(t, http.StatusNotFound, resp.Code, "onlyfiles does not serve the directory")
688+
}

0 commit comments

Comments
 (0)