Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions configurationtypes/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ type DefaultCache struct {
MaxBodyBytes uint64 `json:"max_cacheable_body_bytes" yaml:"max_cacheable_body_bytes"`
DisableCoalescing bool `json:"disable_coalescing" yaml:"disable_coalescing"`
MappingEvictionInterval Duration `json:"mapping_eviction_interval" yaml:"mapping_eviction_interval"`
CacheSetCookie bool `json:"cache_set_cookie" yaml:"cache_set_cookie"`
}

// GetAllowedHTTPVerbs returns the allowed verbs to cache
Expand Down Expand Up @@ -406,6 +407,11 @@ func (d *DefaultCache) IsCoalescingDisable() bool {
return d.DisableCoalescing
}

// GetCacheSetCookie returns if responses carrying a Set-Cookie header can be stored
func (d *DefaultCache) GetCacheSetCookie() bool {
return d.CacheSetCookie
}

// GetMappingEvictionInterval returns the interval for mapping eviction
func (d *DefaultCache) GetMappingEvictionInterval() time.Duration {
if d.MappingEvictionInterval.Duration == 0 {
Expand Down Expand Up @@ -441,6 +447,7 @@ type DefaultCacheInterface interface {
GetMaxBodyBytes() uint64
IsCoalescingDisable() bool
GetMappingEvictionInterval() time.Duration
GetCacheSetCookie() bool
}

// APIEndpoint is the minimal structure to define an endpoint
Expand Down
5 changes: 5 additions & 0 deletions docs/website/content/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ The badger prefix configure the badger storage. (e.g. `default_cache.badger`).
* **configuration**: Configure Badger directly in your configuration file.
[See the Badger configuration for the options]({{% relref "/docs/storages/badger" %}})

#### Cache Set-Cookie
The cache_set_cookie prefix allows storing responses carrying a `Set-Cookie` header. (e.g. `default_cache.cache_set_cookie`).
By default such responses are not stored, so one user's cookie can't be replayed to other clients. Only enable it if your upstream never sends user-specific cookies on cacheable responses.
default: `false`

#### Default Cache-Control
The default_cache_control prefix configure the Cache-Control to set if the upstream server doesn't return any. (e.g. `default_cache.default_cache_control`).
example: `public, max-age=3600`
Expand Down
13 changes: 13 additions & 0 deletions pkg/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,19 @@ func (s *SouinBaseHandler) Store(
return nil
}

// A Set-Cookie response is usually tied to a single user; storing it in a
// shared cache would replay one user's cookie to every subsequent client.
// RFC 9111 §7.3 allows caching such responses, but most reverse proxies
// (nginx, Varnish, Fastly, Cloudflare) treat them as uncacheable by default,
// so we don't store them unless the cache_set_cookie option is enabled. The
// exception is an explicit no-cache="Set-Cookie" directive: the field is
// stripped from the stored response below, so there is nothing to leak and
// the rest of the response can still be cached.
if customWriter.Header().Get("Set-Cookie") != "" && !responseCc.NoCache["Set-Cookie"] && !s.Configuration.GetDefaultCache().GetCacheSetCookie() {
customWriter.Header().Set("Cache-Status", fmt.Sprintf("%s; fwd=uri-miss; key=%s; detail=UNCACHEABLE-SET-COOKIE", rq.Context().Value(context.CacheName), rfc.GetCacheKeyFromCtx(rq.Context())))
return nil
}

currentMatchedURL := s.DefaultMatchedUrl
if regexpURL := s.RegexpUrls.FindString(rq.Host + rq.URL.Path); regexpURL != "" {
u := s.Configuration.GetUrls()[regexpURL]
Expand Down
96 changes: 96 additions & 0 deletions pkg/middleware/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,3 +401,99 @@ func TestHopByHopHeadersAreNotCached(t *testing.T) {
}
}
}

// cacheableNext returns a handlerFunc writing a cacheable response, optionally
// carrying a Set-Cookie header.
func cacheableNext(body string, setCookie string) handlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Cache-Control", "public, max-age=60")
if setCookie != "" {
w.Header().Set("Set-Cookie", setCookie)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(body))
return nil
}
}

// TestSetCookieResponseIsNotStored ensures a response carrying a Set-Cookie
// header is never written to the cache, so one user's cookie can't be replayed
// to other clients. Most reverse proxies (nginx, Varnish, Fastly, Cloudflare)
// treat such responses as uncacheable by default; Souin matches that.
func TestSetCookieResponseIsNotStored(t *testing.T) {
handler, storer := newTestHandler(t)

req := httptest.NewRequest(http.MethodGet, "http://example.com/with-cookie", nil)
rec := httptest.NewRecorder()

if err := handler.ServeHTTP(rec, req, cacheableNext("BODY", "session=abc123")); err != nil {
t.Fatalf("ServeHTTP failed: %v", err)
}

for _, k := range storer.ListKeys() {
if strings.Contains(k, "/with-cookie") {
t.Errorf("Set-Cookie response must not be stored, found key: %q", k)
}
}
if cs := rec.Header().Get("Cache-Status"); !strings.Contains(cs, "UNCACHEABLE-SET-COOKIE") {
t.Errorf("expected Cache-Status detail UNCACHEABLE-SET-COOKIE, got %q", cs)
}
}

// TestResponseWithoutSetCookieIsStored is the positive control for
// TestSetCookieResponseIsNotStored: the same response without a Set-Cookie
// header must still be cached, proving the guard is specific to Set-Cookie.
func TestResponseWithoutSetCookieIsStored(t *testing.T) {
handler, storer := newTestHandler(t)

req := httptest.NewRequest(http.MethodGet, "http://example.com/no-cookie", nil)
rec := httptest.NewRecorder()

if err := handler.ServeHTTP(rec, req, cacheableNext("BODY", "")); err != nil {
t.Fatalf("ServeHTTP failed: %v", err)
}

stored := false
for _, k := range storer.ListKeys() {
if strings.Contains(k, "/no-cookie") {
stored = true
break
}
}
if !stored {
t.Error("a cacheable response without Set-Cookie should be stored, found no matching key")
}
}

// TestSetCookieResponseIsStoredWhenEnabled ensures the cache_set_cookie
// option restores the previous behavior of storing Set-Cookie responses.
func TestSetCookieResponseIsStoredWhenEnabled(t *testing.T) {
cfg := newTestConfig()
cfg.DefaultCache.CacheSetCookie = true
handler := NewHTTPCacheHandler(cfg)
if len(handler.Storers) == 0 {
t.Fatal("expected at least one storer to be registered")
}
storer := handler.Storers[0]

req := httptest.NewRequest(http.MethodGet, "http://example.com/with-cookie-enabled", nil)
rec := httptest.NewRecorder()

if err := handler.ServeHTTP(rec, req, cacheableNext("BODY", "session=abc123")); err != nil {
t.Fatalf("ServeHTTP failed: %v", err)
}

stored := false
for _, k := range storer.ListKeys() {
if strings.Contains(k, "/with-cookie-enabled") {
stored = true
break
}
}
if !stored {
t.Error("a Set-Cookie response should be stored when cache_set_cookie is enabled, found no matching key")
}
if cs := rec.Header().Get("Cache-Status"); strings.Contains(cs, "UNCACHEABLE-SET-COOKIE") {
t.Errorf("unexpected Cache-Status detail UNCACHEABLE-SET-COOKIE with cache_set_cookie enabled, got %q", cs)
}
}
9 changes: 9 additions & 0 deletions plugins/caddy/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ type DefaultCache struct {
DisableCoalescing bool `json:"disable_coalescing"`
// MappingEvictionInterval interval between eviction
MappingEvictionInterval configurationtypes.Duration `json:"mapping_eviction_interval"`
// Store responses carrying a Set-Cookie header.
CacheSetCookie bool `json:"cache_set_cookie"`
}

// GetAllowedHTTPVerbs returns the allowed verbs to cache
Expand Down Expand Up @@ -189,6 +191,11 @@ func (d *DefaultCache) IsCoalescingDisable() bool {
return d.DisableCoalescing
}

// GetCacheSetCookie returns if responses carrying a Set-Cookie header can be stored
func (d *DefaultCache) GetCacheSetCookie() bool {
return d.CacheSetCookie
}

// Configuration holder
type Configuration struct {
// Default cache to fallback on when none are redefined.
Expand Down Expand Up @@ -774,6 +781,8 @@ func parseConfiguration(cfg *Configuration, h *caddyfile.Dispenser, isGlobal boo
}
case "disable_coalescing":
cfg.DefaultCache.DisableCoalescing = true
case "cache_set_cookie":
cfg.DefaultCache.CacheSetCookie = true
case "mapping_eviction_interval":
args := h.RemainingArgs()
interval, err := time.ParseDuration(args[0])
Expand Down
4 changes: 4 additions & 0 deletions plugins/caddy/httpcache.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ func (s *SouinCaddyMiddleware) FromApp(app *SouinApp) error {
MappingEvictionInterval: app.MappingEvictionInterval,
CacheName: app.CacheName,
Timeout: app.Timeout,
CacheSetCookie: app.DefaultCache.CacheSetCookie,
}
return nil
}
Expand Down Expand Up @@ -229,6 +230,9 @@ func (s *SouinCaddyMiddleware) FromApp(app *SouinApp) error {
if dc.CacheName == "" {
s.Configuration.DefaultCache.CacheName = appDc.CacheName
}
if !dc.CacheSetCookie {
s.Configuration.DefaultCache.CacheSetCookie = appDc.CacheSetCookie
}
if isProviderEmpty(dc.Badger) && isProviderEmpty(dc.Etcd) && isProviderEmpty(dc.Nats) && isProviderEmpty(dc.Nuts) && isProviderEmpty(dc.Olric) && isProviderEmpty(dc.Otter) && isProviderEmpty(dc.Redis) && isProviderEmpty(dc.SimpleFS) {
s.Configuration.DefaultCache.Distributed = appDc.Distributed
s.Configuration.DefaultCache.Olric = appDc.Olric
Expand Down
40 changes: 40 additions & 0 deletions plugins/caddy/httpcache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,46 @@ func TestQueryString(t *testing.T) {
}
}

func TestCacheSetCookie(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
{
admin localhost:2999
http_port 9080
https_port 9443
cache {
ttl 1000s
}
}
localhost:9080 {
route /set-cookie-default {
cache
header Set-Cookie "session=abc123"
respond "Hello, Set-Cookie!"
}
route /set-cookie-enabled {
cache {
cache_set_cookie
}
header Set-Cookie "session=abc123"
respond "Hello, Set-Cookie!"
}
}`, "caddyfile")

resp1, _ := tester.AssertGetResponse(`http://localhost:9080/set-cookie-default`, 200, "Hello, Set-Cookie!")
if resp1.Header.Get("Cache-Status") != "Souin; fwd=uri-miss; key=GET-http-localhost:9080-/set-cookie-default; detail=UNCACHEABLE-SET-COOKIE" {
t.Errorf("unexpected Cache-Status header %v", resp1.Header.Get("Cache-Status"))
}

resp2, _ := tester.AssertGetResponse(`http://localhost:9080/set-cookie-enabled`, 200, "Hello, Set-Cookie!")
if resp2.Header.Get("Cache-Status") != "Souin; fwd=uri-miss; stored; key=GET-http-localhost:9080-/set-cookie-enabled" {
t.Errorf("unexpected Cache-Status header %v", resp2.Header.Get("Cache-Status"))
}

resp3, _ := tester.AssertGetResponse(`http://localhost:9080/set-cookie-enabled`, 200, "Hello, Set-Cookie!")
compareHit(t, resp3.Header, "GET-http-localhost:9080-/set-cookie-enabled", "DEFAULT", 1000)
}

func TestQueryStringSort(t *testing.T) {
tester := caddytest.NewTester(t)
tester.InitServer(`
Expand Down
2 changes: 2 additions & 0 deletions plugins/kratos/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,8 @@ func parseDefaultCache(dcConfiguration map[string]config.Value) *configurationty
if ok {
dc.MaxBodyBytes = mbb
}
case "cache_set_cookie":
dc.CacheSetCookie, _ = defaultCacheV.Bool()
}
}

Expand Down
2 changes: 2 additions & 0 deletions plugins/souin/agnostic/configuration_parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,8 @@ func parseDefaultCache(dcConfiguration map[string]interface{}) *configurationtyp
dc.DefaultCacheControl, _ = defaultCacheV.(string)
case "max_cacheable_body_bytes":
dc.MaxBodyBytes, _ = defaultCacheV.(uint64)
case "cache_set_cookie":
dc.CacheSetCookie, _ = defaultCacheV.(bool)
}
}

Expand Down
Loading