Add ServeFileLiteral, ServeFSLiteral and SendFileLiteral (#2163)

ServeFile and ServeFS interpret the path as a URI, so percent-encoded
sequences are decoded and characters like '?' and '#' act as URI
delimiters. This makes it impossible to serve files whose names
contain those characters.

Changing this behavior would be backwards incompatible. So instead the
new ServeFileLiteral, ServeFSLiteral and SendFileLiteral are added.

The new Literal variants percent-encode the path before setting it as
the request URI, preserving every byte of the original filesystem path.

Thanks to @thesmartshadow for reporting this issue.
This commit is contained in:
Erik Dubbelboer
2026-03-23 11:21:36 +09:00
committed by GitHub
parent e2f8a255a0
commit d238e60fed
4 changed files with 300 additions and 45 deletions
+96 -23
View File
@@ -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().
//
+87 -21
View File
@@ -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("<h1>Public</h1>"), 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("<h1>Public</h1>"), 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{
+96
View File
@@ -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()
+21 -1
View File
@@ -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).