Files
fiber/middleware/cache/cache_security_test.go
zeekayandHanzo Dev f60e254acb module: rename to github.com/zap-proto/fiber/v3
Fork of gofiber/fiber v3 based on the v3.2.0 tag. Rewrites all self-imports
github.com/gofiber/fiber/v3 -> github.com/zap-proto/fiber/v3. Upstream
gofiber/schema and gofiber/utils/v2 dependencies are left untouched.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-10 12:49:35 -07:00

598 lines
19 KiB
Go

package cache
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/zap-proto/fiber/v3"
"github.com/stretchr/testify/require"
)
// Test_Cache_Security_DoS_ExcessiveQueryParams tests protection against DoS via excessive query parameters
func Test_Cache_Security_DoS_ExcessiveQueryParams(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString("response")
})
// Build a URL with more than maxQueryParams (128) parameters
queryParams := make([]string, 150)
for i := 0; i < 150; i++ {
queryParams[i] = fmt.Sprintf("param%d=value%d", i, i)
}
url := "/?" + strings.Join(queryParams, "&")
// First request should be cached (but with hashed key due to param limit)
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, url, http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
require.Equal(t, 1, count)
// Second request should hit cache
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, url, http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, 1, count, "Handler should not be called on cache hit")
}
// Test_Cache_Security_DoS_ExcessiveQueryBuffer tests protection against DoS via query buffer growth
func Test_Cache_Security_DoS_ExcessiveQueryBuffer(t *testing.T) {
t.Parallel()
app := fiber.New(fiber.Config{
ReadBufferSize: 16384, // Increase buffer to accommodate large query strings for testing
})
app.Use(New(Config{Expiration: 1 * time.Hour}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString("response")
})
// Build a URL with parameters that expand significantly when URL-escaped
// Using characters that require escaping (e.g., "=" becomes "%3D", 3x larger)
specialChars := strings.Repeat("=", 50) // Each "=" becomes "%3D"
queryParams := make([]string, 30)
for i := 0; i < 30; i++ {
queryParams[i] = fmt.Sprintf("key%d=%s", i, specialChars)
}
url := "/?" + strings.Join(queryParams, "&")
// Request should not crash or exhaust memory
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, url, http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
// Second request should hit cache (with hashed key due to buffer limit)
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, url, http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, 1, count, "Handler should not be called on cache hit")
}
// Test_Cache_Security_DoS_ExcessiveVaryHeaders tests protection against DoS via excessive Vary headers
func Test_Cache_Security_DoS_ExcessiveVaryHeaders(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
// Generate more than maxVaryHeaders (32) headers
varyHeaders := make([]string, 50)
for i := 0; i < 50; i++ {
varyHeaders[i] = fmt.Sprintf("X-Custom-Header-%d", i)
}
c.Set(fiber.HeaderVary, strings.Join(varyHeaders, ", "))
return c.SendString("response")
})
// First request should not be cached due to excessive Vary headers (treated as Vary: *)
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, 1, count)
// Second request should also not be cached
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheUnreachable, resp.Header.Get("X-Cache"))
require.Equal(t, 2, count, "Handler should be called each time when uncacheable")
}
// Test_Cache_Security_LongPathSegmentHashed tests that long path segments are properly hashed
func Test_Cache_Security_LongPathSegmentHashed(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
var count int
// Create a very long path (>192 chars which is maxKeyDimensionSegmentLength)
longPath := "/" + strings.Repeat("a", 300)
app.Get(longPath, func(c fiber.Ctx) error {
count++
return c.SendString("response")
})
// First request
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, longPath, http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
require.Equal(t, 1, count)
// Second request should hit cache with hashed key
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, longPath, http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, 1, count, "Handler should not be called on cache hit")
}
// Test_Cache_Security_MalformedQueryString tests handling of malformed query strings
func Test_Cache_Security_MalformedQueryString(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString("response")
})
// Malformed query string with invalid encoding
malformedURL := "/?invalid=%ZZ%XX"
// Should not crash and should handle gracefully
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, malformedURL, http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
// Second request should hit cache (malformed query is hashed as-is)
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, malformedURL, http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, 1, count)
}
// Test_Cache_Security_HeaderInjection tests that header values cannot inject into cache keys
func Test_Cache_Security_HeaderInjection(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
KeyHeaders: []string{"X-Custom-Header"},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString("response-" + c.Get("X-Custom-Header"))
})
// Try to inject delimiters used in key generation (avoiding null bytes which are invalid in HTTP)
injectionAttempts := []string{
"value|q=injected",
"value|h=injected",
"value|c=injected",
"value|vary|injected",
"sha256:fakehash",
}
for _, injection := range injectionAttempts {
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set("X-Custom-Header", injection)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, "response-"+injection, string(body))
}
// Each injection attempt should create a distinct cache entry
// (no collision through injection)
require.Equal(t, len(injectionAttempts), count)
}
// Test_Cache_Security_CookieInjection tests that cookie values cannot inject into cache keys
func Test_Cache_Security_CookieInjection(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
KeyCookies: []string{"session"},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString("response-" + c.Cookies("session"))
})
// Try to inject delimiters (avoiding null bytes which are invalid in HTTP)
injectionAttempts := []string{
"value|injected",
"value:injected",
"value|vary|injected",
"sha256:fakehash",
}
for _, injection := range injectionAttempts {
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set("Cookie", "session="+injection)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
}
// Each injection attempt should create distinct cache entries
require.Equal(t, len(injectionAttempts), count)
}
// Test_Cache_Security_Concurrent_QueryParamDoS tests concurrent requests with excessive params
func Test_Cache_Security_Concurrent_QueryParamDoS(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
var count atomic.Int32
app.Get("/", func(c fiber.Ctx) error {
count.Add(1)
return c.SendString("response")
})
// Build URL with excessive parameters
queryParams := make([]string, 200)
for i := 0; i < 200; i++ {
queryParams[i] = fmt.Sprintf("p%d=v%d", i, i)
}
url := "/?" + strings.Join(queryParams, "&")
// Run concurrent requests
const numRequests = 100
var wg sync.WaitGroup
wg.Add(numRequests)
// Track errors in goroutines
var errCount atomic.Int32
for i := 0; i < numRequests; i++ {
go func() {
defer wg.Done()
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, url, http.NoBody))
if err != nil {
errCount.Add(1)
return
}
if resp.StatusCode != fiber.StatusOK {
errCount.Add(1)
}
}()
}
wg.Wait()
// Should have handled all requests without crashing or errors
require.Equal(t, int32(0), errCount.Load(), "No errors should occur during concurrent requests")
// First request creates cache, rest should hit it
require.LessOrEqual(t, count.Load(), int32(numRequests))
}
// Test_Cache_Security_QueryParameterRepeated tests handling of repeated query parameters
func Test_Cache_Security_QueryParameterRepeated(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString("response")
})
// Test with 100 values for the same parameter
values := make([]string, 100)
for i := 0; i < 100; i++ {
values[i] = fmt.Sprintf("key=%d", i)
}
url := "/?" + strings.Join(values, "&")
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, url, http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
// Second request should hit cache (hashed due to param count)
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, url, http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, 1, count)
}
// Test_Cache_Security_EmptyVaryHeaders tests handling of empty vary header entries
func Test_Cache_Security_EmptyVaryHeaders(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{Expiration: 1 * time.Hour}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
// Empty and whitespace vary entries should be ignored
c.Set(fiber.HeaderVary, "Accept, , , , ,Accept-Encoding")
return c.SendString("response")
})
resp, err := app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheMiss, resp.Header.Get("X-Cache"))
resp, err = app.Test(httptest.NewRequest(fiber.MethodGet, "/", http.NoBody))
require.NoError(t, err)
require.Equal(t, fiber.StatusOK, resp.StatusCode)
require.Equal(t, cacheHit, resp.Header.Get("X-Cache"))
require.Equal(t, 1, count)
}
// Test_Cache_Security_MultiDimensionInjection tests injection with multiple headers and cookies
func Test_Cache_Security_MultiDimensionInjection(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
KeyHeaders: []string{"X-Header-1", "X-Header-2"},
KeyCookies: []string{"cookie1", "cookie2"},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString(fmt.Sprintf("h1=%s,h2=%s,c1=%s,c2=%s",
c.Get("X-Header-1"), c.Get("X-Header-2"),
c.Cookies("cookie1"), c.Cookies("cookie2")))
})
// Test combinations that should create distinct cache entries
testCases := []struct {
header1 string
header2 string
cookie1 string
cookie2 string
}{
// Normal values
{"value1", "value2", "cookie1", "cookie2"},
// Injection attempts with delimiters
{"value|injected", "normal", "normal", "normal"},
{"normal", "value:injected", "normal", "normal"},
{"normal", "normal", "value|injected", "normal"},
{"normal", "normal", "normal", "value:injected"},
// Multiple delimiters
{"value|with|pipes", "value:with:colons", "normal", "normal"},
// Backslashes
{"value\\with\\backslash", "normal", "normal", "normal"},
{"normal", "normal", "cookie\\value", "normal"},
// Combined escapes
{"value\\|mixed", "normal", "normal", "normal"},
{"normal", "value\\:mixed", "normal", "normal"},
}
for i, tc := range testCases {
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set("X-Header-1", tc.header1)
req.Header.Set("X-Header-2", tc.header2)
req.Header.Set("Cookie", fmt.Sprintf("cookie1=%s; cookie2=%s", tc.cookie1, tc.cookie2))
resp, err := app.Test(req)
require.NoError(t, err, "Test case %d failed", i)
require.Equal(t, fiber.StatusOK, resp.StatusCode, "Test case %d failed", i)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err, "Test case %d failed", i)
expected := fmt.Sprintf("h1=%s,h2=%s,c1=%s,c2=%s", tc.header1, tc.header2, tc.cookie1, tc.cookie2)
require.Equal(t, expected, string(body), "Test case %d failed", i)
}
// Each test case should create a distinct cache entry (no collisions)
require.Equal(t, len(testCases), count, "All test cases should create distinct cache entries")
}
// Test_Cache_Security_BackslashEscaping tests that backslashes are properly escaped
func Test_Cache_Security_BackslashEscaping(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
KeyHeaders: []string{"X-Custom-Header"},
}))
var count int
app.Get("/", func(c fiber.Ctx) error {
count++
return c.SendString("response-" + c.Get("X-Custom-Header"))
})
// Test backslash escaping scenarios
testCases := []string{
"\\", // Single backslash
"\\\\", // Double backslash
"\\p", // Escaped pipe character
"\\c", // Escaped colon character
"value\\|test", // Backslash before pipe
"value\\:test", // Backslash before colon
"\\\\p", // Double backslash then p
"\\\\c", // Double backslash then c
}
for i, tc := range testCases {
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set("X-Custom-Header", tc)
resp, err := app.Test(req)
require.NoError(t, err, "Test case %d (%s) failed", i, tc)
require.Equal(t, fiber.StatusOK, resp.StatusCode, "Test case %d (%s) failed", i, tc)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err, "Test case %d (%s) failed", i, tc)
require.Equal(t, "response-"+tc, string(body), "Test case %d (%s) failed", i, tc)
}
// Each test case should create a distinct cache entry
require.Equal(t, len(testCases), count, "All backslash test cases should create distinct cache entries")
}
// Test_Cache_Security_DelimiterCollisionPrevention verifies that escaped delimiters don't collide
func Test_Cache_Security_DelimiterCollisionPrevention(t *testing.T) {
t.Parallel()
app := fiber.New()
app.Use(New(Config{
Expiration: 1 * time.Hour,
KeyHeaders: []string{"X-Header"},
KeyCookies: []string{"session"},
}))
var responses []string
app.Get("/", func(c fiber.Ctx) error {
response := fmt.Sprintf("h=%s,c=%s", c.Get("X-Header"), c.Cookies("session"))
responses = append(responses, response)
return c.SendString(response)
})
// These pairs should NOT collide after escaping
testCases := []struct {
header string
cookie string
}{
{"value1|part2", "normal"}, // Pipe in header
{"value1", "part2|normal"}, // Different structure but similar
{"value:test", "cookie"}, // Colon in header
{"value", "test:cookie"}, // Colon in cookie
{"a|b:c", "d"}, // Mixed delimiters
{"a", "b:c|d"}, // Different arrangement
{"\\|", "test"}, // Backslash-pipe sequence
{"\\", "|test"}, // Separated backslash and pipe
}
for i, tc := range testCases {
req := httptest.NewRequest(fiber.MethodGet, "/", http.NoBody)
req.Header.Set("X-Header", tc.header)
req.Header.Set("Cookie", "session="+tc.cookie)
resp, err := app.Test(req)
require.NoError(t, err, "Test case %d failed", i)
require.Equal(t, fiber.StatusOK, resp.StatusCode, "Test case %d failed", i)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err, "Test case %d failed", i)
expected := fmt.Sprintf("h=%s,c=%s", tc.header, tc.cookie)
require.Equal(t, expected, string(body), "Test case %d failed", i)
}
// All test cases should create distinct cache entries (no collisions from injection)
require.Len(t, responses, len(testCases), "All test cases should create distinct cache entries")
// Verify all responses are unique
seen := make(map[string]bool)
for _, resp := range responses {
require.False(t, seen[resp], "Response should be unique: %s", resp)
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])
})
}
}