Skip to content

Surrogate-key purge deletes other tags' index entries (unanchored regex), leaving their responses unpurgeable #867

Description

@silverbackdan

Summary

Purging by surrogate key also removes the surrogate-key index entries of other tags. Any tag whose name contains the purged tag as a substring loses its index entry. The cached responses behind those tags are not removed. After that, no purge by their own key can reach them, and they are served until their TTL expires.

Seen on v1.7.9 (the latest release) with the Caddy plugin and the default otter storage.

Where

pkg/api/souin.go, the PURGE branch of HandleRequest (v1.7.9, around line 360):

ck, surrogateKeys := s.surrogateStorage.Purge(r.Header)
for _, k := range ck {
    s.BulkDelete(k, true)
}
for _, k := range surrogateKeys {
    s.BulkDelete("SURROGATE_"+k, true)
}

BulkDelete ends in s.Delete(key):

func (s *SouinAPI) Delete(key string) {
    _, err := regexp.Compile(key)
    for _, current := range s.storers {
        if err != nil {
            current.Delete(key)
        } else {
            current.DeleteMany(key)
        }
    }
}

Most tag names compile as a regular expression, and DeleteMany matches unanchored. So purging tag /items runs DeleteMany("SURROGATE_/items"), and that deletes SURROGATE_/items/1, SURROGATE_/items/2 and so on.

This hurts with API Platform in particular, whose purger always sends the collection IRI next to the item IRI (Surrogate-Key: /items, /items/2). Every write to one item therefore wipes the index entry of every item in that collection.

The cache-key loop has the same problem: BulkDelete(k)Delete(k) compiles the cache key as a regex. Keys containing +, * or . (for example an Accept: application/ld+json value in the key) can match and delete unrelated keys. That only causes extra misses rather than stale content, but the same fix should cover it.

Reproduction

A Caddy site with the cache handler, the admin API enabled, and a backend returning Surrogate-Key: /items/<id> on GET /items/<id>:

curl -s -X PURGE http://localhost:2019/souin-api/souin/flush
curl -s -o /dev/null https://site/items/1
curl -s -o /dev/null https://site/items/2
curl -s http://localhost:2019/souin-api/souin/surrogate_keys   # lists /items/1 and /items/2

curl -s -X PURGE -H 'Surrogate-Key: /items, /items/2' http://localhost:2019/souin-api/souin

curl -s http://localhost:2019/souin-api/souin/surrogate_keys   # /items/1 is gone too
curl -s -X PURGE -H 'Surrogate-Key: /items/1' http://localhost:2019/souin-api/souin
curl -s -D - -o /dev/null https://site/items/1 | grep -i cache-status
# Souin; hit   <- /items/1 can no longer be purged

Observed on a real deployment, with /_api/_/component_groups/<uuid> as the item tags: after the second step, no item tags are left in surrogate_keys, and item 1 is still a hit after purging its own key. In production, about 60% of cached entries had ended up outside their own tag this way.

Expected

A surrogate-key purge removes the responses stored under that tag and the index entry for that tag only. Tags that merely contain it as a substring are untouched.

Suggested fix

  • In the PURGE handler, remove index entries with each storer's exact Delete("SURROGATE_"+k), not through SouinAPI.Delete.
  • In BulkDelete, delete the cache key with exact current.Delete(key). Keep regex deletion for the explicit regex endpoint (PURGE /souin-api/souin/<regex>) only, where the caller asked for it.
  • If regex deletion has to stay on these paths, anchor and quote it: "^" + regexp.QuoteMeta(key) + "$".

A test that fails on v1.7.9:

// Purging tag "/a" must not remove the index entry of tag "/a/1".
// 1. store response R1 tagged "/a/1" and response R2 tagged "/a"
// 2. PURGE with Surrogate-Key: "/a"
// 3. assert R2 is gone
// 4. assert the surrogate storage still lists R1 under "/a/1"
// 5. PURGE with Surrogate-Key: "/a/1"
// 6. assert R1 is gone

Plus the same test with cache keys containing + and *, asserting that a purge removes only its own key.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions