Files
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

1625 lines
44 KiB
Go

// Special thanks to @codemicro for moving this to fiber core
// Original middleware: github.com/codemicro/fiber-cache
package cache
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math"
"net/url"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gofiber/utils/v2"
utilsstrings "github.com/gofiber/utils/v2/strings"
"github.com/valyala/fasthttp"
"github.com/zap-proto/fiber/v3"
)
// timestampUpdatePeriod is the period which is used to check the cache expiration.
// It should not be too long to provide more or less acceptable expiration error, and in the same
// time it should not be too short to avoid overwhelming of the system
const timestampUpdatePeriod = 300 * time.Millisecond
// buffer size for hexpool
const (
hexLen = sha256.Size * 2
maxKeyDimensionSegmentLength = 192
defaultKeyBufferCap = 256
maxQueryParams = 128 // Maximum number of query parameters to parse
maxVaryHeaders = 32 // Maximum number of Vary headers to process
maxQueryBufferSize = 4096 // Maximum buffer size for query string canonicalization
)
// cache status
// unreachable: when cache is bypass, or invalid
// hit: cache is served
// miss: do not have cache record
const (
cacheUnreachable = "unreachable"
cacheHit = "hit"
cacheMiss = "miss"
)
type expirationSource uint8
const (
expirationSourceConfig expirationSource = iota
expirationSourceMaxAge
expirationSourceSMaxAge
expirationSourceExpires
expirationSourceGenerator
)
// directives
const (
noCache = "no-cache"
noStore = "no-store"
privateDirective = "private"
)
type requestCacheDirectives struct {
maxAge uint64
maxStale uint64
minFresh uint64
maxAgeSet bool
maxStaleSet bool
maxStaleAny bool
minFreshSet bool
noStore bool
noCache bool
onlyIfCached bool
}
var ignoreHeaders = map[string]struct{}{
"Age": {},
"Cache-Control": {}, // already stored explicitly by the cache manager
"Connection": {},
"Content-Encoding": {}, // already stored explicitly by the cache manager
"Content-Type": {}, // already stored explicitly by the cache manager
"Date": {},
"ETag": {}, // already stored explicitly by the cache manager
"Expires": {}, // already stored explicitly by the cache manager
"Keep-Alive": {},
"Proxy-Authenticate": {},
"Proxy-Authorization": {},
"TE": {},
"Trailers": {},
"Transfer-Encoding": {},
"Upgrade": {},
}
var cacheableStatusCodes = map[int]struct{}{
fiber.StatusOK: {},
fiber.StatusNonAuthoritativeInformation: {},
fiber.StatusNoContent: {},
fiber.StatusPartialContent: {},
fiber.StatusMultipleChoices: {},
fiber.StatusMovedPermanently: {},
fiber.StatusPermanentRedirect: {},
fiber.StatusNotFound: {},
fiber.StatusMethodNotAllowed: {},
fiber.StatusGone: {},
fiber.StatusRequestURITooLong: {},
fiber.StatusNotImplemented: {},
}
var keyBufferPool = sync.Pool{
New: func() any {
buf := make([]byte, 0, defaultKeyBufferCap)
return &buf
},
}
// New creates a new middleware handler
func New(config ...Config) fiber.Handler {
// Set default config
cfg := configDefault(config...)
type evictionCandidate struct {
key string
size uint
exp uint64
heapIdx int
}
redactKeys := !cfg.DisableValueRedaction
maskKey := func(key string) string {
if redactKeys {
return redactedKey
}
return key
}
// Nothing to cache
if int(cfg.Expiration.Seconds()) < 0 {
return func(c fiber.Ctx) error {
return c.Next()
}
}
var (
// Cache settings
mux = &sync.RWMutex{}
timestamp = safeUnixSeconds(time.Now())
)
// Create manager to simplify storage operations ( see manager.go )
manager := newManager(cfg.Storage, redactKeys)
// Create indexed heap for tracking expirations ( see heap.go )
heap := &indexedHeap{}
// count stored bytes (sizes of response bodies)
var storedBytes uint
// Pool for hex encoding buffers
hexBufPool := &sync.Pool{
New: func() any {
buf := make([]byte, hexLen)
return &buf
},
}
hashAuthorization := makeHashAuthFunc(hexBufPool)
buildVaryKey := makeBuildVaryKeyFunc(hexBufPool)
// Update timestamp in the configured interval
go func() {
ticker := time.NewTicker(timestampUpdatePeriod)
defer ticker.Stop()
for range ticker.C {
atomic.StoreUint64(&timestamp, safeUnixSeconds(time.Now()))
}
}()
// Delete key from both manager and storage
deleteKey := func(ctx context.Context, dkey string) error {
if err := manager.del(ctx, dkey); err != nil {
return err
}
// External storage saves body data with different key
if cfg.Storage != nil {
if err := manager.del(ctx, dkey+"_body"); err != nil {
return err
}
}
return nil
}
removeHeapEntry := func(entryKey string, heapIdx int) {
if cfg.MaxBytes == 0 {
return
}
if heapIdx < 0 || heapIdx >= len(heap.indices) {
return
}
indexedIdx := heap.indices[heapIdx]
if indexedIdx < 0 || indexedIdx >= len(heap.entries) {
return
}
entry := heap.entries[indexedIdx]
if entry.idx != heapIdx || entry.key != entryKey {
return
}
_, size := heap.remove(heapIdx)
storedBytes -= size
}
refreshHeapIndex := func(ctx context.Context, candidate evictionCandidate) error {
entry, err := manager.get(ctx, candidate.key)
if err != nil {
if errors.Is(err, errCacheMiss) {
return nil
}
return fmt.Errorf("cache: failed to reload key %q after eviction failure: %w", maskKey(candidate.key), err)
}
entry.heapidx = candidate.heapIdx
remainingTTL := max(time.Until(secondsToTime(entry.exp)), 0)
if err := manager.set(ctx, candidate.key, entry, remainingTTL); err != nil {
return fmt.Errorf("cache: failed to restore heap index for key %q: %w", maskKey(candidate.key), err)
}
return nil
}
// Return new handler
return func(c fiber.Ctx) error {
hasAuthorization := len(c.Request().Header.Peek(fiber.HeaderAuthorization)) > 0
reqCacheControl := c.Request().Header.Peek(fiber.HeaderCacheControl)
reqDirectives := parseRequestCacheControl(reqCacheControl)
if !reqDirectives.noCache {
reqPragma := utils.UnsafeString(c.Request().Header.Peek(fiber.HeaderPragma))
if hasDirective(reqPragma, noCache) {
reqDirectives.noCache = true
}
}
// Refrain from caching
if reqDirectives.noStore {
return c.Next()
}
requestMethod := c.Method()
// Only cache methods listed in cfg.Methods (default: GET, HEAD).
if !slices.Contains(cfg.Methods, requestMethod) {
c.Set(cfg.CacheHeader, cacheUnreachable)
return c.Next()
}
// Get key from request
baseKey := requestMethod + "|" + cfg.KeyGenerator(c)
manifestKey := baseKey + "|vary"
if hasAuthorization {
authHash := hashAuthorization(c.Request().Header.Peek(fiber.HeaderAuthorization))
baseKey += "_auth_" + authHash
manifestKey = baseKey + "|vary"
}
key := baseKey
reqCtx := c.Context()
varyNames := []string(nil)
hasVaryManifest := false
var err error
if !cfg.DisableVaryHeaders {
varyNames, hasVaryManifest, err = loadVaryManifest(reqCtx, manager, manifestKey)
if err != nil {
return err
}
if len(varyNames) > 0 {
key += buildVaryKey(varyNames, &c.Request().Header)
}
}
// Get entry from pool
e, err := manager.get(reqCtx, key)
if err != nil && !errors.Is(err, errCacheMiss) {
return err
}
entryAge := uint64(0)
revalidate := false
oldHeapIdx := -1 // Track old heap index for replacement during revalidation
handleMinFresh := func(now uint64) {
if e == nil || !reqDirectives.minFreshSet {
return
}
remainingFreshness := remainingFreshness(e, now)
if remainingFreshness < reqDirectives.minFresh {
revalidate = true
oldHeapIdx = e.heapidx
if cfg.Storage != nil {
manager.release(e)
}
e = nil
}
}
// Lock entry
mux.Lock()
locked := true
unlock := func() {
if locked {
mux.Unlock()
locked = false
}
}
relock := func() {
if !locked {
mux.Lock()
locked = true
}
}
// Get timestamp
ts := atomic.LoadUint64(&timestamp)
// Cache Entry found
if e != nil {
entryAge = cachedResponseAge(e, ts)
if reqDirectives.maxAgeSet && (reqDirectives.maxAge == 0 || entryAge > reqDirectives.maxAge) {
revalidate = true
oldHeapIdx = e.heapidx
if cfg.Storage != nil {
manager.release(e)
}
e = nil
}
handleMinFresh(ts)
}
if e != nil && e.ttl == 0 && e.forceRevalidate {
revalidate = true
oldHeapIdx = e.heapidx
if cfg.Storage != nil {
manager.release(e)
}
e = nil
}
if e != nil && e.ttl == 0 && e.exp != 0 && ts >= e.exp {
unlock()
if err := deleteKey(reqCtx, key); err != nil {
if cfg.Storage != nil {
manager.release(e)
}
return fmt.Errorf("cache: failed to delete expired key %q: %w", maskKey(key), err)
}
relock()
removeHeapEntry(key, e.heapidx)
if cfg.Storage != nil {
manager.release(e)
}
e = nil
unlock()
c.Set(cfg.CacheHeader, cacheUnreachable)
goto continueRequest
}
if e != nil {
entryHasPrivate := e != nil && e.private
if !entryHasPrivate && cfg.StoreResponseHeaders && len(e.headers) > 0 {
if cc, ok := lookupCachedHeader(e.headers, fiber.HeaderCacheControl); ok && hasDirective(utils.UnsafeString(cc), privateDirective) {
entryHasPrivate = true
}
}
requestNoCache := reqDirectives.noCache
// Invalidate cache if requested
if cfg.CacheInvalidator != nil && cfg.CacheInvalidator(c) {
e.exp = ts - 1
}
entryHasExpiration := e != nil && e.exp != 0
entryExpired := entryHasExpiration && ts >= e.exp
staleness := uint64(0)
if entryExpired {
staleness = ts - e.exp
}
allowStale := entryExpired && (reqDirectives.maxStaleAny || (reqDirectives.maxStaleSet && staleness <= reqDirectives.maxStale))
if entryExpired && e.revalidate {
revalidate = true
oldHeapIdx = e.heapidx
if cfg.Storage != nil {
manager.release(e)
}
e = nil
}
handleMinFresh(ts)
if revalidate {
unlock()
c.Set(cfg.CacheHeader, cacheUnreachable)
if reqDirectives.onlyIfCached {
return c.SendStatus(fiber.StatusGatewayTimeout)
}
goto continueRequest
}
servedStale := false
switch {
case entryExpired && !allowStale:
unlock()
if err := deleteKey(reqCtx, key); err != nil {
if e != nil {
manager.release(e)
}
return fmt.Errorf("cache: failed to delete expired key %q: %w", maskKey(key), err)
}
relock()
idx := e.heapidx
manager.release(e)
removeHeapEntry(key, idx)
e = nil
case entryHasPrivate:
unlock()
if err := deleteKey(reqCtx, key); err != nil {
if e != nil {
manager.release(e)
}
return fmt.Errorf("cache: failed to delete private response for key %q: %w", maskKey(key), err)
}
relock()
removeHeapEntry(key, e.heapidx)
if cfg.Storage != nil && e != nil {
manager.release(e)
}
e = nil
unlock()
c.Set(cfg.CacheHeader, cacheUnreachable)
if reqDirectives.onlyIfCached {
return c.SendStatus(fiber.StatusGatewayTimeout)
}
return c.Next()
case entryHasExpiration && !requestNoCache:
servedStale = entryExpired
if hasAuthorization && !e.shareable {
if cfg.Storage != nil {
manager.release(e)
}
unlock()
c.Set(cfg.CacheHeader, cacheUnreachable)
return c.Next()
}
// Separate body value to avoid msgp serialization
// We can store raw bytes with Storage 👍
if cfg.Storage != nil {
unlock()
rawBody, err := manager.getRaw(reqCtx, key+"_body")
if err != nil {
manager.release(e)
return cacheBodyFetchError(maskKey, key, err)
}
e.body = rawBody
} else {
unlock()
}
// Set response headers from cache
c.Response().SetBodyRaw(e.body)
c.Response().SetStatusCode(e.status)
c.Response().Header.SetContentTypeBytes(e.ctype)
if len(e.cencoding) > 0 {
c.Response().Header.SetBytesV(fiber.HeaderContentEncoding, e.cencoding)
}
if len(e.cacheControl) > 0 {
c.Response().Header.SetBytesV(fiber.HeaderCacheControl, e.cacheControl)
}
if len(e.expires) > 0 {
c.Response().Header.SetBytesV(fiber.HeaderExpires, e.expires)
}
if len(e.etag) > 0 {
c.Response().Header.SetBytesV(fiber.HeaderETag, e.etag)
}
clampedDate := clampDateSeconds(e.date, ts)
dateValue := fasthttp.AppendHTTPDate(nil, secondsToTime(clampedDate))
c.Response().Header.SetBytesV(fiber.HeaderDate, dateValue)
for i := range e.headers {
h := e.headers[i]
c.Response().Header.SetBytesKV(h.key, h.value)
}
// Set Cache-Control header if not disabled and not already set
if !cfg.DisableCacheControl && len(c.Response().Header.Peek(fiber.HeaderCacheControl)) == 0 {
remaining := uint64(0)
if e.exp > ts {
remaining = e.exp - ts
}
maxAge := utils.FormatUint(remaining)
c.Set(fiber.HeaderCacheControl, "public, max-age="+maxAge)
}
const maxDeltaSeconds = uint64(math.MaxInt32)
ageSeconds := min(entryAge, maxDeltaSeconds)
// RFC-compliant Age header (RFC 9111)
age := utils.FormatUint(ageSeconds)
c.Response().Header.Set(fiber.HeaderAge, age)
appendWarningHeaders(&c.Response().Header, servedStale, isHeuristicFreshness(e, &cfg, entryAge))
c.Set(cfg.CacheHeader, cacheHit)
// release item allocated from storage
if cfg.Storage != nil {
manager.release(e)
}
// Return response
return nil
default:
// no cached response to serve
}
}
if e == nil && revalidate {
unlock()
c.Set(cfg.CacheHeader, cacheUnreachable)
if reqDirectives.onlyIfCached {
return c.SendStatus(fiber.StatusGatewayTimeout)
}
goto continueRequest
}
if e == nil && reqDirectives.onlyIfCached {
unlock()
c.Set(cfg.CacheHeader, cacheUnreachable)
return c.SendStatus(fiber.StatusGatewayTimeout)
}
// make sure we're not blocking concurrent requests - do unlock
unlock()
continueRequest:
// Continue stack, return err to Fiber if exist
if err := c.Next(); err != nil {
return err
}
cacheControlBytes := c.Response().Header.Peek(fiber.HeaderCacheControl)
respCacheControl := parseResponseCacheControl(cacheControlBytes)
varyHeader := utils.UnsafeString(c.Response().Header.Peek(fiber.HeaderVary))
hasPrivate := respCacheControl.hasPrivate
hasNoCache := respCacheControl.hasNoCache
varyNames, varyHasStar := parseVary(varyHeader)
// Respect server cache-control: no-store
if respCacheControl.hasNoStore {
c.Set(cfg.CacheHeader, cacheUnreachable)
return nil
}
// RFC 9111 requires responses with Vary: * to remain uncacheable even when
// response-driven Vary partitioning is otherwise disabled.
if hasPrivate || hasNoCache || varyHasStar {
if e != nil {
if err := deleteKey(reqCtx, key); err != nil {
if cfg.Storage != nil {
manager.release(e)
}
return fmt.Errorf("cache: failed to delete cached response for key %q: %w", maskKey(key), err)
}
mux.Lock()
removeHeapEntry(key, e.heapidx)
if cfg.Storage != nil {
manager.release(e)
}
e = nil
mux.Unlock()
}
if !cfg.DisableVaryHeaders && hasVaryManifest {
if err := manager.del(reqCtx, manifestKey); err != nil {
return fmt.Errorf("cache: failed to delete stale vary manifest %q: %w", maskKey(manifestKey), err)
}
}
c.Set(cfg.CacheHeader, cacheUnreachable)
return nil
}
shouldStoreVaryManifest := !cfg.DisableVaryHeaders && len(varyNames) > 0
if !cfg.DisableVaryHeaders && len(varyNames) > 0 {
if key == baseKey {
key += buildVaryKey(varyNames, &c.Request().Header)
}
} else if !cfg.DisableVaryHeaders && hasVaryManifest {
if err := manager.del(reqCtx, manifestKey); err != nil {
return fmt.Errorf("cache: failed to delete stale vary manifest %q: %w", maskKey(manifestKey), err)
}
}
isSharedCacheAllowed := allowsSharedCacheDirectives(respCacheControl)
if hasAuthorization && !isSharedCacheAllowed {
c.Set(cfg.CacheHeader, cacheUnreachable)
return nil
}
sharedCacheMode := !hasAuthorization || isSharedCacheAllowed
// Don't cache response if status code is not cacheable
if _, ok := cacheableStatusCodes[c.Response().StatusCode()]; !ok {
c.Set(cfg.CacheHeader, cacheUnreachable)
return nil
}
// Don't cache response if Next returns true
if cfg.Next != nil && cfg.Next(c) {
c.Set(cfg.CacheHeader, cacheUnreachable)
return nil
}
// Don't try to cache if body won't fit into cache
bodySize := uint(len(c.Response().Body()))
if cfg.MaxBytes > 0 && bodySize > cfg.MaxBytes {
c.Set(cfg.CacheHeader, cacheUnreachable)
return nil
}
// Eviction loop: atomically reserve space for new entry and evict old entries.
// Strategy:
// 1. Under lock: reserve space by pre-incrementing storedBytes, then collect entries to evict
// 2. Outside lock: perform I/O deletions
// 3. On deletion failure: restore storedBytes and return error
// 4. Track reservation with a flag; unreserve on early return via defer
var spaceReserved bool
defer func() {
// If we reserved space but the entry was not successfully added to heap, unreserve it
if cfg.MaxBytes > 0 && spaceReserved {
mux.Lock()
storedBytes -= bodySize
mux.Unlock()
}
}()
if cfg.MaxBytes > 0 {
mux.Lock()
// Reserve space for the new entry first
storedBytes += bodySize
spaceReserved = true
// Now evict entries until we're under the limit
var keysToRemove []string
var sizesToRemove []uint
var candidates []evictionCandidate
for storedBytes > cfg.MaxBytes {
if heap.Len() == 0 {
// Can't evict more, unreserve space and fail
storedBytes -= bodySize
// Set spaceReserved to false so the deferred cleanup does not unreserve again
spaceReserved = false
mux.Unlock()
return errors.New("cache: insufficient space and no entries to evict")
}
next := heap.entries[0]
keyToRemove, size := heap.removeFirst()
keysToRemove = append(keysToRemove, keyToRemove)
sizesToRemove = append(sizesToRemove, size)
candidates = append(candidates, evictionCandidate{
key: keyToRemove,
size: size,
exp: next.exp,
})
storedBytes -= size
}
mux.Unlock()
// Perform deletions outside the lock
if len(keysToRemove) > 0 {
for i, keyToRemove := range keysToRemove {
delErr := deleteKey(reqCtx, keyToRemove)
if delErr == nil {
continue
}
// Deletion failed: restore storedBytes for failed deletions
mux.Lock()
// Restore sizes of entries we failed to delete
for j := i; j < len(sizesToRemove); j++ {
storedBytes += sizesToRemove[j]
}
// Unreserve space for the new entry
storedBytes -= bodySize
spaceReserved = false
// Re-add entries to the heap to keep expiration tracking consistent
var restored []evictionCandidate
for j := i; j < len(candidates); j++ {
candidate := candidates[j]
candidate.heapIdx = heap.put(candidate.key, candidate.exp, candidate.size)
restored = append(restored, candidate)
}
mux.Unlock()
var restoreErr error
for _, candidate := range restored {
if err := refreshHeapIndex(reqCtx, candidate); err != nil {
restoreErr = errors.Join(restoreErr, err)
}
}
if restoreErr != nil {
return errors.Join(fmt.Errorf("cache: failed to delete key %q while evicting: %w", maskKey(keyToRemove), delErr), restoreErr)
}
return fmt.Errorf("cache: failed to delete key %q while evicting: %w", maskKey(keyToRemove), delErr)
}
}
}
e = manager.acquire()
// Cache response
e.body = utils.CopyBytes(c.Response().Body())
e.status = c.Response().StatusCode()
e.ctype = utils.CopyBytes(c.Response().Header.ContentType())
e.cencoding = utils.CopyBytes(c.Response().Header.Peek(fiber.HeaderContentEncoding))
e.private = false
e.cacheControl = utils.CopyBytes(cacheControlBytes)
e.expires = utils.CopyBytes(c.Response().Header.Peek(fiber.HeaderExpires))
e.etag = utils.CopyBytes(c.Response().Header.Peek(fiber.HeaderETag))
e.date = 0
ageVal := uint64(0)
if b := c.Response().Header.Peek(fiber.HeaderAge); len(b) > 0 {
if v, err := fasthttp.ParseUint(b); err == nil {
if v >= 0 {
ageVal = uint64(v)
}
}
} else {
c.Response().Header.Set(fiber.HeaderAge, "0")
}
e.age = ageVal
e.shareable = isSharedCacheAllowed
now := time.Now().UTC()
nowUnix := safeUnixSeconds(now)
dateHeader := c.Response().Header.Peek(fiber.HeaderDate)
parsedDate, _ := parseHTTPDate(dateHeader)
e.date = clampDateSeconds(parsedDate, nowUnix)
dateBytes := fasthttp.AppendHTTPDate(nil, secondsToTime(e.date))
c.Response().Header.SetBytesV(fiber.HeaderDate, dateBytes)
// Store all response headers
// (more: https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1)
if cfg.StoreResponseHeaders {
allHeaders := c.Response().Header.All()
e.headers = e.headers[:0]
for key, value := range allHeaders {
keyStr := string(key)
if _, ok := ignoreHeaders[keyStr]; ok {
continue
}
e.headers = append(e.headers, cachedHeader{
key: utils.CopyBytes(utils.UnsafeBytes(keyStr)),
value: utils.CopyBytes(value),
})
}
}
expirationSource := expirationSourceConfig
expiresParseError := false
mustRevalidate := respCacheControl.mustRevalidate || respCacheControl.proxyRevalidate
// default cache expiration
expiration := cfg.Expiration
if sharedCacheMode && respCacheControl.sMaxAgeSet {
expiration = secondsToDuration(respCacheControl.sMaxAge)
expirationSource = expirationSourceSMaxAge
}
if expirationSource == expirationSourceConfig {
if respCacheControl.maxAgeSet {
expiration = secondsToDuration(respCacheControl.maxAge)
expirationSource = expirationSourceMaxAge
} else if expiresBytes := c.Response().Header.Peek(fiber.HeaderExpires); len(expiresBytes) > 0 {
expiresAt, err := fasthttp.ParseHTTPDate(expiresBytes)
if err != nil {
expiration = time.Nanosecond
expiresParseError = true
} else {
expiration = time.Until(expiresAt)
}
expirationSource = expirationSourceExpires
}
}
// Calculate expiration by response header or other setting
if cfg.ExpirationGenerator != nil {
expiration = cfg.ExpirationGenerator(c, &cfg)
expirationSource = expirationSourceGenerator
}
e.forceRevalidate = expiresParseError
e.revalidate = mustRevalidate
storageExpiration := expiration
if expiresParseError || storageExpiration < cfg.Expiration {
storageExpiration = cfg.Expiration
}
if expiration <= 0 && !expiresParseError {
c.Set(cfg.CacheHeader, cacheUnreachable)
return nil
}
ts = atomic.LoadUint64(&timestamp)
responseTS := max(ts, nowUnix)
maxAgeSeconds := uint64(time.Duration(math.MaxInt64) / time.Second)
var ageDuration time.Duration
apparentAge := e.age
if e.date > 0 && responseTS > e.date {
dateAge := responseTS - e.date
if dateAge > apparentAge {
apparentAge = dateAge
}
}
if expirationSource != expirationSourceExpires {
if apparentAge > maxAgeSeconds {
ageDuration = expiration + time.Second
} else {
ageDuration = time.Duration(apparentAge) * time.Second
}
}
remainingExpiration := expiration - ageDuration
if remainingExpiration <= 0 {
if expirationSource != expirationSourceExpires {
c.Set(cfg.CacheHeader, cacheUnreachable)
return nil
}
remainingExpiration = 0
}
if !cfg.DisableVaryHeaders && shouldStoreVaryManifest {
if err := storeVaryManifest(reqCtx, manager, manifestKey, varyNames, storageExpiration); err != nil {
return err
}
}
e.exp = responseTS + uint64(remainingExpiration.Seconds())
e.ttl = uint64(expiration.Seconds())
if expiresParseError {
e.exp = ts + 1
}
// Store entry in heap (space already reserved in eviction phase)
var heapIdx int
if cfg.MaxBytes > 0 {
mux.Lock()
heapIdx = heap.put(key, e.exp, bodySize)
e.heapidx = heapIdx
// Note: storedBytes was incremented during reservation, and evictions
// have already been accounted for, so no additional increment is needed
spaceReserved = false // Clear flag to prevent defer from unreserving
mux.Unlock()
}
cleanupOnStoreError := func(ctx context.Context, releaseEntry, rawStored bool) error {
var cleanupErr error
if cfg.MaxBytes > 0 {
mux.Lock()
_, size := heap.remove(heapIdx)
storedBytes -= size
mux.Unlock()
}
if releaseEntry {
manager.release(e)
}
if rawStored {
rawKey := key + "_body"
if err := manager.del(ctx, rawKey); err != nil {
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("cache: failed to delete raw key %q after store error: %w", maskKey(rawKey), err))
}
}
return cleanupErr
}
// For external Storage we store raw body separated
if cfg.Storage != nil {
if err := manager.setRaw(reqCtx, key+"_body", e.body, storageExpiration); err != nil {
if cleanupErr := cleanupOnStoreError(reqCtx, true, false); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return err
}
// avoid body msgp encoding
e.body = nil
if err := manager.set(reqCtx, key, e, storageExpiration); err != nil {
if cleanupErr := cleanupOnStoreError(reqCtx, false, true); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return err
}
} else {
// Store entry in memory
if err := manager.set(reqCtx, key, e, storageExpiration); err != nil {
if cleanupErr := cleanupOnStoreError(reqCtx, true, false); cleanupErr != nil {
err = errors.Join(err, cleanupErr)
}
return err
}
}
// If revalidating, remove old heap entry now that replacement is successfully stored
if cfg.MaxBytes > 0 && revalidate && oldHeapIdx >= 0 {
mux.Lock()
removeHeapEntry(key, oldHeapIdx)
mux.Unlock()
}
c.Set(cfg.CacheHeader, cacheMiss)
// Finish response
return nil
}
}
// hasDirective checks if a cache directive header value contains a directive (case-insensitive).
// A directive is considered matched when followed by end-of-string, ',', ' ', '\t', or '='
// per RFC 9111 §5.2.
func hasDirective(cc, directive string) bool {
ccLen := len(cc)
dirLen := len(directive)
for i := 0; i <= ccLen-dirLen; i++ {
if !utils.EqualFold(cc[i:i+dirLen], directive) {
continue
}
if i > 0 {
prev := cc[i-1]
if prev != ' ' && prev != ',' && prev != '\t' {
continue
}
}
if i+dirLen == ccLen {
return true
}
next := cc[i+dirLen]
if next == ',' || next == ' ' || next == '\t' || next == '=' {
return true
}
}
return false
}
func cacheBodyFetchError(mask func(string) string, key string, err error) error {
if errors.Is(err, errCacheMiss) {
return fmt.Errorf("cache: no cached body for key %q: %w", mask(key), err)
}
return err
}
func parseUintDirective(val []byte) (uint64, bool) {
if len(val) == 0 {
return 0, false
}
parsed, err := fasthttp.ParseUint(val)
if err != nil || parsed < 0 {
return 0, false
}
return uint64(parsed), true
}
func parseCacheControlDirectives(cc []byte, fn func(key, value []byte)) {
for i := 0; i < len(cc); {
// skip leading separators and OWS (space/tab per RFC 9110 §5.6.3)
for i < len(cc) && (cc[i] == ' ' || cc[i] == '\t' || cc[i] == ',') {
i++
}
if i >= len(cc) {
break
}
start := i
for i < len(cc) && cc[i] != ',' {
i++
}
partEnd := i
for partEnd > start && (cc[partEnd-1] == ' ' || cc[partEnd-1] == '\t') {
partEnd--
}
keyStart := start
for keyStart < partEnd && (cc[keyStart] == ' ' || cc[keyStart] == '\t') {
keyStart++
}
if keyStart >= partEnd {
continue
}
keyEnd := keyStart
for keyEnd < partEnd && cc[keyEnd] != '=' {
keyEnd++
}
// Trim trailing OWS from key
keyEndTrimmed := keyEnd
for keyEndTrimmed > keyStart && (cc[keyEndTrimmed-1] == ' ' || cc[keyEndTrimmed-1] == '\t') {
keyEndTrimmed--
}
key := cc[keyStart:keyEndTrimmed]
var value []byte
if keyEnd < partEnd && cc[keyEnd] == '=' {
valueStart := keyEnd + 1
for valueStart < partEnd && (cc[valueStart] == ' ' || cc[valueStart] == '\t') {
valueStart++
}
valueEnd := partEnd
for valueEnd > valueStart && (cc[valueEnd-1] == ' ' || cc[valueEnd-1] == '\t') {
valueEnd--
}
if valueStart <= valueEnd {
value = cc[valueStart:valueEnd]
// Handle quoted-string values per RFC 9111 Section 5.2
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
value = unquoteCacheDirective(value)
}
}
}
fn(key, value)
i++ // skip comma
}
}
// unquoteCacheDirective removes quotes and handles escaped characters in quoted-string values.
// Per RFC 9111 Section 5.2, quoted-string values follow RFC 9110 Section 5.6.4.
func unquoteCacheDirective(quoted []byte) []byte {
if len(quoted) < 2 {
return quoted
}
// Remove surrounding quotes
inner := quoted[1 : len(quoted)-1]
// Check if there are any escaped characters (backslash followed by another character)
hasEscapes := false
for i := 0; i < len(inner)-1; i++ {
if inner[i] == '\\' {
hasEscapes = true
break
}
}
// If no escapes, return the inner content directly
if !hasEscapes {
return inner
}
// Process escaped characters
result := make([]byte, 0, len(inner))
for i := 0; i < len(inner); i++ {
if inner[i] == '\\' && i+1 < len(inner) {
// Skip the backslash and take the next character
i++
result = append(result, inner[i])
} else {
result = append(result, inner[i])
}
}
return result
}
type responseCacheControl struct {
maxAge uint64
sMaxAge uint64
maxAgeSet bool
sMaxAgeSet bool
hasNoCache bool
hasNoStore bool
hasPrivate bool
hasPublic bool
mustRevalidate bool
proxyRevalidate bool
}
func parseResponseCacheControl(cc []byte) responseCacheControl {
parsed := responseCacheControl{}
parseCacheControlDirectives(cc, func(key, value []byte) {
switch {
case utils.EqualFold(utils.UnsafeString(key), noStore):
parsed.hasNoStore = true
case utils.EqualFold(utils.UnsafeString(key), noCache):
parsed.hasNoCache = true
case utils.EqualFold(utils.UnsafeString(key), privateDirective):
parsed.hasPrivate = true
case utils.EqualFold(utils.UnsafeString(key), "public"):
parsed.hasPublic = true
case utils.EqualFold(utils.UnsafeString(key), "max-age"):
if v, ok := parseUintDirective(value); ok {
parsed.maxAgeSet = true
parsed.maxAge = v
}
case utils.EqualFold(utils.UnsafeString(key), "s-maxage"):
if v, ok := parseUintDirective(value); ok {
parsed.sMaxAgeSet = true
parsed.sMaxAge = v
}
case utils.EqualFold(utils.UnsafeString(key), "must-revalidate"):
parsed.mustRevalidate = true
case utils.EqualFold(utils.UnsafeString(key), "proxy-revalidate"):
parsed.proxyRevalidate = true
default:
// ignore unknown directives
}
})
return parsed
}
// parseMaxAge extracts the max-age directive from a Cache-Control header.
func parseMaxAge(cc string) (time.Duration, bool) {
parsed := parseResponseCacheControl(utils.UnsafeBytes(cc))
if !parsed.maxAgeSet {
return 0, false
}
return secondsToDuration(parsed.maxAge), true
}
func parseRequestCacheControl(cc []byte) requestCacheDirectives {
directives := requestCacheDirectives{}
parseCacheControlDirectives(cc, func(key, value []byte) {
switch {
case utils.EqualFold(utils.UnsafeString(key), noStore):
directives.noStore = true
case utils.EqualFold(utils.UnsafeString(key), noCache):
directives.noCache = true
case utils.EqualFold(utils.UnsafeString(key), "only-if-cached"):
directives.onlyIfCached = true
case utils.EqualFold(utils.UnsafeString(key), "max-age"):
if sec, ok := parseUintDirective(value); ok {
directives.maxAgeSet = true
directives.maxAge = sec
}
case utils.EqualFold(utils.UnsafeString(key), "max-stale"):
directives.maxStaleSet = true
directives.maxStaleAny = len(value) == 0
if !directives.maxStaleAny {
if sec, ok := parseUintDirective(value); ok {
directives.maxStale = sec
}
}
case utils.EqualFold(utils.UnsafeString(key), "min-fresh"):
if sec, ok := parseUintDirective(value); ok {
directives.minFreshSet = true
directives.minFresh = sec
}
default:
// ignore unknown directives
}
})
return directives
}
func parseRequestCacheControlString(cc string) requestCacheDirectives {
return parseRequestCacheControl(utils.UnsafeBytes(cc))
}
func cachedResponseAge(e *item, now uint64) uint64 {
clampedDate := clampDateSeconds(e.date, now)
resident := uint64(0)
if e.exp != 0 {
if e.exp <= now {
resident = e.ttl + (now - e.exp)
} else {
resident = e.ttl - (e.exp - now)
}
}
dateAge := uint64(0)
if clampedDate != 0 && now > clampedDate {
dateAge = now - clampedDate
}
currentAge := max(dateAge, max(resident, e.age))
return currentAge
}
func appendWarningHeaders(h *fasthttp.ResponseHeader, servedStale, heuristicFreshness bool) { //nolint:revive // flags are intentional to represent Warning variants
if servedStale {
h.Add(fiber.HeaderWarning, `110 - "Response is stale"`)
}
if heuristicFreshness {
h.Add(fiber.HeaderWarning, `113 - "Heuristic expiration"`)
}
}
func remainingFreshness(e *item, now uint64) uint64 {
if e == nil || e.exp == 0 || now >= e.exp {
return 0
}
return e.exp - now
}
func isHeuristicFreshness(e *item, cfg *Config, entryAge uint64) bool {
const heuristicAgeThresholdSeconds = uint64(24 * time.Hour / time.Second)
if entryAge <= heuristicAgeThresholdSeconds {
return false
}
if len(e.expires) > 0 {
return false
}
cacheControl := utils.UnsafeString(e.cacheControl)
if parsedCC := parseResponseCacheControl(utils.UnsafeBytes(cacheControl)); parsedCC.maxAgeSet || parsedCC.sMaxAgeSet {
return false
}
return cfg.Expiration > 0
}
func lookupCachedHeader(headers []cachedHeader, name string) ([]byte, bool) {
for i := range headers {
if utils.EqualFold(utils.UnsafeString(headers[i].key), name) {
return headers[i].value, true
}
}
return nil, false
}
func parseHTTPDate(dateBytes []byte) (uint64, bool) {
if len(dateBytes) == 0 {
return 0, false
}
parsedDate, err := fasthttp.ParseHTTPDate(dateBytes)
if err != nil {
return 0, false
}
return safeUnixSeconds(parsedDate), true
}
func clampDateSeconds(dateSeconds, fallback uint64) uint64 {
const maxUnixSeconds = uint64(math.MaxInt64)
if dateSeconds == 0 || dateSeconds > maxUnixSeconds || dateSeconds > fallback {
return fallback
}
return dateSeconds
}
func safeUnixSeconds(t time.Time) uint64 {
sec := t.Unix()
if sec < 0 {
return 0
}
return uint64(sec)
}
func secondsToTime(sec uint64) time.Time {
var clamped int64
if sec > uint64(math.MaxInt64) {
clamped = math.MaxInt64
} else {
clamped = int64(sec)
}
return time.Unix(clamped, 0).UTC()
}
func secondsToDuration(sec uint64) time.Duration {
const maxSeconds = uint64(math.MaxInt64) / uint64(time.Second)
if sec > maxSeconds {
return time.Duration(math.MaxInt64)
}
return time.Duration(sec) * time.Second
}
func defaultKeyGenerator(c fiber.Ctx, cfg *Config) string {
v := keyBufferPool.Get()
bufPtr, ok := v.(*[]byte)
if !ok || bufPtr == nil {
b := make([]byte, 0, defaultKeyBufferCap)
bufPtr = &b
}
buf := (*bufPtr)[:0]
// 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', '=')
buf = append(buf, canonicalQueryString(c.Request().URI())...)
}
if len(cfg.KeyHeaders) > 0 {
buf = append(buf, '|', 'h', '=')
buf = append(buf, canonicalHeaderSubset(&c.Request().Header, cfg.KeyHeaders)...)
}
if len(cfg.KeyCookies) > 0 {
buf = append(buf, '|', 'c', '=')
buf = append(buf, canonicalCookieSubset(c, cfg.KeyCookies)...)
}
result := string(buf)
// Reset buffer and return to pool, but discard if it grew too large
// to prevent pool from retaining oversized buffers
if cap(buf) <= defaultKeyBufferCap*4 {
*bufPtr = buf
keyBufferPool.Put(bufPtr)
}
return result
}
func canonicalQueryString(uri *fasthttp.URI) string {
raw := uri.QueryString()
if len(raw) == 0 {
return ""
}
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(escapeKeyDelimiters(query))
}
// Fast path: single key=value pair needs no parsing or sorting
if strings.IndexByte(query, '&') < 0 {
return boundKeySegment(escapeKeyDelimiters(query))
}
// Quick count of potential parameters (ampersands + 1)
paramCount := 1
for i := 0; i < len(query); i++ {
if query[i] == '&' {
paramCount++
if paramCount > maxQueryParams {
// Too many parameters detected, hash without parsing
return boundKeySegment(escapeKeyDelimiters(query))
}
}
}
parsed, err := url.ParseQuery(query)
if err != nil {
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(escapeKeyDelimiters(query))
}
}
keys := make([]string, 0, len(parsed))
for key := range parsed {
keys = append(keys, key)
}
sort.Strings(keys)
// 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]
sort.Strings(values)
for _, value := range values {
if len(buf) > 0 {
buf = append(buf, '&')
}
escapedKey := url.QueryEscape(key)
escapedValue := url.QueryEscape(value)
// 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(escapeKeyDelimiters(query))
}
buf = append(buf, escapedKey...)
buf = append(buf, '=')
buf = append(buf, escapedValue...)
}
}
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 {
if len(names) == 0 {
return ""
}
buf := make([]byte, 0, len(names)*16)
for idx, name := range names {
if idx > 0 {
buf = append(buf, '|')
}
// Escape name (though names are normalized and trusted)
buf = append(buf, escapeKeyDelimiters(name)...)
buf = append(buf, ':')
headerValue := header.Peek(name)
// Escape value to prevent delimiter injection
escapedValue := escapeKeyDelimiters(utils.UnsafeString(headerValue))
buf = append(buf, boundKeySegment(escapedValue)...)
}
return string(buf)
}
func canonicalCookieSubset(c fiber.Ctx, names []string) string {
if len(names) == 0 {
return ""
}
buf := make([]byte, 0, len(names)*16)
for idx, name := range names {
if idx > 0 {
buf = append(buf, '|')
}
// Escape name (though names are normalized and trusted)
buf = append(buf, escapeKeyDelimiters(name)...)
buf = append(buf, ':')
cookieValue := c.Cookies(name)
// Escape value to prevent delimiter injection
escapedValue := escapeKeyDelimiters(cookieValue)
buf = append(buf, boundKeySegment(escapedValue)...)
}
return string(buf)
}
// 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 characters to escape
if !strings.ContainsAny(s, "|:\\") {
return s
}
// Escape | as \p and : as \c, and \ as \\ (backslash must be escaped first)
result := strings.ReplaceAll(s, "\\", "\\\\")
result = strings.ReplaceAll(result, "|", "\\p")
result = strings.ReplaceAll(result, ":", "\\c")
return result
}
func boundKeySegment(segment string) string {
if len(segment) <= maxKeyDimensionSegmentLength {
return segment
}
hash := sha256.Sum256(utils.UnsafeBytes(segment))
return "sha256:" + hex.EncodeToString(hash[:])
}
func parseVary(vary string) ([]string, bool) {
names := make([]string, 0, 8)
count := 0
for part := range strings.SplitSeq(vary, ",") {
name := utils.TrimSpace(utilsstrings.ToLower(part))
if name == "" {
continue
}
if name == "*" {
return nil, true
}
// Protect against DoS via excessive Vary headers
count++
if count > maxVaryHeaders {
// Too many Vary headers, treat as uncacheable (same as Vary: *)
return nil, true
}
names = append(names, name)
}
if len(names) == 0 {
return nil, false
}
sort.Strings(names)
return names, false
}
func makeBuildVaryKeyFunc(hexBufPool *sync.Pool) func([]string, *fasthttp.RequestHeader) string {
return func(names []string, hdr *fasthttp.RequestHeader) string {
sum := sha256.New()
for _, name := range names {
_, _ = sum.Write(utils.UnsafeBytes(name)) //nolint:errcheck // hash.Hash.Write for std hashes never errors
_, _ = sum.Write([]byte{0}) //nolint:errcheck // hash.Hash.Write for std hashes never errors
_, _ = sum.Write(hdr.Peek(name)) //nolint:errcheck // hash.Hash.Write for std hashes never errors
_, _ = sum.Write([]byte{0}) //nolint:errcheck // hash.Hash.Write for std hashes never errors
}
var hashBytes [sha256.Size]byte
sum.Sum(hashBytes[:0])
v := hexBufPool.Get()
bufPtr, ok := v.(*[]byte)
if !ok || bufPtr == nil {
b := make([]byte, hexLen)
bufPtr = &b
}
buf := *bufPtr
// Defensive in case someone changed Pool.New or Put a different sized buffer.
if cap(buf) < hexLen {
buf = make([]byte, hexLen)
} else {
buf = buf[:hexLen]
}
*bufPtr = buf
hex.Encode(buf, hashBytes[:])
result := "|vary|" + string(buf)
hexBufPool.Put(bufPtr)
return result
}
}
func storeVaryManifest(ctx context.Context, manager *manager, manifestKey string, names []string, exp time.Duration) error {
if len(names) == 0 {
return nil
}
data := strings.Join(names, ",")
return manager.setRaw(ctx, manifestKey, utils.UnsafeBytes(data), exp)
}
//nolint:gocritic // returning explicit values keeps the signature concise while avoiding unnecessary named results
func loadVaryManifest(ctx context.Context, manager *manager, manifestKey string) ([]string, bool, error) {
raw, err := manager.getRaw(ctx, manifestKey)
if err != nil {
if errors.Is(err, errCacheMiss) {
return nil, false, nil
}
return nil, false, err
}
manifest := utils.UnsafeString(raw)
names, hasStar := parseVary(manifest)
if hasStar {
return nil, false, nil
}
return names, len(names) > 0, nil
}
func allowsSharedCacheDirectives(cc responseCacheControl) bool {
if cc.hasPrivate {
return false
}
if cc.hasPublic || cc.sMaxAgeSet || cc.mustRevalidate || cc.proxyRevalidate {
return true
}
// RFC 9111 §4.2.2 permits Expires as an absolute expiry for cacheable responses, but for
// authenticated requests §3.6 requires an explicit shared-cache directive. Therefore,
// an Expires header alone MUST NOT allow sharing when Authorization is present.
return false
}
func allowsSharedCache(cc string) bool {
return allowsSharedCacheDirectives(parseResponseCacheControl(utils.UnsafeBytes(cc)))
}
func makeHashAuthFunc(hexBufPool *sync.Pool) func([]byte) string {
return func(authHeader []byte) string {
sum := sha256.Sum256(authHeader)
v := hexBufPool.Get()
bufPtr, ok := v.(*[]byte)
if !ok || bufPtr == nil {
b := make([]byte, hexLen)
bufPtr = &b
}
buf := *bufPtr
if cap(buf) < hexLen {
buf = make([]byte, hexLen)
} else {
buf = buf[:hexLen]
}
*bufPtr = buf
hex.Encode(buf, sum[:])
result := string(buf)
hexBufPool.Put(bufPtr)
return result
}
}