Summary

I reported and coordinated CVE-2026-25891, a path traversal in the Go Fiber static middleware. I reported this the week Go Fiber v3 was released, and the maintainers quickly published a patch in 3.1.0.

Severity: High - 8.7 (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N)

A Path Traversal (CWE-22) vulnerability in Fiber allows a remote attacker to bypass the static middleware sanitizer and read arbitrary files on the server file system on Windows. This affects Fiber v3 through version 3.0.0. This has been patched in Fiber v3 version 3.1.0.


Technical Analysis

Fiber is an Express-inspired web framework for Golang used to build HTTP APIs and web applications. It provides an Express-like developer experience (routing, handler functions, middleware chaining) while focusing heavily on high performance.

Double encoding is an evasion technique where input data is encoded twice, typically using URL encoding to disguise special characters. For example, the character < becomes %3C in standard URL encoding, but encoding the percent sign again becomes %253C. A filter intending to block < might decode this input once, see the string %3C, and let it pass. However, any logic that performs a second round of decoding that reverts the input back to the original < symbol bypasses filters.

Sanitization Bypass

CVE-2026-25891 resides in the sanitizePath function of Fiber’s Static middleware used to handle static web resources. This function attempts to sanitize the requested path by checking for backslashes, decoding the URL, and then cleaning the path.

  • The normalization of backslash characters happens before the URL decoding loop. If an attacker sends a double-encoded backslash, the initial check sees %255C and passes.
  • A loop then decodes multiple encoding layers into a single backslash. The loop itself is a dangerous implementation pattern that commonly leads to encoding-related vulnerabilities.
  • A final check uses Golang’s path.Clean, which canonicalize the URL path, but doesn’t handle filepaths like those that contain backslashes on Windows. On Linux, a backslash is just a valid character in a filename, so a backslash traversal sequence is treated as a file named \..\ inside the current directory. However, the Windows API treats both / and \ as directory separators. This mismatch, where the application logic ignores backslashes but the underlying OS respects them, enables the path traversal.

The snippet below demonstrates the vulnerable sanitization method.

// pkg/static/static.go
func sanitizePath(p []byte, filesystem fs.FS) ([]byte, error) {
    ...
    // this normalization happens before decoding
    if bytes.IndexByte(p, '\\') >= 0 {
        ...
    } 
    // This loop decodes %255C to %5C to \
    for strings.IndexByte(s, '%') >= 0 {
        us, err := url.PathUnescape(s)
        ...
        s = us
    }
    // path.Clean canonicalizes paths with forward slashes
    s = pathpkg.Clean("/" + s)
    ...
    return utils.UnsafeBytes(s), nil
}

The method pathpkg.Clean, Golang’s path.Clean, canonicalizes the path, preventing directory traversal using forward slashes. However this native method is not intended for file path sanitization. Below is the source code from Golang demonstrating how forward slash traversals are cleaned but directory traversal on Windows is not prevented, because it’s designed for lexical URL path canonicalization, not filepath sanitization.

// go/src/path/path.go
...
// Clean returns the shortest path name equivalent to path
// by purely lexical processing. It applies the following rules
// iteratively until no further processing can be done:
//
//  1. Replace multiple slashes with a single slash.
//  2. Eliminate each . path name element (the current directory).
//  3. Eliminate each inner .. path name element (the parent directory)
//     along with the non-.. element that precedes it.
//  4. Eliminate .. elements that begin a rooted path:
//     that is, replace "/.." by "/" at the beginning of a path.
//
// The returned path ends in a slash only if it is the root "/".
//
// If the result of this process is an empty string, Clean
// returns the string ".".
//
// See also Rob Pike, “Lexical File Names in Plan 9 or
// Getting Dot-Dot Right,”
// https://9p.io/sys/doc/lexnames.html
func Clean(path string) string {
	if path == "" {
		return "."
	}

	rooted := path[0] == '/'
	n := len(path)

	// Invariants:
	//	reading from path; r is index of next byte to process.
	//	writing to buf; w is index of next byte to write.
	//	dotdot is index in buf where .. must stop, either because
	//		it is the leading slash or it is a leading ../../.. prefix.
	out := lazybuf{s: path}
	r, dotdot := 0, 0
	if rooted {
		out.append('/')
		r, dotdot = 1, 1
	}

	for r < n {
		switch {
		case path[r] == '/':
			// empty path element
			r++
		case path[r] == '.' && (r+1 == n || path[r+1] == '/'):
			// . element
			r++
		case path[r] == '.' && path[r+1] == '.' && (r+2 == n || path[r+2] == '/'):
			// .. element: remove to last /
			r += 2
			switch {
			case out.w > dotdot:
				// can backtrack
				out.w--
				for out.w > dotdot && out.index(out.w) != '/' {
					out.w--
				}
			case !rooted:
				// cannot backtrack, but not rooted, so append .. element.
				if out.w > 0 {
					out.append('/')
				}
				out.append('.')
				out.append('.')
				dotdot = out.w
			}
		default:
			// real path element.
			// add slash if needed
			if rooted && out.w != 1 || !rooted && out.w != 0 {
				out.append('/')
			}
			// copy element
			for ; r < n && path[r] != '/'; r++ {
				out.append(path[r])
			}
		}
	}

	// Turn empty string into "."
	if out.w == 0 {
		return "."
	}

	return out.string()
}

Middleware Chaining and fasthttp Internals

In Fiber, middleware is defined as a function chained within an HTTP request cycle that executes sequentially, allowing it to optionally perform specific actions before passing control to the next handler. This architecture allows functions to modify the request, terminate the cycle early, or hand off execution to subsequent logic in the middleware stack.

In Fiber v3, middleware and handlers run in the request chain for any route they match. The static middleware is often mounted broadly with catch-alls, so it may process attacker-controlled paths on many requests.

import (
  "github.com/gofiber/fiber/v3"
  "github.com/gofiber/fiber/v3/middleware/static"
)

app := fiber.New()

// prefix middleware mount
app.Use("/", static.New("./static"))

Fiber delegates the heavy lifting of file serving to the fasthttp library. When the static middleware is initialized, Fiber injects custom PathRewrite logic, containing the vulnerable sanitizePath function, into the fasthttp filesystem handler. fasthttp always ends by calling Open on an fs.FS, the Go standard-library filesystem interface. Either that fs.FS is provided by the application, or fasthttp falls back to an native OS-backed implementation that calls os.Open. The security boundary is whatever the fs.FS implementation enforces. The bypass relies on Windows path semantics and becomes exploitable when the configured fs.FS ultimately uses Windows OS path parsing.

// pkg/static/static.go
fileServer := &fasthttp.FS{
  Root:                   root,
  FS:                     config.FS,
  AllowEmptyRoot:         true,
  GenerateIndexPages:     config.Browse,
  AcceptByteRange:        config.ByteRange,
  Compress:               config.Compress,
  CompressBrotli:         config.Compress,
  CompressZstd:           config.Compress,
  CompressedFileSuffixes: c.App().Config().CompressedFileSuffixes,
  CacheDuration:          config.CacheDuration,
  SkipCache:              config.CacheDuration < 0,
  IndexNames:             config.IndexNames,
  PathNotFound: func(fctx *fasthttp.RequestCtx) {
    fctx.Response.SetStatusCode(fiber.StatusNotFound)
  },
}
fileServer.PathRewrite = func(fctx *fasthttp.RequestCtx) []byte {
  path := fctx.Path()

  if len(path) >= prefixLen {
    checkFile, err := isFile(root, fileServer.FS)
    if err != nil {
      return path
    }
  ...
  sanitized, err := sanitizePath(path, fileServer.FS)
  ...
  return sanitized
}
...
fileHandler = fileServer.NewRequestHandler()
...
fileHandler(c.RequestCtx())

When a request matches the Static middleware, it derives a file path from the request URL path and then sanitizes it before attempting to open a file. It serves files from a fasthttp directory root like ./static using an fs.FS backend, such as os.DirFS, embed.FS, or the default osFS.

CVE-2026-25891 allows an attacker to break out of the configured directory or FS root, though exploitability depends on the filesystem used.

os.DirFS

os.DirFS returns an fs.FS backed by the host operating system rooted at the directory a developer passes in, so files are read from disk at runtime. Despite the backing store being the real OS filesystem, os.DirFS validates names in dirFS.join by calling filepathlite.Localize, which strictly rejects any path containing backslashes before they ever reach the OS. If a developer uses os.DirFS with a current underlying Go version, this exploit fails.

app.Get("/files*", static.New("", static.Config{
  FS: os.DirFS("files"),
  Browse: true,
}))

In Go’s os package, dirFS.join calls filepathlite.Localize and errors out if it can’t be represented.

// go/src/internal/filepathlite/path.go
...
// Localize is filepath.Localize.
func Localize(path string) (string, error) {
	if !fs.ValidPath(path) {
		return "", errInvalidPath
	}
	return localize(path)
}

Golang explicitly documents that filepath.Localize rejects backslashes on Windows.

// go/src/path/filepath/path.go
...
// Localize converts a slash-separated path into an operating system path.
// The input path must be a valid path as reported by [io/fs.ValidPath].
//
// Localize returns an error if the path cannot be represented by the operating system.
// For example, the path a\b is rejected on Windows, on which \ is a separator
// character and cannot be part of a filename.
//
// The path returned by Localize will always be local, as reported by IsLocal.
func Localize(path string) (string, error) {
	return filepathlite.Localize(path)
}

The localize method in filepathlite performs this check.

// go/src/internal/filepathlite/path_windows.go
func localize(path string) (string, error) {
    for i := 0; i < len(path); i++ {
        switch path[i] {
        case ':', '\\', 0:
            return "", errInvalidPath
        }
    }
    ...
}

embed.FS

Unlike os.DirFS, embed.FS never converts to an OS path at all. Open is a lookup into a compiler-generated, in-memory file table. embed.FS is a read-only, “virtual” filesystem whose contents are compiled into the binary at build time using a //go:embed directive. If declared without any directive, it’s an empty filesystem. A path traversal is not possible here either, as the filesystem isn’t OS-backed.

//go:embed path/to/files
var myfiles embed.FS
app.Get("/files*", static.New("", static.Config{
  FS: myfiles,
  Browse: true,
}))

At runtime, embed.FS.Open() just calls lookup() and returns either a directory handle (openDir) or file handle (openFile).

// go/src/embed/embed.go
func (f FS) Open(name string) (fs.File, error) {
	file := f.lookup(name)
	if file == nil {
		return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
	}
	if file.IsDir() {
		return &openDir{file, f.readDir(name), 0}, nil
	}
	return &openFile{file, 0}, nil
}

lookup(name) is a table lookup with a validity check.

// go/src/embed/embed.go
...
// lookup returns the named file, or nil if it is not present.
func (f FS) lookup(name string) *file {
	if !fs.ValidPath(name) {
		// The compiler should never emit a file with an invalid name,
		// so this check is not strictly necessary (if name is invalid,
		// we shouldn't find a match below), but it's a good backstop anyway.
		return nil
	}
	if name == "." {
		return dotFile
	}
	if f.files == nil {
		return nil
	}

	// Binary search to find where name would be in the list,
	// and then check if name is at that position.
  ...

fs.ValidPath explicitly allows backslash characters, so backslash traversal sequences won’t get separated or invalidated. However, the requests will never match any embedded filename, and there’s no structure to traverse. This function is also leveraged in os.DirFS, but filepath.Localize kills the exploit.

// ValidPath reports whether the given path name
// is valid for use in a call to Open.
//
// Note that paths are slash-separated on all systems, even Windows.
// Paths containing other characters such as backslash and colon
// are accepted as valid, but those characters must never be
// interpreted by an [FS] implementation as path element separators.
// See the [Path Names] section for more details.
//
// [Path Names]: https://pkg.go.dev/io/fs#hdr-Path_Names
func ValidPath(name string) bool {
	if !utf8.ValidString(name) {
		return false
	}

	if name == "." {
		// special case
		return true
	}

	// Iterate over elements in name, checking each.
	for {
		i := 0
		for i < len(name) && name[i] != '/' {
			i++
		}
		elem := name[:i]
		if elem == "" || elem == "." || elem == ".." {
			return false
		}
		if i == len(name) {
			return true // reached clean ending
		}
		name = name[i+1:]
	}
}

fasthttp osFS

If no custom filesystem is provided in static.Config.FS, fasthttp defaults to its internal OS-backed osFS, then runs the request path through Fiber’s PathRewrite with a subsequent check that only blocks the literal substring /../. It then joins the configured root with the rewritten path and opens the resulting OS path via osFS.Open to os.Open. On Windows, this ultimately reaches the CreateFile API.

// fasthttp/fs.go
...
// Defaults to osFS, providing os.Open
if h.filesystem == nil {
  h.filesystem = &osFS{}
}
...
func (h *fsHandler) handleRequest(ctx *RequestCtx) {
    var path []byte
    
    // Fiber's PathRewrite is called here
    path = h.pathRewrite(ctx) 
    ...
    // fasthttp's substring security check only matches forward slashes
    if h.pathRewrite != nil {
        if n := bytes.Index(path, strSlashDotDotSlash); n >= 0 {
            ctx.Error("Internal Server Error", StatusInternalServerError)
            return
        }
    }
    // Joins the root with the path
    filePath := h.pathToFilePath(path, hasTrailingSlash)

    ff, err = h.openFSFile(filePath, mustCompress, fileEncoding)
    ...
}
...
func (h *fsHandler) openFSFile(filePath string, ...) (*fsFile, error) {
    f, err := h.filesystem.Open(filePath) 
    ...
}
...
// os.Open is a wrapper for the Windows CreateFile API
func (o *osFS) Open(name string) (fs.File, error) { 
    return os.Open(name) 
}

The following server uses the Fiber v3 default fasthttp handler.

$ curl "http://localhost:3030/static/%255C..%255C..%255C..%255C..%255C..%255C..%255C..%255C..%255CWindows%255Cwin.ini"              
; for 16-bit app support
[fonts]
[extensions]
[mci extensions]
[files]
[Mail]
MAPI=1
[ResponseResult]
ResultCode=0

Impact

This impacts Fiber v3 prereleases through stable release version 3.0.0. At the time of writing, Fiber v3 has only been stable for about a week. While prerelease versions were under development for about 3 years, real-world exposure is likely limited.

Successful exploitation requires the server to be using the static middleware with the default fasthttp osFS on Windows. Since most webservers run on Linux, real-world exposure is further limited by this fact.

Exploitation allows directory traversal on the host server. An attacker can read arbitrary files within the scope of the application server context. Depending on permissions and deployment conditions, attackers may access sensitive files outside the web root, such as configuration files, source code, or system files. Leaking application secrets often leads to further compromise.


Patches

This has been patched in Fiber v3 version 3.1.0 by introducing a new sanitization layer. It explicitly checks for and rejects backslashes after the recursive URL decoding loop completes. Additionally, if no custom filesystem is provided (the vulnerable fasthttp fallback), it now strictly validates the fully decoded string by manually rejecting directory traversal elements, UNC paths, and Windows drive letters before they reach the underlying OS.

func sanitizePath(p []byte, filesystem fs.FS) ([]byte, error) {
	var s string

	hasTrailingSlash := len(p) > 0 && p[len(p)-1] == '/'

	if bytes.IndexByte(p, '\\') >= 0 {
		b := make([]byte, len(p))
		copy(b, p)
		for i := range b {
			if b[i] == '\\' {
				b[i] = '/'
			}
		}
		s = utils.UnsafeString(b)
	} else {
		s = utils.UnsafeString(p)
	}

	// repeatedly unescape until it no longer changes, catching errors
	for strings.IndexByte(s, '%') >= 0 {
		us, err := url.PathUnescape(s)
		if err != nil {
			return nil, ErrInvalidPath
		}
		if us == s {
			break
		}
		s = us
	}

	if strings.IndexByte(s, '\\') >= 0 {
		return nil, ErrInvalidPath
	}

	// reject any null bytes
	if strings.IndexByte(s, '\x00') >= 0 {
		return nil, ErrInvalidPath
	}

	normalized := filepath.ToSlash(s)
	if filesystem == nil && strings.HasPrefix(normalized, "//") {
		return nil, ErrInvalidPath
	}

	s = pathpkg.Clean("/" + normalized)

	trimmed := utils.TrimLeft(s, '/')
	if trimmed != "" {
		if slices.Contains(strings.Split(trimmed, "/"), "..") {
			return nil, ErrInvalidPath
		}
	}

	if filesystem == nil {
		normalizedClean := filepath.ToSlash(trimmed)
		if strings.HasPrefix(normalizedClean, "//") {
			return nil, ErrInvalidPath
		}
		if volume := filepath.VolumeName(normalizedClean); volume != "" {
			return nil, ErrInvalidPath
		}
		if len(normalizedClean) >= 2 && normalizedClean[1] == ':' {
			drive := normalizedClean[0]
			if (drive >= 'a' && drive <= 'z') || (drive >= 'A' && drive <= 'Z') {
				return nil, ErrInvalidPath
			}
		}
		if strings.HasPrefix(filepath.ToSlash(s), "//") {
			return nil, ErrInvalidPath
		}
	}

	if filesystem != nil {
		s = trimmed
		if s == "" {
			return []byte("/"), nil
		}
		if !fs.ValidPath(s) {
			return nil, ErrInvalidPath
		}
		s = "/" + s
	}

	if hasTrailingSlash && len(s) > 1 && s[len(s)-1] != '/' {
		s += "/"
	}

	return utils.UnsafeBytes(s), nil
}

Timeline

DateEvent
2026-01-01Initial Report via GitHub Private Vulnerability Report
2026-01-01Maintainer Acknowledgement
2026-01-02GitHub Staff Assigns CVE-2026-25891
2026-02-24Patch Released in 3.1.0
2026-02-24Maintainer Releases GHSA-gvq6-hvvp-h34h
2026-02-24Published to GitHub Advisory Database
2026-02-24Published to National Vulnerability Database
2026-02-24Public Exploit Published by Third Party
2026-02-25Published to websmite.com