From 57ab62008ae7411d4faeafe519c66038c33c3b5b Mon Sep 17 00:00:00 2001 From: Ringo Hoffmann Date: Fri, 1 Sep 2023 10:47:23 +0200 Subject: [PATCH 1/5] add compression for `file.Content` --- file/options.go | 71 ++++++++++++++++++++++++++++++++++++++++++ file/server.go | 82 +++++++------------------------------------------ 2 files changed, 82 insertions(+), 71 deletions(-) create mode 100644 file/options.go diff --git a/file/options.go b/file/options.go new file mode 100644 index 0000000..bfaefd1 --- /dev/null +++ b/file/options.go @@ -0,0 +1,71 @@ +package file + +import ( + "path/filepath" + + routing "github.com/go-ozzo/ozzo-routing/v2" +) + +// ServerOptions defines the possible options for the Server handler. +type ServerOptions struct { + // The path that all files to be served should be located within. The path map passed to the Server method + // are all relative to this path. This property can be specified as an absolute file path or a path relative + // to the current working path. If not set, this property defaults to the current working path. + RootPath string + // The file (e.g. index.html) to be served when the current request corresponds to a directory. + // If not set, the handler will return a 404 HTTP error when the request corresponds to a directory. + // This should only be a file name without the directory part. + IndexFile string + // The file to be served when no file or directory matches the current request. + // If not set, the handler will return a 404 HTTP error when no file/directory matches the request. + // The path of this file is relative to RootPath + CatchAllFile string + // A function that checks if the requested file path is allowed. If allowed, the function + // may do additional work such as setting Expires HTTP header. + // The function should return a boolean indicating whether the file should be served or not. + // If false, a 404 HTTP error will be returned by the handler. + Allow func(*routing.Context, string) bool + // Define available compression encodings for serving files. Encodings are negotiated against the + // unser agent. The first encoding which matches the accepted encodings from the user agent as well + // as is available as file is served to the client. + Compression []Encoding +} + +// Merge takes another instance of ServerOptions and merges it with the current instance. +// Thereby other overwrites values in t if existent. The merged instance is returned as new +// ServerOptions instance. +func (t ServerOptions) Merge(other ServerOptions) (new ServerOptions) { + new = t + + if other.Allow != nil { + new.Allow = other.Allow + } + if other.CatchAllFile != "" { + new.CatchAllFile = other.CatchAllFile + } + if other.Compression != nil { + new.Compression = other.Compression + } + if other.IndexFile != "" { + new.IndexFile = other.IndexFile + } + if other.RootPath != "" { + new.RootPath = other.RootPath + } + + return new +} + +func getServerOptions(opts []ServerOptions) ServerOptions { + var options ServerOptions + + for _, opt := range opts { + options = options.Merge(opt) + } + + if !filepath.IsAbs(options.RootPath) { + options.RootPath = filepath.Join(RootPath, options.RootPath) + } + + return options +} diff --git a/file/server.go b/file/server.go index 2da0f53..66d75b0 100644 --- a/file/server.go +++ b/file/server.go @@ -22,53 +22,6 @@ const ( GZip = Encoding("gzip") ) -// ServerOptions defines the possible options for the Server handler. -type ServerOptions struct { - // The path that all files to be served should be located within. The path map passed to the Server method - // are all relative to this path. This property can be specified as an absolute file path or a path relative - // to the current working path. If not set, this property defaults to the current working path. - RootPath string - // The file (e.g. index.html) to be served when the current request corresponds to a directory. - // If not set, the handler will return a 404 HTTP error when the request corresponds to a directory. - // This should only be a file name without the directory part. - IndexFile string - // The file to be served when no file or directory matches the current request. - // If not set, the handler will return a 404 HTTP error when no file/directory matches the request. - // The path of this file is relative to RootPath - CatchAllFile string - // A function that checks if the requested file path is allowed. If allowed, the function - // may do additional work such as setting Expires HTTP header. - // The function should return a boolean indicating whether the file should be served or not. - // If false, a 404 HTTP error will be returned by the handler. - Allow func(*routing.Context, string) bool - // Define available compression encodings for serving files. Encodings are negotiated against the - // unser agent. The first encoding which matches the accepted encodings from the user agent as well - // as is available as file is served to the client. - Compression []Encoding -} - -func (t ServerOptions) Merge(other ServerOptions) (new ServerOptions) { - new = t - - if other.Allow != nil { - new.Allow = other.Allow - } - if other.CatchAllFile != "" { - new.CatchAllFile = other.CatchAllFile - } - if other.Compression != nil { - new.Compression = other.Compression - } - if other.IndexFile != "" { - new.IndexFile = other.IndexFile - } - if other.RootPath != "" { - new.RootPath = other.RootPath - } - - return new -} - // PathMap specifies the mapping between URL paths (keys) and file paths (keys). // The file paths are relative to Options.RootPath type PathMap map[string]string @@ -100,13 +53,8 @@ func init() { // "/js": "/ui/dist/js", // })) func Server(pathMap PathMap, opts ...ServerOptions) routing.Handler { - var options ServerOptions - if len(opts) > 0 { - options = opts[0] - } - if !filepath.IsAbs(options.RootPath) { - options.RootPath = filepath.Join(RootPath, options.RootPath) - } + options := getServerOptions(opts) + from, to := parsePathMap(pathMap) // security measure: limit the files within options.RootPath @@ -184,28 +132,20 @@ func serveFile(c *routing.Context, dir compressionDir, path string) error { // The file to be served can be specified as an absolute file path or a path relative to RootPath (which // defaults to the current working path). // If the specified file does not exist, the handler will pass the control to the next available handler. -func Content(path string) routing.Handler { - if !filepath.IsAbs(path) { - path = filepath.Join(RootPath, path) - } +func Content(path string, opts ...ServerOptions) routing.Handler { + options := getServerOptions(opts) + + dir := http.Dir(options.RootPath) + return func(c *routing.Context) error { if c.Request.Method != "GET" && c.Request.Method != "HEAD" { return routing.NewHTTPError(http.StatusMethodNotAllowed) } - file, err := os.Open(path) - if err != nil { - return routing.NewHTTPError(http.StatusNotFound, err.Error()) - } - defer file.Close() - fstat, err := file.Stat() - if err != nil { - return routing.NewHTTPError(http.StatusNotFound, err.Error()) - } else if fstat.IsDir() { - return routing.NewHTTPError(http.StatusNotFound) - } - http.ServeContent(c.Response, c.Request, path, fstat.ModTime(), file) - return nil + encodings := negotiateEncodings(c, options.Compression) + dir := compressionDir{dir, encodings} + + return serveFile(c, dir, path) } } From b07daad01ca23149d584ea9391b62e0337a1a1cc Mon Sep 17 00:00:00 2001 From: Ringo Hoffmann Date: Wed, 27 Sep 2023 11:32:07 +0200 Subject: [PATCH 2/5] add caching handlers --- caching/handler.go | 126 ++++++++++++++++++++++++++++++++++++++++ caching/handler_test.go | 67 +++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 caching/handler.go create mode 100644 caching/handler_test.go diff --git a/caching/handler.go b/caching/handler.go new file mode 100644 index 0000000..91f8419 --- /dev/null +++ b/caching/handler.go @@ -0,0 +1,126 @@ +package caching + +import ( + "fmt" + routing "github.com/go-ozzo/ozzo-routing/v2" + "strings" + "time" +) + +type AccessType string + +const ( + AccessPublic = AccessType("public") + AccessPrivate = AccessType("private") +) + +// Options defines Cache Control directive values as defined in +// RFC 7234 Section 5.2.1. +// +// https://www.rfc-editor.org/rfc/rfc7234#section-5.2.1 +// +// Additional Reference: +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#directives +type Options struct { + Access AccessType + MaxAge time.Duration + SMaxAge time.Duration + NoCache bool + NoStore bool + MustRevalidate bool + ProxyRevalidate bool + MustUnderstand bool + NoTransform bool + Immutable bool +} + +func (t Options) BuildHeaderValues() string { + var sb strings.Builder + + if t.Access != "" { + fmt.Fprintf(&sb, "%s,", t.Access) + } + + if t.MaxAge != 0 { + fmt.Fprintf(&sb, "max-age=%d,", int64(t.MaxAge.Round(time.Second).Seconds())) + } + + if t.SMaxAge != 0 { + fmt.Fprintf(&sb, "s-max-age=%d,", int64(t.SMaxAge.Round(time.Second).Seconds())) + } + + if t.NoCache { + fmt.Fprint(&sb, "no-cache,") + } + + if t.NoStore { + fmt.Fprint(&sb, "no-store,") + } + + if t.MustRevalidate { + fmt.Fprint(&sb, "must-revalidate,") + } + + if t.ProxyRevalidate { + fmt.Fprint(&sb, "proxy-revalidate,") + } + + if t.MustUnderstand { + fmt.Fprint(&sb, "must-understand,") + } + + if t.NoTransform { + fmt.Fprint(&sb, "no-transform,") + } + + if t.Immutable { + fmt.Fprint(&sb, "immutable,") + } + + v := sb.String() + if len(v) == 0 { + return "" + } + + return v[:len(v)-1] +} + +// Handler returns a routing.Handler which sets the "Access-Control" header +// value as defined in the given options to the response. +func Handler(options Options) routing.Handler { + headerValue := options.BuildHeaderValues() + return func(c *routing.Context) error { + c.Response.Header().Set("Access-Control", headerValue) + return nil + } +} + +// Public returns a routing.Handler which sets the "Access-Control" header +// value to "public,max-age=". +func Public(maxAge time.Duration) routing.Handler { + return Handler(Options{ + Access: AccessPublic, + MaxAge: maxAge, + }) +} + +// Private returns a routing.Handler which sets the "Access-Control" header +// value to "private,max-age=". +func Private(maxAge time.Duration) routing.Handler { + return Handler(Options{ + Access: AccessPrivate, + MaxAge: maxAge, + }) +} + +// NoCache returns a routing.Handler which sets the "Access-Control" header +// value to "no-cache". +func NoCache() routing.Handler { + return Handler(Options{NoCache: true}) +} + +// NoStore returns a routing.Handler which sets the "Access-Control" header +// value to "no-store". +func NoStore() routing.Handler { + return Handler(Options{NoStore: true}) +} diff --git a/caching/handler_test.go b/caching/handler_test.go new file mode 100644 index 0000000..18ec855 --- /dev/null +++ b/caching/handler_test.go @@ -0,0 +1,67 @@ +package caching + +import ( + "testing" + "time" +) + +func TestOptions_BuildHeaderValues(t1 *testing.T) { + tests := []struct { + name string + fields Options + want string + }{ + { + name: "all", + fields: Options{ + Access: AccessPublic, + MaxAge: 5 * time.Minute, + SMaxAge: 10 * time.Minute, + NoCache: true, + NoStore: true, + MustRevalidate: true, + ProxyRevalidate: true, + MustUnderstand: true, + NoTransform: true, + Immutable: true, + }, + want: "public,max-age=300,s-max-age=600,no-cache,no-store,must-revalidate,proxy-revalidate,must-understand,no-transform,immutable", + }, + { + name: "partial", + fields: Options{ + Access: AccessPublic, + MaxAge: 30 * time.Second, + MustRevalidate: true, + }, + want: "public,max-age=30,must-revalidate", + }, + { + name: "empty", + fields: Options{}, + want: "", + }, + } + + for _, tt := range tests { + t1.Run(tt.name, func(t1 *testing.T) { + t := Options{ + Access: tt.fields.Access, + MaxAge: tt.fields.MaxAge, + SMaxAge: tt.fields.SMaxAge, + NoCache: tt.fields.NoCache, + NoStore: tt.fields.NoStore, + MustRevalidate: tt.fields.MustRevalidate, + ProxyRevalidate: tt.fields.ProxyRevalidate, + MustUnderstand: tt.fields.MustUnderstand, + NoTransform: tt.fields.NoTransform, + Immutable: tt.fields.Immutable, + } + if got := t.BuildHeaderValues(); got != tt.want { + t1.Errorf("BuildHeaderValues() failed\n"+ + "\twas: %v\n"+ + "\twant: %v", got, tt.want) + } + }) + } +} From ab5f047db4b5dfeb6622d2e4429e2e6a9e98aed2 Mon Sep 17 00:00:00 2001 From: Alexander Manegold Date: Fri, 13 Oct 2023 08:27:08 +0200 Subject: [PATCH 3/5] added exclusion of as path elements --- file/server.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/file/server.go b/file/server.go index 66d75b0..86c16f8 100644 --- a/file/server.go +++ b/file/server.go @@ -41,6 +41,8 @@ func init() { // For example, if the path map contains both "/css" and "/css/img", and the URL path is "/css/img/logo.gif", // then the path mapped by "/css/img" will be used. // +// The usage of URL.Paths containing ".." as path element is forbidden, but ".."" can be used in file names. +// // import ( // "log" // "github.com/studio-b12/ozzo-routing" @@ -65,6 +67,10 @@ func Server(pathMap PathMap, opts ...ServerOptions) routing.Handler { return routing.NewHTTPError(http.StatusMethodNotAllowed) } + if containsDotDot(c.Request.URL.Path) { + return routing.NewHTTPError(http.StatusBadRequest, "invalid URL path") + } + path, found := matchPath(c.Request.URL.Path, from, to) if !found || options.Allow != nil && !options.Allow(c, path) { return routing.NewHTTPError(http.StatusNotFound) @@ -132,6 +138,8 @@ func serveFile(c *routing.Context, dir compressionDir, path string) error { // The file to be served can be specified as an absolute file path or a path relative to RootPath (which // defaults to the current working path). // If the specified file does not exist, the handler will pass the control to the next available handler. +// +// The usage of URL.Paths containing ".." as path element is forbidden, but ".."" can be used in file names. func Content(path string, opts ...ServerOptions) routing.Handler { options := getServerOptions(opts) @@ -142,6 +150,10 @@ func Content(path string, opts ...ServerOptions) routing.Handler { return routing.NewHTTPError(http.StatusMethodNotAllowed) } + if containsDotDot(c.Request.URL.Path) { + return routing.NewHTTPError(http.StatusBadRequest, "invalid URL path") + } + encodings := negotiateEncodings(c, options.Compression) dir := compressionDir{dir, encodings} @@ -192,3 +204,18 @@ func negotiateEncodings(c *routing.Context, available []Encoding) []Encoding { return negotioated } + +// Equivalent to containsDotDot() check in http.ServeFile() +func containsDotDot(v string) bool { + if !strings.Contains(v, "..") { + return false + } + for _, ent := range strings.FieldsFunc(v, isSlashRune) { + if ent == ".." { + return true + } + } + return false +} + +func isSlashRune(r rune) bool { return r == '/' || r == '\\' } From 61c1c25275011c42a6ea98f4d83657580b85c1f7 Mon Sep 17 00:00:00 2001 From: Ringo Hoffmann Date: Mon, 16 Oct 2023 10:22:57 +0200 Subject: [PATCH 4/5] fix typo --- file/server.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/file/server.go b/file/server.go index 86c16f8..85a3d05 100644 --- a/file/server.go +++ b/file/server.go @@ -41,7 +41,7 @@ func init() { // For example, if the path map contains both "/css" and "/css/img", and the URL path is "/css/img/logo.gif", // then the path mapped by "/css/img" will be used. // -// The usage of URL.Paths containing ".." as path element is forbidden, but ".."" can be used in file names. +// The usage of URL.Paths containing ".." as path element is forbidden, but ".." can be used in file names. // // import ( // "log" @@ -139,7 +139,7 @@ func serveFile(c *routing.Context, dir compressionDir, path string) error { // defaults to the current working path). // If the specified file does not exist, the handler will pass the control to the next available handler. // -// The usage of URL.Paths containing ".." as path element is forbidden, but ".."" can be used in file names. +// The usage of URL.Paths containing ".." as path element is forbidden, but ".." can be used in file names. func Content(path string, opts ...ServerOptions) routing.Handler { options := getServerOptions(opts) From 70beae039f760f1ff00bb696dc9ca1f90b103fda Mon Sep 17 00:00:00 2001 From: Alexander Manegold Date: Mon, 16 Oct 2023 12:32:15 +0200 Subject: [PATCH 5/5] added unit tests for dot dot exclusion --- file/server_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/file/server_test.go b/file/server_test.go index 62e6be9..83e4de9 100644 --- a/file/server_test.go +++ b/file/server_test.go @@ -185,3 +185,31 @@ func TestServer(t *testing.T) { assert.Equal(t, "hello\n", res.Body.String()) } } + +func TestExcludeDotDotContent(t *testing.T) { + h := Content("testdata/index.html") + req, _ := http.NewRequest("GET", "/../index.html", nil) + res := httptest.NewRecorder() + c := routing.NewContext(res, req) + err := h(c) + assert.NotNil(t, err) + assert.Equal(t, http.StatusBadRequest, err.(routing.HTTPError).StatusCode()) +} + +func TestExcludeDotDotServer(t *testing.T) { + h := Server(PathMap{"/test": "testdata/css"}) + // working request + req, _ := http.NewRequest("GET", "/test/index.html", nil) + res := httptest.NewRecorder() + c := routing.NewContext(res, req) + err := h(c) + assert.Nil(t, err) + assert.Equal(t, "css.html\n", res.Body.String()) + // illegal request + req, _ = http.NewRequest("GET", "/test/../index.html", nil) + res = httptest.NewRecorder() + c = routing.NewContext(res, req) + err = h(c) + assert.NotNil(t, err) + assert.Equal(t, http.StatusBadRequest, err.(routing.HTTPError).StatusCode()) +}