diff --git a/fs.go b/fs.go index bf1b113..076602a 100644 --- a/fs.go +++ b/fs.go @@ -72,7 +72,7 @@ func ServeFileUncompressed(ctx *RequestCtx, path string) { // Use ServeFileBytesUncompressed is you don't need serving compressed // file contents. // -// See also RequestCtx.SendFileBytes. +// See also RequestCtx.SendFileBytes, ServeFileLiteral. // // WARNING: do not pass any user supplied paths to this function! // WARNING: if path is based on user input users will be able to request @@ -93,7 +93,11 @@ func ServeFileBytes(ctx *RequestCtx, path []byte) { // // Use ServeFileUncompressed is you don't need serving compressed file contents. // -// See also RequestCtx.SendFile. +// ServeFile interprets path as a URI path internally. Percent-encoded +// sequences may be decoded, and '?' or '#' may be treated as URI delimiters. +// Use ServeFileLiteral if you need literal path semantics. +// +// See also RequestCtx.SendFile, ServeFileLiteral. // // WARNING: do not pass any user supplied paths to this function! // WARNING: if path is based on user input users will be able to request @@ -103,30 +107,47 @@ func ServeFile(ctx *RequestCtx, path string) { rootFSHandler = rootFS.NewRequestHandler() }) - if path == "" || !filepath.IsAbs(path) { - // extend relative path to absolute path - hasTrailingSlash := path != "" && (path[len(path)-1] == '/' || path[len(path)-1] == '\\') - - var err error - path = filepath.FromSlash(path) - if path, err = filepath.Abs(path); err != nil { - ctx.Logger().Printf("cannot resolve path %q to absolute file path: %v", path, err) - ctx.Error("Internal Server Error", StatusInternalServerError) - return - } - if hasTrailingSlash { - path += "/" - } + path, ok := normalizeServeFilePath(ctx, path) + if !ok { + return } - - // convert the path to forward slashes regardless the OS in order to set the URI properly - // the handler will convert back to OS path separator before opening the file - path = filepath.ToSlash(path) - ctx.Request.SetRequestURI(path) rootFSHandler(ctx) } +// ServeFileLiteral returns HTTP response containing compressed file contents +// from the given path using literal path semantics. +// +// Reserved URI characters in path such as '%', '?' and '#' are preserved +// instead of being interpreted during internal request URI processing. +// +// HTTP response may contain uncompressed file contents in the following cases: +// +// - Missing 'Accept-Encoding: gzip' request header. +// - No write access to directory containing the file. +// +// Directory contents is returned if path points to directory. +// +// Use ServeFileUncompressed if you don't need serving compressed file contents. +// +// See also RequestCtx.SendFileLiteral, ServeFile. +// +// WARNING: do not pass any user supplied paths to this function! +// WARNING: if path is based on user input users will be able to request +// any file on your filesystem! Use fasthttp.FS with a sane Root instead. +func ServeFileLiteral(ctx *RequestCtx, path string) { + rootFSOnce.Do(func() { + rootFSHandler = rootFS.NewRequestHandler() + }) + + path, ok := normalizeServeFilePath(ctx, path) + if !ok { + return + } + ctx.Request.SetRequestURIBytes(appendQuotedPath(nil, s2b(path))) + rootFSHandler(ctx) +} + var ( rootFSOnce sync.Once rootFS = &FS{ @@ -150,8 +171,34 @@ var ( // // Directory contents is returned if path points to directory. // -// See also ServeFile. +// ServeFS interprets path as a URI path internally. Percent-encoded +// sequences may be decoded, and '?' or '#' may be treated as URI delimiters. +// Use ServeFSLiteral if you need literal path semantics. +// +// See also ServeFile, ServeFSLiteral. func ServeFS(ctx *RequestCtx, filesystem fs.FS, path string) { + serveFS(ctx, filesystem, path, false) +} + +// ServeFSLiteral returns HTTP response containing compressed file contents +// from the given fs.FS's path using literal path semantics. +// +// Reserved URI characters in path such as '%', '?' and '#' are preserved +// instead of being interpreted during internal request URI processing. +// +// HTTP response may contain uncompressed file contents in the following cases: +// +// - Missing 'Accept-Encoding: gzip' request header. +// - No write access to directory containing the file. +// +// Directory contents is returned if path points to directory. +// +// See also ServeFS, ServeFileLiteral. +func ServeFSLiteral(ctx *RequestCtx, filesystem fs.FS, path string) { + serveFS(ctx, filesystem, path, true) +} + +func serveFS(ctx *RequestCtx, filesystem fs.FS, path string, literal bool) { f := &FS{ FS: filesystem, Root: "", @@ -164,10 +211,36 @@ func ServeFS(ctx *RequestCtx, filesystem fs.FS, path string) { } handler := f.NewRequestHandler() - ctx.Request.SetRequestURI(path) + if literal { + ctx.Request.SetRequestURIBytes(appendQuotedPath(nil, s2b(path))) + } else { + ctx.Request.SetRequestURI(path) + } handler(ctx) } +func normalizeServeFilePath(ctx *RequestCtx, path string) (string, bool) { + if path == "" || !filepath.IsAbs(path) { + // extend relative path to absolute path + hasTrailingSlash := path != "" && (path[len(path)-1] == '/' || path[len(path)-1] == '\\') + + var err error + path = filepath.FromSlash(path) + if path, err = filepath.Abs(path); err != nil { + ctx.Logger().Printf("cannot resolve path %q to absolute file path: %v", path, err) + ctx.Error("Internal Server Error", StatusInternalServerError) + return "", false + } + if hasTrailingSlash { + path += "/" + } + } + + // convert the path to forward slashes regardless the OS in order to set the URI properly + // the handler will convert back to OS path separator before opening the file + return filepath.ToSlash(path), true +} + // PathRewriteFunc must return new request path based on arbitrary ctx // info such as ctx.Path(). // diff --git a/fs_fs_test.go b/fs_fs_test.go index 10d97a1..dc7a3b5 100644 --- a/fs_fs_test.go +++ b/fs_fs_test.go @@ -56,6 +56,62 @@ func TestFSServeFileHead(t *testing.T) { } } +func TestServeFSLiteral(t *testing.T) { + t.Parallel() + + testFS := fstest.MapFS{ + "space name.txt": {Data: []byte("space")}, + "hash#name.txt": {Data: []byte("hash")}, + "percent%61name.txt": {Data: []byte("percent")}, + "query?name.txt": {Data: []byte("query")}, + } + + for name, file := range testFS { + t.Run(name, func(t *testing.T) { + var ctx RequestCtx + var req Request + req.SetRequestURI("http://foobar.com/original") + ctx.Init(&req, nil, nil) + + ServeFSLiteral(&ctx, testFS, name) + + resp := readResponseFromCtx(t, &ctx, false) + if resp.StatusCode() != StatusOK { + t.Fatalf("unexpected status code %d. expecting %d", resp.StatusCode(), StatusOK) + } + if !bytes.Equal(resp.Body(), file.Data) { + t.Fatalf("unexpected body %q. expecting %q", resp.Body(), file.Data) + } + }) + } +} + +func TestServeFSSpecialCharsNotLiteral(t *testing.T) { + t.Parallel() + + testFS := fstest.MapFS{ + "hash#name.txt": {Data: []byte("hash")}, + "percent%61name.txt": {Data: []byte("percent")}, + "query?name.txt": {Data: []byte("query")}, + } + + for name := range testFS { + t.Run(name, func(t *testing.T) { + var ctx RequestCtx + var req Request + req.SetRequestURI("http://foobar.com/original") + ctx.Init(&req, nil, nil) + + ServeFS(&ctx, testFS, name) + + resp := readResponseFromCtx(t, &ctx, false) + if resp.StatusCode() == StatusOK { + t.Fatalf("ServeFS should fail for special-character path %q without literal mode, but got 200", name) + } + }) + } +} + func TestFSServeFileCompressed(t *testing.T) { t.Parallel() @@ -667,6 +723,10 @@ func TestDirFSServeFileCompressed(t *testing.T) { } func TestDirFSFSByteRangeConcurrent(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip() + } + t.Parallel() stop := make(chan struct{}) @@ -925,18 +985,24 @@ func TestFSRootEnforcement(t *testing.T) { "public/nested/info": {Data: []byte("nested")}, } - tmpDir := t.TempDir() - if err := os.MkdirAll(filepath.Join(tmpDir, "public"), 0o755); err != nil { - t.Fatalf("cannot create public dir: %v", err) - } - if err := os.MkdirAll(filepath.Join(tmpDir, "secret"), 0o755); err != nil { - t.Fatalf("cannot create secret dir: %v", err) - } - if err := os.WriteFile(filepath.Join(tmpDir, "public", "index.html"), []byte("

Public

"), 0o644); err != nil { - t.Fatalf("cannot create public index: %v", err) - } - if err := os.WriteFile(filepath.Join(tmpDir, "secret", "admin.json"), []byte(`{"admin": true, "key": "s3cret"}`), 0o644); err != nil { - t.Fatalf("cannot create secret admin file: %v", err) + // Skip dirfs subtests on Windows: the FS handler pools open file handles + // (via bigFileReader) which prevents t.TempDir cleanup. + // The mapfs subtests exercise the same root enforcement logic. + var tmpDir string + if runtime.GOOS != "windows" { + tmpDir = t.TempDir() + if err := os.MkdirAll(filepath.Join(tmpDir, "public"), 0o755); err != nil { + t.Fatalf("cannot create public dir: %v", err) + } + if err := os.MkdirAll(filepath.Join(tmpDir, "secret"), 0o755); err != nil { + t.Fatalf("cannot create secret dir: %v", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "public", "index.html"), []byte("

Public

"), 0o644); err != nil { + t.Fatalf("cannot create public index: %v", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "secret", "admin.json"), []byte(`{"admin": true, "key": "s3cret"}`), 0o644); err != nil { + t.Fatalf("cannot create secret admin file: %v", err) + } } type testCase struct { @@ -948,18 +1014,18 @@ func TestFSRootEnforcement(t *testing.T) { cases := make([]testCase, 0, 9) for _, root := range []string{"public", "public/", "./public", "/public"} { - cases = append( - cases, - testCase{ - name: "mapfs/" + root, - root: root, - filesystem: memFS, - }, testCase{ + cases = append(cases, testCase{ + name: "mapfs/" + root, + root: root, + filesystem: memFS, + }) + if tmpDir != "" { + cases = append(cases, testCase{ name: "dirfs/" + root, root: root, filesystem: os.DirFS(tmpDir), - }, - ) + }) + } } cases = append(cases, testCase{ diff --git a/fs_test.go b/fs_test.go index 8c38ced..721481b 100644 --- a/fs_test.go +++ b/fs_test.go @@ -23,6 +23,19 @@ func (t TestLogger) Printf(format string, args ...any) { t.t.Logf(format, args...) } +func readResponseFromCtx(t *testing.T, ctx *RequestCtx, skipBody bool) *Response { + t.Helper() + + resp := &Response{} + resp.SkipBody = skipBody + s := ctx.Response.String() + br := bufio.NewReader(bytes.NewBufferString(s)) + if err := resp.Read(br); err != nil { + t.Fatalf("unexpected error: %v", err) + } + return resp +} + func TestNewVHostPathRewriter(t *testing.T) { t.Parallel() @@ -152,6 +165,89 @@ func TestServeFileHead(t *testing.T) { } } +func TestServeFileLiteral(t *testing.T) { + // Skip on Windows: the global rootFS handler caches open file handles, + // which prevents t.TempDir cleanup. TestServeFSLiteral (using MapFS) + // provides coverage on Windows. + if runtime.GOOS == "windows" { + t.SkipNow() + } + + t.Parallel() + + testCases := []string{ + "space name.txt", + "hash#name.txt", + "percent%61name.txt", + "query?name.txt", + } + + for _, name := range testCases { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, name) + expectedBody := []byte("body:" + name) + if err := os.WriteFile(filePath, expectedBody, 0o644); err != nil { + t.Fatalf("cannot create %q: %v", filePath, err) + } + + var ctx RequestCtx + var req Request + req.SetRequestURI("http://foobar.com/original") + ctx.Init(&req, nil, nil) + + ServeFileLiteral(&ctx, filePath) + + resp := readResponseFromCtx(t, &ctx, false) + if resp.StatusCode() != StatusOK { + t.Fatalf("unexpected status code %d. expecting %d", resp.StatusCode(), StatusOK) + } + if !bytes.Equal(resp.Body(), expectedBody) { + t.Fatalf("unexpected body %q. expecting %q", resp.Body(), expectedBody) + } + }) + } +} + +func TestServeFileSpecialCharsNotLiteral(t *testing.T) { + // Skip on Windows: the global rootFS handler caches open file handles, + // which prevents t.TempDir cleanup. TestServeFSSpecialCharsNotLiteral + // provides coverage on Windows. + if runtime.GOOS == "windows" { + t.SkipNow() + } + + t.Parallel() + + testCases := []string{ + "hash#name.txt", + "percent%61name.txt", + "query?name.txt", + } + + for _, name := range testCases { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, name) + if err := os.WriteFile(filePath, []byte("body"), 0o644); err != nil { + t.Fatalf("cannot create %q: %v", filePath, err) + } + + var ctx RequestCtx + var req Request + req.SetRequestURI("http://foobar.com/original") + ctx.Init(&req, nil, nil) + + ServeFile(&ctx, filePath) + + resp := readResponseFromCtx(t, &ctx, false) + if resp.StatusCode() == StatusOK { + t.Fatalf("ServeFile should fail for special-character path %q without literal mode, but got 200", name) + } + }) + } +} + func TestServeFileSmallNoReadFrom(t *testing.T) { t.Parallel() diff --git a/server.go b/server.go index aa97f24..403e3ea 100644 --- a/server.go +++ b/server.go @@ -1487,7 +1487,11 @@ func (ctx *RequestCtx) ResetBody() { // // SendFile logs all the errors via ctx.Logger. // -// See also ServeFile, FSHandler and FS. +// SendFile interprets path as a URI path internally. Percent-encoded +// sequences may be decoded, and '?' or '#' may be treated as URI delimiters. +// Use SendFileLiteral if you need literal path semantics. +// +// See also ServeFile, SendFileLiteral, FSHandler and FS. // // WARNING: do not pass any user supplied paths to this function! // WARNING: if path is based on user input users will be able to request @@ -1496,6 +1500,22 @@ func (ctx *RequestCtx) SendFile(path string) { ServeFile(ctx, path) } +// SendFileLiteral sends local file contents from the given path as response body +// using literal path semantics. +// +// This is a shortcut to ServeFileLiteral(ctx, path). +// +// SendFileLiteral logs all the errors via ctx.Logger. +// +// See also ServeFileLiteral, SendFile, FSHandler and FS. +// +// WARNING: do not pass any user supplied paths to this function! +// WARNING: if path is based on user input users will be able to request +// any file on your filesystem! Use fasthttp.FS with a sane Root instead. +func (ctx *RequestCtx) SendFileLiteral(path string) { + ServeFileLiteral(ctx, path) +} + // SendFileBytes sends local file contents from the given path as response body. // // This is a shortcut to ServeFileBytes(ctx, path).