CVE-2026-30246
Description
Fiber is a web framework for Go. In github.com/gofiber/fiber/v3 versions through 3.1.0, the default key generator in the cache middleware uses only the request path and does not include the query string. As a result, requests for the same path with different query parameters can share a cache key and receive the wrong cached response. This can cause response mix-up for query-dependent endpoints and may expose data intended for a different request. This issue is fixed after version 3.1.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/gofiber/fiber/v3Go | < 3.2.0 | 3.2.0 |
Affected products
1Patches
2050ff1ff1851refactor: improve cache key generation by escaping key delimiters and normalizing method names
3 files changed · +12 −9
middleware/cache/cache.go+6 −6 modified@@ -1332,12 +1332,12 @@ func canonicalQueryString(uri *fasthttp.URI) string { // Pre-scan query string to detect excessive parameters before expensive parsing. // This prevents DoS via url.ParseQuery allocating large maps/slices. if len(query) > maxQueryBufferSize { - return boundKeySegment(query) + return boundKeySegment(escapeKeyDelimiters(query)) } // Fast path: single key=value pair needs no parsing or sorting if strings.IndexByte(query, '&') < 0 { - return boundKeySegment(query) + return boundKeySegment(escapeKeyDelimiters(query)) } // Quick count of potential parameters (ampersands + 1) @@ -1347,22 +1347,22 @@ func canonicalQueryString(uri *fasthttp.URI) string { paramCount++ if paramCount > maxQueryParams { // Too many parameters detected, hash without parsing - return boundKeySegment(query) + return boundKeySegment(escapeKeyDelimiters(query)) } } } parsed, err := url.ParseQuery(query) if err != nil { - return boundKeySegment(query) + return boundKeySegment(escapeKeyDelimiters(query)) } // Double-check actual parameter count after parsing actualCount := 0 for _, values := range parsed { actualCount += len(values) if actualCount > maxQueryParams { - return boundKeySegment(query) + return boundKeySegment(escapeKeyDelimiters(query)) } } @@ -1399,7 +1399,7 @@ func canonicalQueryString(uri *fasthttp.URI) string { *bufPtr = buf keyBufferPool.Put(bufPtr) } - return boundKeySegment(query) + return boundKeySegment(escapeKeyDelimiters(query)) } buf = append(buf, escapedKey...)
middleware/cache/cache_test.go+1 −1 modified@@ -5451,7 +5451,7 @@ func Test_hasDirective(t *testing.T) { {"at start", "no-cache, max-age=0", "no-cache", true}, {"at end", "public, no-cache", "no-cache", true}, {"not present", "public, max-age=0", "no-cache", false}, - {"partial match (truncated)", "no-cach", "no-cache", false}, // cspell:disable-line -- intentionally truncated directive + {"shorter token does not match", "no-catch", "no-cache", false}, {"substring of longer token", "no-cache-extended", "no-cache", false}, // Trailing whitespace (#4143)
middleware/cache/config.go+5 −2 modified@@ -169,10 +169,13 @@ func configDefault(config ...Config) Config { cfg.Methods = ConfigDefault.Methods } else { // Normalize method names to uppercase (HTTP methods are case-sensitive - // and c.Method() returns uppercase, e.g. "GET" not "get") + // and c.Method() returns uppercase, e.g. "GET" not "get"). + // Copy first to avoid mutating the caller's slice. + normalized := make([]string, len(cfg.Methods)) for i, m := range cfg.Methods { - cfg.Methods[i] = utilsstrings.ToUpper(m) + normalized[i] = utilsstrings.ToUpper(m) } + cfg.Methods = normalized } if cfg.KeyGenerator == nil { cfg.KeyGenerator = func(c fiber.Ctx) string {
9a0d12c07ed8bug: harden cache middleware key generation and restore Methods config
6 files changed · +240 −24
docs/middleware/cache.md+3 −1 modified@@ -120,7 +120,7 @@ This prevents common collisions from path-only keys (for example, `/?id=1` vs `/ The middleware **does not include request body/form values in the default cache key**. -Cache lookup/storage is only applied for `GET` and `HEAD` requests. Other HTTP methods always bypass the cache middleware. +Cache lookup/storage is applied only for `GET` and `HEAD` requests by default. Other HTTP methods bypass the cache middleware. You can change this via the `Methods` config field. If a response sets `Vary`, request lookup/storage is also partitioned by those header values unless `DisableVaryHeaders` is `true`. Responses with `Vary: *` remain uncacheable. @@ -138,6 +138,7 @@ If a response sets `Vary`, request lookup/storage is also partitioned by those h | DisableQueryKeys | `bool` | Disables canonicalized query params in keys. | `false` | | KeyHeaders | `[]string` | Header allow-list used for key partitioning. Names are normalized case-insensitively and sorted. Use `[]string{}` to disable header-based partitioning. | `[]string{"accept","accept-encoding","accept-language"}` | | KeyCookies | `[]string` | Optional cookie allow-list for key partitioning. Explicit opt-in only; names remain case-sensitive. | `nil` | +| Methods | `[]string` | HTTP methods eligible for caching. Requests whose method is not in this list bypass the cache. | `[]string{fiber.MethodGet, fiber.MethodHead}` | | DisableVaryHeaders | `bool` | Disables response `Vary` dimensions in cache lookup/storage partitioning. | `false` | | ExpirationGenerator | `func(fiber.Ctx, *cache.Config) time.Duration` | ExpirationGenerator allows you to generate custom expiration keys based on the request. | `nil` | | Storage | `fiber.Storage` | Storage is used to store the state of the middleware. | In-memory store | @@ -162,6 +163,7 @@ var ConfigDefault = Config{ fiber.HeaderAcceptLanguage, }, KeyCookies: nil, + Methods: []string{fiber.MethodGet, fiber.MethodHead}, DisableVaryHeaders: false, ExpirationGenerator: nil, StoreResponseHeaders: false,
docs/whats_new.md+2 −1 modified@@ -1369,7 +1369,7 @@ Cache keys are now redacted in logs and error messages by default, and a `Disabl The default cache key strategy was also hardened. Instead of path-only behavior, keys now use structured request dimensions: method partitioning, path, canonical query string, and selected representation headers (`Accept`, `Accept-Encoding`, `Accept-Language`). This avoids collisions such as `/items?id=1` vs `/items?id=2` while keeping key generation deterministic. New config fields were added for explicit control: `DisableQueryKeys`, `KeyHeaders`, `KeyCookies`, and `DisableVaryHeaders`. -As a security/performance default, request body/form values are not part of the default cache key. Cache handling is limited to `GET` and `HEAD` requests. +As a security/performance default, request body/form values are not part of the default cache key. Cache handling is limited to `GET` and `HEAD` requests by default, configurable via the `Methods` field. :::note The deprecated `Store` and `Key` options have been removed in v3. Use `Storage` and `KeyGenerator` instead. @@ -2890,6 +2890,7 @@ To restore v2 behavior: Additional v3 cache key options: +- `Methods`: HTTP methods eligible for caching (default `GET`, `HEAD`) - `DisableQueryKeys`: disable canonicalized query args in keys (default `false`) - `KeyHeaders`: request header allow-list for key partitioning - `KeyCookies`: explicit cookie allow-list for key partitioning
middleware/cache/cache.go+47 −22 modified@@ -10,6 +10,7 @@ import ( "fmt" "math" "net/url" + "slices" "sort" "strings" "sync" @@ -253,8 +254,8 @@ func New(config ...Config) fiber.Handler { requestMethod := c.Method() - // Cache only GET and HEAD requests. - if requestMethod != fiber.MethodGet && requestMethod != fiber.MethodHead { + // Only cache methods listed in cfg.Methods (default: GET, HEAD). + if !slices.Contains(cfg.Methods, requestMethod) { c.Set(cfg.CacheHeader, cacheUnreachable) return c.Next() } @@ -1284,7 +1285,8 @@ func defaultKeyGenerator(c fiber.Ctx, cfg *Config) string { } buf := (*bufPtr)[:0] - buf = append(buf, boundKeySegment(c.Path())...) + // Escape delimiters in path to prevent crafted paths from injecting key structure + buf = append(buf, boundKeySegment(escapeKeyDelimiters(c.Path()))...) if !cfg.DisableQueryKeys { buf = append(buf, '|', 'q', '=') @@ -1301,7 +1303,7 @@ func defaultKeyGenerator(c fiber.Ctx, cfg *Config) string { buf = append(buf, canonicalCookieSubset(c, cfg.KeyCookies)...) } - result := utils.CopyString(utils.UnsafeString(buf)) + result := string(buf) // Reset buffer and return to pool, but discard if it grew too large // to prevent pool from retaining oversized buffers @@ -1314,17 +1316,24 @@ func defaultKeyGenerator(c fiber.Ctx, cfg *Config) string { } func canonicalQueryString(uri *fasthttp.URI) string { - query := utils.CopyString(utils.UnsafeString(uri.QueryString())) - if query == "" { + raw := uri.QueryString() + if len(raw) == 0 { return "" } - // Pre-scan query string to detect excessive parameters before expensive parsing - // This prevents DoS via url.ParseQuery allocating large maps/slices + query := utils.CopyString(utils.UnsafeString(raw)) + + // Pre-scan query string to detect excessive parameters before expensive parsing. + // This prevents DoS via url.ParseQuery allocating large maps/slices. if len(query) > maxQueryBufferSize { return boundKeySegment(query) } + // Fast path: single key=value pair needs no parsing or sorting + if strings.IndexByte(query, '&') < 0 { + return boundKeySegment(query) + } + // Quick count of potential parameters (ampersands + 1) paramCount := 1 for i := 0; i < len(query); i++ { @@ -1357,10 +1366,15 @@ func canonicalQueryString(uri *fasthttp.URI) string { } sort.Strings(keys) - // Use a bounded buffer to prevent excessive memory allocation during URL escaping - // URL escaping can expand strings up to 3x (each byte -> %XX) - initialCap := min(len(query)*2, maxQueryBufferSize/2) - buf := make([]byte, 0, initialCap) + // Use pooled buffer to prevent excessive memory allocation during URL escaping. + // URL escaping can expand strings up to 3x (each byte -> %XX). + v := keyBufferPool.Get() + bufPtr, ok := v.(*[]byte) + if !ok || bufPtr == nil { + b := make([]byte, 0, defaultKeyBufferCap) + bufPtr = &b + } + buf := (*bufPtr)[:0] for _, key := range keys { values := parsed[key] @@ -1370,12 +1384,15 @@ func canonicalQueryString(uri *fasthttp.URI) string { buf = append(buf, '&') } - // Check buffer size before appending to prevent unbounded growth escapedKey := url.QueryEscape(key) escapedValue := url.QueryEscape(value) - // If buffer would exceed safe limits, hash the entire query + // Check buffer size before appending to prevent unbounded growth if len(buf)+len(escapedKey)+len(escapedValue)+2 > maxQueryBufferSize { + if cap(buf) <= defaultKeyBufferCap*4 { + *bufPtr = buf + keyBufferPool.Put(bufPtr) + } return boundKeySegment(query) } @@ -1385,7 +1402,15 @@ func canonicalQueryString(uri *fasthttp.URI) string { } } - return boundKeySegment(utils.CopyString(utils.UnsafeString(buf))) + result := boundKeySegment(string(buf)) + + // Return buffer to pool if not oversized + if cap(buf) <= defaultKeyBufferCap*4 { + *bufPtr = buf + keyBufferPool.Put(bufPtr) + } + + return result } func canonicalHeaderSubset(header *fasthttp.RequestHeader, names []string) string { @@ -1404,10 +1429,10 @@ func canonicalHeaderSubset(header *fasthttp.RequestHeader, names []string) strin headerValue := header.Peek(name) // Escape value to prevent delimiter injection escapedValue := escapeKeyDelimiters(utils.UnsafeString(headerValue)) - buf = append(buf, utils.UnsafeBytes(boundKeySegment(escapedValue))...) + buf = append(buf, boundKeySegment(escapedValue)...) } - return utils.CopyString(utils.UnsafeString(buf)) + return string(buf) } func canonicalCookieSubset(c fiber.Ctx, names []string) string { @@ -1426,17 +1451,17 @@ func canonicalCookieSubset(c fiber.Ctx, names []string) string { cookieValue := c.Cookies(name) // Escape value to prevent delimiter injection escapedValue := escapeKeyDelimiters(cookieValue) - buf = append(buf, utils.UnsafeBytes(boundKeySegment(escapedValue))...) + buf = append(buf, boundKeySegment(escapedValue)...) } - return utils.CopyString(utils.UnsafeString(buf)) + return string(buf) } -// escapeKeyDelimiters escapes pipe and colon characters used as delimiters in cache keys +// escapeKeyDelimiters escapes pipe, colon, and backslash characters used as delimiters in cache keys // to prevent injection attacks where crafted values could collide with different inputs func escapeKeyDelimiters(s string) string { - // Fast path: no delimiters to escape - if !strings.ContainsAny(s, "|:") { + // Fast path: no characters to escape + if !strings.ContainsAny(s, "|:\\") { return s }
middleware/cache/cache_security_test.go+56 −0 modified@@ -539,3 +539,59 @@ func Test_Cache_Security_DelimiterCollisionPrevention(t *testing.T) { seen[resp] = true } } + +// Test_Cache_Security_EscapeKeyDelimiters_Unit is a direct regression test for the +// escapeKeyDelimiters function, ensuring backslashes are escaped to prevent collisions +// between e.g. a literal "a\pb" and the escaped form of "a|b" → "a\pb". +func Test_Cache_Security_EscapeKeyDelimiters_Unit(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + expected string + }{ + // Fast path: no special characters + {"hello", "hello"}, + {"", ""}, + {"foo/bar?baz=1", "foo/bar?baz=1"}, + // Pipe escaping + {"a|b", "a\\pb"}, + // Colon escaping + {"a:b", "a\\cb"}, + // Backslash escaping (regression: fast path must also check for \) + {"a\\b", "a\\\\b"}, + // Backslash-pipe sequence must not collide with escaped pipe + {"a\\pb", "a\\\\pb"}, // literal \p → \\p (differs from escaped | → \p) + {"a\\cb", "a\\\\cb"}, // literal \c → \\c (differs from escaped : → \c) + // Mixed delimiters + {"k|v:w\\x", "k\\pv\\cw\\\\x"}, + // Multiple consecutive + {"||", "\\p\\p"}, + {"::", "\\c\\c"}, + {"\\\\", "\\\\\\\\"}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("escape_%q", tt.input), func(t *testing.T) { + t.Parallel() + result := escapeKeyDelimiters(tt.input) + require.Equal(t, tt.expected, result, "escapeKeyDelimiters(%q)", tt.input) + }) + } + + // Verify no collisions between pairs that would collide without backslash escaping + collisionPairs := [][2]string{ + {"a\\pb", "a|b"}, // literal \p vs escaped | + {"a\\cb", "a:b"}, // literal \c vs escaped : + {"\\\\", "\\"}, // double backslash vs single + {"x\\py", "x|y"}, + } + for _, pair := range collisionPairs { + t.Run(fmt.Sprintf("no_collision_%q_vs_%q", pair[0], pair[1]), func(t *testing.T) { + t.Parallel() + a := escapeKeyDelimiters(pair[0]) + b := escapeKeyDelimiters(pair[1]) + require.NotEqual(t, a, b, "escapeKeyDelimiters(%q) must differ from escapeKeyDelimiters(%q)", pair[0], pair[1]) + }) + } +}
middleware/cache/cache_test.go+112 −0 modified@@ -964,6 +964,118 @@ func Test_Cache_Post(t *testing.T) { require.Equal(t, "3:12345", string(body)) } +func Test_Cache_CustomMethods(t *testing.T) { + t.Parallel() + + t.Run("POST cached when in Methods", func(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New(Config{ + Methods: []string{fiber.MethodGet, fiber.MethodHead, fiber.MethodPost}, + })) + + var count atomic.Int32 + app.Post("/", func(c fiber.Ctx) error { + current := count.Add(1) + return c.SendString(strconv.Itoa(int(current))) + }) + + // First POST — cache miss + resp, err := app.Test(httptest.NewRequest(fiber.MethodPost, "/", http.NoBody)) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, cacheMiss, resp.Header.Get("X-Cache")) + require.Equal(t, "1", string(body)) + + // Second POST — cache hit + resp, err = app.Test(httptest.NewRequest(fiber.MethodPost, "/", http.NoBody)) + require.NoError(t, err) + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, cacheHit, resp.Header.Get("X-Cache")) + require.Equal(t, "1", string(body)) + }) + + t.Run("unconfigured method bypasses cache", func(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New(Config{ + Methods: []string{fiber.MethodGet}, + })) + + var count atomic.Int32 + app.Put("/", func(c fiber.Ctx) error { + current := count.Add(1) + return c.SendString(strconv.Itoa(int(current))) + }) + + // PUT not in Methods — always bypasses cache + resp, err := app.Test(httptest.NewRequest(fiber.MethodPut, "/", http.NoBody)) + require.NoError(t, err) + require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache")) + require.Equal(t, int32(1), count.Load()) + + resp, err = app.Test(httptest.NewRequest(fiber.MethodPut, "/", http.NoBody)) + require.NoError(t, err) + require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache")) + require.Equal(t, int32(2), count.Load(), "handler must be called on every bypass") + }) + + t.Run("empty Methods slice disables caching", func(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New(Config{ + Methods: []string{}, + })) + + var count atomic.Int32 + app.Get("/", func(c fiber.Ctx) error { + current := count.Add(1) + return c.SendString(strconv.Itoa(int(current))) + }) + + // Even GET bypasses cache when Methods is explicitly empty + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)) + require.NoError(t, err) + require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache")) + + resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)) + require.NoError(t, err) + require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache")) + require.Equal(t, int32(2), count.Load(), "handler must be called each time with empty Methods") + }) + + t.Run("lowercase method names are normalized", func(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New(Config{ + Methods: []string{"get", "post"}, + })) + + var count atomic.Int32 + app.Get("/", func(c fiber.Ctx) error { + current := count.Add(1) + return c.SendString(strconv.Itoa(int(current))) + }) + + // "get" should be normalized to "GET" and match + resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, cacheMiss, resp.Header.Get("X-Cache")) + require.Equal(t, "1", string(body)) + + resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)) + require.NoError(t, err) + body, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, cacheHit, resp.Header.Get("X-Cache")) + require.Equal(t, "1", string(body)) + }) +} + func Test_Cache_DefaultKeyDimensions(t *testing.T) { t.Parallel()
middleware/cache/config.go+20 −0 modified@@ -2,6 +2,7 @@ package cache import ( "sort" + "strings" "time" "github.com/gofiber/fiber/v3" @@ -67,6 +68,14 @@ type Config struct { // Optional. Default: nil KeyCookies []string + // Methods specifies which HTTP methods are eligible for caching. + // Requests with methods not in this list bypass the cache entirely. + // Method names are normalized to uppercase automatically. + // Set to nil to use the default; set to an empty slice to disable caching for all methods. + // + // Default: []string{fiber.MethodGet, fiber.MethodHead} + Methods []string + // Expiration is the time that a cached response will live // // Optional. Default: 5 * time.Minute @@ -118,6 +127,7 @@ var ConfigDefault = Config{ DisableQueryKeys: false, KeyHeaders: []string{fiber.HeaderAccept, fiber.HeaderAcceptEncoding, fiber.HeaderAcceptLanguage}, KeyCookies: nil, + Methods: []string{fiber.MethodGet, fiber.MethodHead}, DisableVaryHeaders: false, ExpirationGenerator: nil, StoreResponseHeaders: false, @@ -155,6 +165,16 @@ func configDefault(config ...Config) Config { } cfg.KeyHeaders = normalizeHeaderDimensions(cfg.KeyHeaders, ConfigDefault.KeyHeaders) cfg.KeyCookies = normalizeCookieDimensions(cfg.KeyCookies, nil) + // nil = use default methods; explicit empty slice = cache no methods + if cfg.Methods == nil { + cfg.Methods = ConfigDefault.Methods + } else { + // Normalize method names to uppercase (HTTP methods are case-sensitive + // and c.Method() returns uppercase, e.g. "GET" not "get") + for i, m := range cfg.Methods { + cfg.Methods[i] = strings.ToUpper(m) + } + } if cfg.KeyGenerator == nil { cfg.KeyGenerator = func(c fiber.Ctx) string { return defaultKeyGenerator(c, &cfg)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/gofiber/fiber/security/advisories/GHSA-35hp-hqmv-8qg8nvdExploitVendor AdvisoryWEB
- github.com/advisories/GHSA-35hp-hqmv-8qg8ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-30246ghsaADVISORY
- github.com/gofiber/fiber/blob/main/middleware/cache/cache_test.gonvdProductWEB
- github.com/gofiber/fiber/blob/main/middleware/cache/config.gonvdProductWEB
- github.com/gofiber/fiber/commit/050ff1ff18511c1475b8ec627460216aaecddd4eghsaWEB
- github.com/gofiber/fiber/commit/9a0d12c07ed895b84c72987f9288b04137afe5deghsaWEB
News mentions
0No linked articles in our index yet.