| new file mode 100644 |
| index 000000000..1b8879f02 |
| --- /dev/null |
| +++ b/internal/edge/purge_header_test.go |
| @@ -0,0 +1,1152 @@ |
| +package edge |
| + |
| +import ( |
| + "bufio" |
| + "bytes" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/http/httptrace" |
| + "net/textproto" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "syscall" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// purgeEdge builds an edge with a cache in front of one addr-backed route for |
| +// host, whose upstream runs h. hibernate=false is the direct-proxy shape (a |
| +// plain `routed` app behind the edge — tito production), true routes the |
| +// request through the activator instead. |
| +func purgeEdge(t *testing.T, host string, hibernate bool, h http.HandlerFunc) (*Edge, *registry.Registry) { |
| + t.Helper() |
| + backend := httptest.NewServer(h) |
| + t.Cleanup(backend.Close) |
| + reg := openReg(t) |
| + rt := registry.Route{Host: host, Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: hibernate} |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + var act Activator |
| + if hibernate { |
| + act = &fakeActivator{} |
| + } |
| + e, err := New(reg, act) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + // EnableCache deliberately AFTER New (which already ran Reload) — the |
| + // production ordering in RunWithActivator. A purge hook that captured |
| + // e.cache at reload time would capture nil and never purge anything. |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + return e, reg |
| +} |
| + |
| +// seed puts a trivially-cacheable entry for one URL so a purge has something |
| +// to remove. |
| +func seed(t *testing.T, c *Cache, host, path, rawQuery, body string) { |
| + t.Helper() |
| + c.Put(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: rawQuery}, "", &CachedResponse{ |
| + StatusCode: 200, |
| + Header: http.Header{"Content-Type": {"text/html"}}, |
| + Body: []byte(body), |
| + StoredAt: time.Now(), |
| + TTL: time.Minute, |
| + }) |
| +} |
| + |
| +func cached(c *Cache, host, path, rawQuery string) bool { |
| + _, ok := c.get(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: rawQuery}) |
| + return ok |
| +} |
| + |
| +// TestPurgeHeaderOnPOSTPurgesAndIsStripped is the headline contract: a write |
| +// (POST — never a cacheable method, so it never touches the GET/HEAD caching |
| +// path at all) purges the URLs it names, and the client never sees the header. |
| +func TestPurgeHeaderOnPOSTPurgesAndIsStripped(t *testing.T) { |
| + e, _ := purgeEdge(t, "w.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/events/42, /events/42?lang=fr") |
| + w.Header().Set("Location", "/events/42") |
| + w.WriteHeader(http.StatusFound) |
| + }) |
| + seed(t, e.cache, "w.example.com", "/events/42", "", "old") |
| + seed(t, e.cache, "w.example.com", "/events/42", "lang=fr", "vieux") |
| + seed(t, e.cache, "w.example.com", "/events/43", "", "untouched") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://w.example.com/events/42/edit", strings.NewReader("x=1"))) |
| + |
| + if rec.Code != http.StatusFound { |
| + t.Fatalf("status = %d, want 302", rec.Code) |
| + } |
| + if got := rec.Header().Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("Carlos-Purge reached the client: %q", got) |
| + } |
| + if cached(e.cache, "w.example.com", "/events/42", "") { |
| + t.Fatal("/events/42 still cached after purge") |
| + } |
| + if cached(e.cache, "w.example.com", "/events/42", "lang=fr") { |
| + t.Fatal("/events/42?lang=fr still cached after purge") |
| + } |
| + if !cached(e.cache, "w.example.com", "/events/43", "") { |
| + t.Fatal("/events/43 was purged but nothing named it") |
| + } |
| +} |
| + |
| +// TestPurgeHeaderOnDirectProxyPath pins the shape this feature exists for: |
| +// an addr-backed, NON-hibernating route (tito production) — no activator in |
| +// the picture at all. |
| +func TestPurgeHeaderOnDirectProxyPath(t *testing.T) { |
| + e, _ := purgeEdge(t, "tito.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/tickets") |
| + io.WriteString(w, "ok") |
| + }) |
| + seed(t, e.cache, "tito.example.com", "/tickets", "", "old") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://tito.example.com/tickets/buy", nil)) |
| + |
| + if cached(e.cache, "tito.example.com", "/tickets", "") { |
| + t.Fatal("direct-proxy path did not honour Carlos-Purge") |
| + } |
| + if got := rec.Header().Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("Carlos-Purge reached the client: %q", got) |
| + } |
| +} |
| + |
| +// TestPurgeHeaderOnHibernatingPath proves the same on the activator path, |
| +// where the activator wraps the very same proxy. |
| +func TestPurgeHeaderOnHibernatingPath(t *testing.T) { |
| + e, _ := purgeEdge(t, "hib.example.com", true, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/tickets") |
| + io.WriteString(w, "ok") |
| + }) |
| + seed(t, e.cache, "hib.example.com", "/tickets", "", "old") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://hib.example.com/tickets/buy", nil)) |
| + |
| + if cached(e.cache, "hib.example.com", "/tickets", "") { |
| + t.Fatal("hibernating/activator path did not honour Carlos-Purge") |
| + } |
| + if got := rec.Header().Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("Carlos-Purge reached the client: %q", got) |
| + } |
| +} |
| + |
| +// TestPurgeAppliesBeforeStore is the ordering regression: a cacheable GET |
| +// whose own response purges its own URL must end with the NEW response |
| +// cached, not with an empty entry. Purge runs in ModifyResponse, strictly |
| +// before maybeStore sees the response — reverse the two and this GET purges |
| +// exactly what it just stored, so the URL can never stay cached and a |
| +// hibernating route wakes on every single request. |
| +// |
| +// The URL is deliberately NOT pre-seeded: a seeded entry would be a cache hit |
| +// and the request would never reach the backend at all. |
| +func TestPurgeAppliesBeforeStore(t *testing.T) { |
| + e, _ := purgeEdge(t, "order.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/page, /index") |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + io.WriteString(w, "fresh") |
| + }) |
| + seed(t, e.cache, "order.example.com", "/index", "", "stale index") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://order.example.com/page", nil)) |
| + if rec.Body.String() != "fresh" { |
| + t.Fatalf("body = %q, want %q", rec.Body.String(), "fresh") |
| + } |
| + |
| + got, ok := e.cache.get(CacheKey{Host: "order.example.com", Method: "GET", Path: "/page"}) |
| + if !ok { |
| + t.Fatal("nothing cached: the purge ran AFTER maybeStore and deleted what it just stored") |
| + } |
| + if string(got.Body) != "fresh" { |
| + t.Fatalf("cached body = %q, want %q", got.Body, "fresh") |
| + } |
| + if v := got.Header.Get("Carlos-Purge"); v != "" { |
| + t.Fatalf("Carlos-Purge was stored in the cached response: %q", v) |
| + } |
| + // The other target in the same declaration really was purged — proving |
| + // the entry above survived because of ordering, not because the purge |
| + // silently did nothing. |
| + if cached(e.cache, "order.example.com", "/index", "") { |
| + t.Fatal("/index survived: the purge did not run at all") |
| + } |
| +} |
| + |
| +// TestPurgeHeaderStrippedFromCachedReplay — the second half of "the client |
| +// never sees it": a response stored and later replayed from cache must not |
| +// carry the header either. |
| +func TestPurgeHeaderStrippedFromCachedReplay(t *testing.T) { |
| + e, _ := purgeEdge(t, "replay.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/other") |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + io.WriteString(w, "page") |
| + }) |
| + |
| + rec1 := httptest.NewRecorder() |
| + e.ServeHTTP(rec1, httptest.NewRequest("GET", "http://replay.example.com/page", nil)) |
| + if got := rec1.Header().Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("pass-through response carried Carlos-Purge: %q", got) |
| + } |
| + rec2 := httptest.NewRecorder() |
| + e.ServeHTTP(rec2, httptest.NewRequest("GET", "http://replay.example.com/page", nil)) |
| + if rec2.Body.String() != "page" { |
| + t.Fatalf("second request body = %q, want a cache hit of %q", rec2.Body.String(), "page") |
| + } |
| + if got := rec2.Header().Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("cached replay carried Carlos-Purge: %q", got) |
| + } |
| +} |
| + |
| +// TestPurgeHeaderStarPurgesHost — `*` maps to Cache.PurgeHost, and only for |
| +// the host being served. |
| +func TestPurgeHeaderStarPurgesHost(t *testing.T) { |
| + e, _ := purgeEdge(t, "star.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "*") |
| + w.WriteHeader(http.StatusNoContent) |
| + }) |
| + seed(t, e.cache, "star.example.com", "/a", "", "a") |
| + seed(t, e.cache, "star.example.com", "/b", "x=1", "b") |
| + seed(t, e.cache, "other.example.com", "/a", "", "other") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://star.example.com/deploy", nil)) |
| + |
| + if cached(e.cache, "star.example.com", "/a", "") || cached(e.cache, "star.example.com", "/b", "x=1") { |
| + t.Fatal("* did not purge the whole host") |
| + } |
| + if !cached(e.cache, "other.example.com", "/a", "") { |
| + t.Fatal("* purged another host's entries") |
| + } |
| +} |
| + |
| +// TestPurgeHeaderCannotTouchAnotherHost — the isolation half. Every shape |
| +// that smuggles an authority must be IGNORED, never reinterpreted: neither |
| +// the named foreign host's entry nor the serving host's same-path entry may |
| +// be touched by them. |
| +func TestPurgeHeaderCannotTouchAnotherHost(t *testing.T) { |
| + smugglers := []string{ |
| + "https://a.example.com/x", |
| + "http://a.example.com/x", |
| + "//a.example.com/x", |
| + `/\a.example.com/x`, |
| + "a.example.com/x", |
| + "x", |
| + "", |
| + "a.example.com", |
| + "*.a.example.com", |
| + } |
| + for _, target := range smugglers { |
| + t.Run(fmt.Sprintf("%q", target), func(t *testing.T) { |
| + e, _ := purgeEdge(t, "b.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", target) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "a.example.com", "/x", "", "host A") |
| + seed(t, e.cache, "b.example.com", "/x", "", "host B") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://b.example.com/write", nil)) |
| + |
| + if !cached(e.cache, "a.example.com", "/x", "") { |
| + t.Fatalf("target %q purged another host's entry", target) |
| + } |
| + if !cached(e.cache, "b.example.com", "/x", "") { |
| + t.Fatalf("target %q was reinterpreted as the path /x on the serving host", target) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestPurgeHeaderMixedValidAndSmuggled — a rejected target does not stop the |
| +// valid ones beside it from being honoured. |
| +func TestPurgeHeaderMixedValidAndSmuggled(t *testing.T) { |
| + e, _ := purgeEdge(t, "mix.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "https://a.example.com/x, /good, //a.example.com/y") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "a.example.com", "/x", "", "A x") |
| + seed(t, e.cache, "a.example.com", "/y", "", "A y") |
| + seed(t, e.cache, "mix.example.com", "/good", "", "good") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://mix.example.com/write", nil)) |
| + |
| + if cached(e.cache, "mix.example.com", "/good", "") { |
| + t.Fatal("/good was not purged: one bad target discarded the whole header") |
| + } |
| + if !cached(e.cache, "a.example.com", "/x", "") || !cached(e.cache, "a.example.com", "/y", "") { |
| + t.Fatal("a smuggled target reached another host") |
| + } |
| +} |
| + |
| +// TestListValuedPurgeHeader — the fourth instance of this package's |
| +// list-valued-header bug class, caught before merge this time. The header |
| +// split across two Add() calls must be fully honoured. |
| +func TestListValuedPurgeHeader(t *testing.T) { |
| + e, _ := purgeEdge(t, "list.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Add("Carlos-Purge", "/a") |
| + w.Header().Add("Carlos-Purge", "/b, /c") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "list.example.com", "/a", "", "a") |
| + seed(t, e.cache, "list.example.com", "/b", "", "b") |
| + seed(t, e.cache, "list.example.com", "/c", "", "c") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://list.example.com/write", nil)) |
| + |
| + for _, p := range []string{"/a", "/b", "/c"} { |
| + if cached(e.cache, "list.example.com", p, "") { |
| + t.Fatalf("%s survived: only the first Carlos-Purge line was read", p) |
| + } |
| + } |
| + if got := rec.Header().Values("Carlos-Purge"); len(got) != 0 { |
| + t.Fatalf("Carlos-Purge lines reached the client: %v", got) |
| + } |
| +} |
| + |
| +// TestListValuedPurgeHeaderStarOnSecondLine — `*` hiding on a later line |
| +// still purges the whole host. |
| +func TestListValuedPurgeHeaderStarOnSecondLine(t *testing.T) { |
| + e, _ := purgeEdge(t, "list2.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Add("Carlos-Purge", "/a") |
| + w.Header().Add("Carlos-Purge", "*") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "list2.example.com", "/a", "", "a") |
| + seed(t, e.cache, "list2.example.com", "/z", "", "z") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://list2.example.com/write", nil)) |
| + _ = rec |
| + |
| + if cached(e.cache, "list2.example.com", "/z", "") { |
| + t.Fatal("* on the second header line was not honoured") |
| + } |
| +} |
| + |
| +// TestPurgeHeaderNonCanonicalCase — Go canonicalizes header names off the |
| +// wire, so an app writing the lowercase form still lands on Carlos-Purge. |
| +func TestPurgeHeaderNonCanonicalCase(t *testing.T) { |
| + e, _ := purgeEdge(t, "case.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header()["carlos-purge"] = []string{"/a"} |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "case.example.com", "/a", "", "a") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://case.example.com/write", nil)) |
| + |
| + if cached(e.cache, "case.example.com", "/a", "") { |
| + t.Fatal("lowercase carlos-purge was not honoured") |
| + } |
| + if got := rec.Header().Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("Carlos-Purge reached the client: %q", got) |
| + } |
| +} |
| + |
| +// TestPurgeHeaderEmptyAndWhitespaceValues — an empty header, and a header of |
| +// nothing but separators and spaces, purge nothing and break nothing. |
| +func TestPurgeHeaderEmptyAndWhitespaceValues(t *testing.T) { |
| + for _, value := range []string{"", " ", ",,,", " , , "} { |
| + t.Run(fmt.Sprintf("%q", value), func(t *testing.T) { |
| + e, _ := purgeEdge(t, "empty.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", value) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "empty.example.com", "/a", "", "a") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://empty.example.com/write", nil)) |
| + |
| + if !cached(e.cache, "empty.example.com", "/a", "") { |
| + t.Fatalf("value %q purged something", value) |
| + } |
| + if got := rec.Header().Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("Carlos-Purge reached the client: %q", got) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestPurgeHeaderSurroundingWhitespace — targets are trimmed, so the |
| +// human-readable ", " form and a tab-padded one both work. |
| +func TestPurgeHeaderSurroundingWhitespace(t *testing.T) { |
| + e, _ := purgeEdge(t, "ws.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", " /a ,\t/b\t") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "ws.example.com", "/a", "", "a") |
| + seed(t, e.cache, "ws.example.com", "/b", "", "b") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ws.example.com/write", nil)) |
| + _ = rec |
| + |
| + if cached(e.cache, "ws.example.com", "/a", "") || cached(e.cache, "ws.example.com", "/b", "") { |
| + t.Fatal("whitespace-padded targets were not honoured") |
| + } |
| +} |
| + |
| +// TestPurgeHeaderOnNon2xx — the purge is decoupled from cacheability: a 302 |
| +// and a 404 both purge, and neither is stored. |
| +func TestPurgeHeaderOnNon2xx(t *testing.T) { |
| + for _, status := range []int{http.StatusFound, http.StatusNotFound, http.StatusInternalServerError} { |
| + t.Run(http.StatusText(status), func(t *testing.T) { |
| + e, _ := purgeEdge(t, "nx.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/a") |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.WriteHeader(status) |
| + io.WriteString(w, "body") |
| + }) |
| + seed(t, e.cache, "nx.example.com", "/a", "", "a") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("GET", "http://nx.example.com/thing", nil)) |
| + if rec.Code != status { |
| + t.Fatalf("status = %d, want %d", rec.Code, status) |
| + } |
| + if cached(e.cache, "nx.example.com", "/a", "") { |
| + t.Fatalf("a %d response did not purge", status) |
| + } |
| + if cached(e.cache, "nx.example.com", "/thing", "") { |
| + t.Fatalf("a %d response was cached", status) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestPurgeHeaderBoundsDegradeToHost — over either bound the edge over-purges |
| +// (whole host) rather than partially applying. Freshness above cleverness. |
| +func TestPurgeHeaderBoundsDegradeToHost(t *testing.T) { |
| + tooMany := make([]string, maxPurgeTargets+1) |
| + for i := range tooMany { |
| + tooMany[i] = fmt.Sprintf("/p%d", i) |
| + } |
| + cases := map[string]string{ |
| + "too many targets": strings.Join(tooMany, ","), |
| + "oversized header": "/" + strings.Repeat("a", maxPurgeHeaderBytes), |
| + } |
| + for name, value := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + e, _ := purgeEdge(t, "cap.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", value) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "cap.example.com", "/untouched", "", "u") |
| + seed(t, e.cache, "other.example.com", "/untouched", "", "o") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://cap.example.com/write", nil)) |
| + |
| + if cached(e.cache, "cap.example.com", "/untouched", "") { |
| + t.Fatal("over-bound header did not degrade to a whole-host purge") |
| + } |
| + if !cached(e.cache, "other.example.com", "/untouched", "") { |
| + t.Fatal("degrading to a host purge crossed the host boundary") |
| + } |
| + if got := rec.Header().Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("Carlos-Purge reached the client: %q", got) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestPurgeHeaderAtBound — exactly maxPurgeTargets is honoured target by |
| +// target, not degraded. Pins the boundary from the other side. |
| +func TestPurgeHeaderAtBound(t *testing.T) { |
| + targets := make([]string, maxPurgeTargets) |
| + for i := range targets { |
| + targets[i] = fmt.Sprintf("/p%d", i) |
| + } |
| + e, _ := purgeEdge(t, "atcap.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", strings.Join(targets, ",")) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "atcap.example.com", "/p0", "", "p0") |
| + seed(t, e.cache, "atcap.example.com", "/untouched", "", "u") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://atcap.example.com/write", nil)) |
| + _ = rec |
| + |
| + if cached(e.cache, "atcap.example.com", "/p0", "") { |
| + t.Fatal("/p0 was not purged at exactly the target bound") |
| + } |
| + if !cached(e.cache, "atcap.example.com", "/untouched", "") { |
| + t.Fatal("exactly maxPurgeTargets degraded to a whole-host purge") |
| + } |
| +} |
| + |
| +// TestPurgeHeaderNeverReadFromRequest — a client-supplied Carlos-Purge is |
| +// exactly the externally-reachable purge endpoint the parent spec rejects as |
| +// a wake-storm vector. It must do nothing. |
| +func TestPurgeHeaderNeverReadFromRequest(t *testing.T) { |
| + e, _ := purgeEdge(t, "req.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + io.WriteString(w, "ok") |
| + }) |
| + seed(t, e.cache, "req.example.com", "/a", "", "a") |
| + |
| + req := httptest.NewRequest("POST", "http://req.example.com/write", nil) |
| + req.Header.Set("Carlos-Purge", "*") |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, req) |
| + |
| + if !cached(e.cache, "req.example.com", "/a", "") { |
| + t.Fatal("a request-borne Carlos-Purge purged the cache") |
| + } |
| +} |
| + |
| +// TestPurgeHeaderWithoutCacheIsStillStripped — with caching disabled there is |
| +// nothing to purge, but the header is an edge-internal signal and must still |
| +// never reach a client. |
| +func TestPurgeHeaderWithoutCacheIsStillStripped(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/a") |
| + io.WriteString(w, "ok") |
| + })) |
| + t.Cleanup(backend.Close) |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "nocache.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://nocache.example.com/write", nil)) |
| + if got := rec.Header().Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("Carlos-Purge reached the client with caching disabled: %q", got) |
| + } |
| +} |
| + |
| +// TestPurgeHeaderOnUpgradeBypassPath — the upgrade path skips caching AND the |
| +// recordingResponseWriter entirely (edge.go's isUpgradeRequest branch), so it |
| +// is the one proxied path where a purge hook placed anywhere but |
| +// ModifyResponse would silently not fire. This drives a REAL 101 handshake |
| +// over a real socket, which is the only way that branch is exercised end to |
| +// end. |
| +func TestPurgeHeaderOnUpgradeBypassPath(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + conn, buf, err := w.(http.Hijacker).Hijack() |
| + if err != nil { |
| + t.Errorf("backend hijack: %v", err) |
| + return |
| + } |
| + defer conn.Close() |
| + fmt.Fprint(buf, "HTTP/1.1 101 Switching Protocols\r\n"+ |
| + "Upgrade: carlos-test\r\n"+ |
| + "Connection: Upgrade\r\n"+ |
| + "Carlos-Purge: /live\r\n\r\n") |
| + buf.Flush() |
| + // Hold the tunnel open long enough for the client to read the |
| + // handshake response. |
| + io.Copy(io.Discard, buf) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "ws.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + seed(t, e.cache, "ws.example.com", "/live", "", "live") |
| + |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + conn, err := net.Dial("tcp", srv.Listener.Addr().String()) |
| + if err != nil { |
| + t.Fatalf("dial edge: %v", err) |
| + } |
| + defer conn.Close() |
| + fmt.Fprint(conn, "GET /socket HTTP/1.1\r\n"+ |
| + "Host: ws.example.com\r\n"+ |
| + "Upgrade: carlos-test\r\n"+ |
| + "Connection: Upgrade\r\n\r\n") |
| + conn.SetReadDeadline(time.Now().Add(10 * time.Second)) |
| + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) |
| + if err != nil { |
| + t.Fatalf("read upgrade response: %v", err) |
| + } |
| + if resp.StatusCode != http.StatusSwitchingProtocols { |
| + t.Fatalf("status = %d, want 101", resp.StatusCode) |
| + } |
| + if got := resp.Header.Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("Carlos-Purge survived onto the upgrade response: %q", got) |
| + } |
| + if cached(e.cache, "ws.example.com", "/live", "") { |
| + t.Fatal("upgrade-bypass path did not honour Carlos-Purge") |
| + } |
| +} |
| + |
| +// TestPurgeHeaderPurgesEveryVaryVariant — a target names a URL, and purging |
| +// it drops ALL stored variants of that URL (existing PurgeURL semantics). |
| +func TestPurgeHeaderPurgesEveryVaryVariant(t *testing.T) { |
| + e, _ := purgeEdge(t, "vary.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/page") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + base := CacheKey{Host: "vary.example.com", Method: "GET", Path: "/page"} |
| + for _, lang := range []string{"en", "fr"} { |
| + k := base |
| + k.VaryValue = "Accept-Language=" + lang + ";" |
| + e.cache.Put(k, "Accept-Language", &CachedResponse{ |
| + StatusCode: 200, Header: http.Header{}, Body: []byte(lang), |
| + StoredAt: time.Now(), TTL: time.Minute, |
| + }) |
| + } |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://vary.example.com/write", nil)) |
| + _ = rec |
| + |
| + for _, lang := range []string{"en", "fr"} { |
| + k := base |
| + k.VaryValue = "Accept-Language=" + lang + ";" |
| + if _, ok := e.cache.get(k); ok { |
| + t.Fatalf("Vary variant %q survived a URL purge", lang) |
| + } |
| + } |
| +} |
| + |
| +// TestPurgeHeaderHEADEntriesToo — PurgeURL drops GET and HEAD together; a |
| +// write invalidates both. |
| +func TestPurgeHeaderHEADEntriesToo(t *testing.T) { |
| + e, _ := purgeEdge(t, "head.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/page") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + k := CacheKey{Host: "head.example.com", Method: "HEAD", Path: "/page"} |
| + e.cache.Put(k, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, StoredAt: time.Now(), TTL: time.Minute}) |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://head.example.com/write", nil)) |
| + _ = rec |
| + |
| + if _, ok := e.cache.get(k); ok { |
| + t.Fatal("the HEAD entry for a purged URL survived") |
| + } |
| +} |
| + |
| +// TestValidPurgeTarget is the unit-level table for the shape gate, including |
| +// the paths that must stay VALID — a query string, a path containing a colon |
| +// (which is not an authority once the value starts with a single slash), and |
| +// a trailing slash. |
| +func TestValidPurgeTarget(t *testing.T) { |
| + valid := []string{"/", "/a", "/a/b/", "/events/42", "/events/42?lang=fr", "/a:b", "/x://y", "/a%20b", "/a?b=c&d=e"} |
| + invalid := []string{"", "*", "a", "a/b", "//host", `/\host`, "http://h/x", "https://h/x", "//", `//\`, "/a b", "/a\tb", "/a\nb", "/a\x00b", "/a\x7f"} |
| + for _, v := range valid { |
| + if !validPurgeTarget(v) { |
| + t.Errorf("validPurgeTarget(%q) = false, want true", v) |
| + } |
| + } |
| + for _, v := range invalid { |
| + if validPurgeTarget(v) { |
| + t.Errorf("validPurgeTarget(%q) = true, want false", v) |
| + } |
| + } |
| +} |
| + |
| +// TestPurgeHeaderScrubbedFromRequestBeforeUpstream — Carlos-Purge is an |
| +// edge-internal signal in BOTH directions. TestPurgeHeaderNeverReadFromRequest |
| +// pins that the edge itself ignores a client-supplied one; this pins that the |
| +// client-supplied one never reaches the APP either. |
| +// |
| +// It matters because the app is the other half of this contract. An app that |
| +// reflects request headers onto its response (a debug echo, a framework's |
| +// "copy these through" list, a CORS mirror) would turn a client-supplied |
| +// Carlos-Purge into an app-declared one — and that one IS honoured, which |
| +// hands any client on the internet the purge channel the whole design exists |
| +// to keep unreachable. Scrubbing it on the way in means no app can be tricked |
| +// into laundering it, regardless of how the app behaves. |
| +func TestPurgeHeaderScrubbedFromRequestBeforeUpstream(t *testing.T) { |
| + seen := make(chan string, 1) |
| + e, _ := purgeEdge(t, "scrub.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + seen <- strings.Join(r.Header.Values(PurgeHeader), "|") |
| + io.WriteString(w, "ok") |
| + }) |
| + |
| + req := httptest.NewRequest("POST", "http://scrub.example.com/write", nil) |
| + req.Header.Add(PurgeHeader, "*") |
| + req.Header.Add(PurgeHeader, "/a") |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, req) |
| + |
| + if got := <-seen; got != "" { |
| + t.Fatalf("upstream saw Carlos-Purge %q — an echoing app would launder it into a real purge", got) |
| + } |
| +} |
| + |
| +// The same scrub on the unix-socket proxy: unixProxy and tcpProxy have |
| +// separate Rewrite funcs, so a fix applied to one is not applied to the other. |
| +func TestPurgeHeaderScrubbedFromRequestOverUnixSocket(t *testing.T) { |
| + sock := filepath.Join(t.TempDir(), "u.sock") |
| + ln, err := net.Listen("unix", sock) |
| + if err != nil { |
| + if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") { |
| + t.Skipf("unix-socket listeners not permitted in this environment: %v", err) |
| + } |
| + t.Fatalf("listen unix: %v", err) |
| + } |
| + seen := make(chan string, 1) |
| + backend := &httptest.Server{ |
| + Listener: ln, |
| + Config: &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + seen <- strings.Join(r.Header.Values(PurgeHeader), "|") |
| + io.WriteString(w, "ok") |
| + })}, |
| + } |
| + backend.Start() |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "sockscrub.example.com", Kind: "instance", Socket: sock, Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + |
| + req := httptest.NewRequest("POST", "http://sockscrub.example.com/write", nil) |
| + req.Header.Set(PurgeHeader, "*") |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, req) |
| + |
| + if got := <-seen; got != "" { |
| + t.Fatalf("unix-socket upstream saw Carlos-Purge %q", got) |
| + } |
| +} |
| + |
| +// --------------------------------------------------------------------------- |
| +// Query-variant semantics: a bare path means EVERY query variant. |
| +// --------------------------------------------------------------------------- |
| + |
| +// TestPurgeHeaderBarePathPurgesEveryQueryVariant is the contract change. An |
| +// app that writes /events/42 and declares "/events/42" means the page is |
| +// stale — all of it. Keying the purge on the exact empty query left |
| +// /events/42?utm_source=… (a campaign link) and /events/42?lang=fr serving the |
| +// pre-write body until their TTLs expired, which are precisely the copies a |
| +// reader arrives on. |
| +func TestPurgeHeaderBarePathPurgesEveryQueryVariant(t *testing.T) { |
| + e, _ := purgeEdge(t, "q.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/events/42") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "q.example.com", "/events/42", "", "bare") |
| + seed(t, e.cache, "q.example.com", "/events/42", "utm_source=newsletter", "utm") |
| + seed(t, e.cache, "q.example.com", "/events/42", "lang=fr", "fr") |
| + // A HEAD entry under a query, to prove both method groups of byPath are |
| + // swept and not just GET. |
| + headKey := CacheKey{Host: "q.example.com", Method: "HEAD", Path: "/events/42", RawQuery: "lang=fr"} |
| + e.cache.Put(headKey, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, StoredAt: time.Now(), TTL: time.Minute}) |
| + // Neighbours that share a query but not the path must be untouched. |
| + seed(t, e.cache, "q.example.com", "/events/43", "lang=fr", "neighbour") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://q.example.com/events/42/edit", nil)) |
| + _ = rec |
| + |
| + for _, rq := range []string{"", "utm_source=newsletter", "lang=fr"} { |
| + if cached(e.cache, "q.example.com", "/events/42", rq) { |
| + t.Fatalf("/events/42?%s survived a bare-path purge", rq) |
| + } |
| + } |
| + if _, ok := e.cache.get(headKey); ok { |
| + t.Fatal("the HEAD entry under a query survived a bare-path purge") |
| + } |
| + if !cached(e.cache, "q.example.com", "/events/43", "lang=fr") { |
| + t.Fatal("a bare-path purge crossed into a different path") |
| + } |
| +} |
| + |
| +// TestPurgeHeaderQueryFormPurgesOnlyThatVariant — the other half of the |
| +// contract: WITH a "?" the target is exact, so an app that really does mean |
| +// "only the French one" still has that. |
| +func TestPurgeHeaderQueryFormPurgesOnlyThatVariant(t *testing.T) { |
| + e, _ := purgeEdge(t, "qx.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/events/42?lang=fr") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "qx.example.com", "/events/42", "", "bare") |
| + seed(t, e.cache, "qx.example.com", "/events/42", "lang=fr", "fr") |
| + seed(t, e.cache, "qx.example.com", "/events/42", "lang=de", "de") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://qx.example.com/write", nil)) |
| + _ = rec |
| + |
| + if cached(e.cache, "qx.example.com", "/events/42", "lang=fr") { |
| + t.Fatal("the named variant was not purged") |
| + } |
| + for _, rq := range []string{"", "lang=de"} { |
| + if !cached(e.cache, "qx.example.com", "/events/42", rq) { |
| + t.Fatalf("/events/42?%s was purged, but a ?-form target is exact", rq) |
| + } |
| + } |
| +} |
| + |
| +// TestPurgeHeaderEmptyQueryFormIsExact — "/events/42?" is how an app asks for |
| +// the unqueried variant ALONE, now that the bare form means "all of them". |
| +func TestPurgeHeaderEmptyQueryFormIsExact(t *testing.T) { |
| + e, _ := purgeEdge(t, "qe.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/events/42?") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "qe.example.com", "/events/42", "", "bare") |
| + seed(t, e.cache, "qe.example.com", "/events/42", "lang=fr", "fr") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://qe.example.com/write", nil)) |
| + _ = rec |
| + |
| + if cached(e.cache, "qe.example.com", "/events/42", "") { |
| + t.Fatal(`"/events/42?" did not purge the empty-query variant`) |
| + } |
| + if !cached(e.cache, "qe.example.com", "/events/42", "lang=fr") { |
| + t.Fatal(`"/events/42?" purged a queried variant — the ?-form is exact`) |
| + } |
| +} |
| + |
| +// --------------------------------------------------------------------------- |
| +// Percent-encoding: path decoded, query raw — matching how the key is stored. |
| +// --------------------------------------------------------------------------- |
| + |
| +// TestPurgeHeaderPercentEncodedPathDecodes — edge.go stores CacheKey.Path from |
| +// r.URL.Path, which net/url has ALREADY decoded. A target is therefore |
| +// PathUnescape'd before it is used as a key, or "/caf%C3%A9" would purge |
| +// nothing at all while looking like it worked. |
| +func TestPurgeHeaderPercentEncodedPathDecodes(t *testing.T) { |
| + for _, tc := range []struct{ target, stored string }{ |
| + {"/a%20b", "/a b"}, |
| + {"/caf%C3%A9", "/café"}, |
| + {"/a%2Fb", "/a/b"}, |
| + } { |
| + t.Run(tc.target, func(t *testing.T) { |
| + e, _ := purgeEdge(t, "pct.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", tc.target) |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "pct.example.com", tc.stored, "", "old") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://pct.example.com/write", nil)) |
| + _ = rec |
| + |
| + if cached(e.cache, "pct.example.com", tc.stored, "") { |
| + t.Fatalf("target %q did not purge the entry stored under %q", tc.target, tc.stored) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// TestPurgeHeaderQueryIsComparedRaw pins the other side of the asymmetry. |
| +// CacheKey.RawQuery is r.URL.RawQuery — the query byte-for-byte as received, |
| +// never decoded — so a declared query must match RAW, not decoded. |
| +func TestPurgeHeaderQueryIsComparedRaw(t *testing.T) { |
| + e, _ := purgeEdge(t, "rawq.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/s?q=caf%C3%A9, /s2?q=caf%C3%A9") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + // Stored exactly as a request would arrive percent-encoded: raw matches raw. |
| + seed(t, e.cache, "rawq.example.com", "/s", "q=caf%C3%A9", "encoded") |
| + // Stored under a DECODED query. Nothing can arrive this way over the wire, |
| + // and the raw-vs-raw comparison must not reach it. |
| + seed(t, e.cache, "rawq.example.com", "/s2", "q=café", "decoded") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://rawq.example.com/write", nil)) |
| + _ = rec |
| + |
| + if cached(e.cache, "rawq.example.com", "/s", "q=caf%C3%A9") { |
| + t.Fatal("a raw-encoded query target did not match the raw-encoded stored key") |
| + } |
| + if !cached(e.cache, "rawq.example.com", "/s2", "q=café") { |
| + t.Fatal("the query half was decoded before comparison — store and purge must agree on RAW") |
| + } |
| +} |
| + |
| +// TestPurgeTargetKey is the unit table for the decode layer: what survives the |
| +// raw gate can still be refused once decoded, because decoding manufactures |
| +// the very shapes the raw gate exists to refuse. |
| +func TestPurgeTargetKey(t *testing.T) { |
| + type want struct { |
| + path string |
| + rawQuery string |
| + exact bool |
| + } |
| + ok := map[string]want{ |
| + "/a": {"/a", "", false}, |
| + "/events/42": {"/events/42", "", false}, |
| + "/events/42?lang=fr": {"/events/42", "lang=fr", true}, |
| + "/events/42?": {"/events/42", "", true}, |
| + "/a%20b": {"/a b", "", false}, // decoded space is a real path byte |
| + "/caf%C3%A9": {"/café", "", false}, // decoded UTF-8 |
| + "/a%2Fb": {"/a/b", "", false}, // encoded slash decodes mid-path |
| + "/s?q=caf%C3%A9": {"/s", "q=caf%C3%A9", true}, // query kept RAW |
| + "/a:b": {"/a:b", "", false}, |
| + } |
| + for target, w := range ok { |
| + p, q, exact, good := purgeTargetKey(target) |
| + if !good { |
| + t.Errorf("purgeTargetKey(%q) refused a valid target", target) |
| + continue |
| + } |
| + if p != w.path || q != w.rawQuery || exact != w.exact { |
| + t.Errorf("purgeTargetKey(%q) = (%q, %q, %v), want (%q, %q, %v)", |
| + target, p, q, exact, w.path, w.rawQuery, w.exact) |
| + } |
| + } |
| + // Refused: undecodable, and decoded-into-a-smuggle or a control byte. |
| + bad := []string{ |
| + "/a%zz", // not hex |
| + "/a%A", // truncated escape |
| + "/%2Fhost", // decodes to //host — protocol-relative |
| + "/%2F%2Fhost", // decodes to ///host |
| + `/%5Chost`, // decodes to /\host — the backslash smuggle |
| + "/a%00b", // decoded NUL |
| + "/a%0ab", // decoded LF — log-injection shaped |
| + "/a%09b", // decoded TAB |
| + "/a%7f", // decoded DEL |
| + "//host", // refused by the raw gate already |
| + "/a b", // raw space: malformed as declared |
| + "", |
| + "*", |
| + } |
| + for _, target := range bad { |
| + if _, _, _, good := purgeTargetKey(target); good { |
| + t.Errorf("purgeTargetKey(%q) accepted a target it must refuse", target) |
| + } |
| + } |
| +} |
| + |
| +// TestPurgeHeaderDecodedSmuggleIsRefusedEndToEnd — the decode re-check at the |
| +// level that matters: a target that decodes into a protocol-relative shape |
| +// must purge nothing, not be honoured as a path. |
| +func TestPurgeHeaderDecodedSmuggleIsRefusedEndToEnd(t *testing.T) { |
| + // "/%2Fa.example.com/x" decodes to "//a.example.com/x" — leading slash plus |
| + // one decoded slash. (Note "/%2F%2F…" would decode to THREE slashes and |
| + // match nothing, which would make this test pass vacuously.) |
| + e, _ := purgeEdge(t, "dec.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "/%2Fa.example.com/x, /good") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "a.example.com", "/x", "", "host A") |
| + seed(t, e.cache, "dec.example.com", "//a.example.com/x", "", "decoded shape") |
| + seed(t, e.cache, "dec.example.com", "/good", "", "good") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://dec.example.com/write", nil)) |
| + _ = rec |
| + |
| + if !cached(e.cache, "a.example.com", "/x", "") { |
| + t.Fatal("a decoded smuggle reached another host") |
| + } |
| + if !cached(e.cache, "dec.example.com", "//a.example.com/x", "") { |
| + t.Fatal("a target that decodes to //host was honoured as a path") |
| + } |
| + if cached(e.cache, "dec.example.com", "/good", "") { |
| + t.Fatal("the valid target beside it was discarded") |
| + } |
| +} |
| + |
| +// --------------------------------------------------------------------------- |
| +// The narrowed contract: FINAL response headers only. |
| +// --------------------------------------------------------------------------- |
| + |
| +// TestPurgeHeaderOn1xxIsNotHonoured pins a documented GAP rather than a fix. |
| +// |
| +// ModifyResponse is handed the FINAL response only; the stdlib forwards 1xx |
| +// informational blocks to the client on a separate path that never reaches |
| +// this package. So a Carlos-Purge on a 103 Early Hints block is neither |
| +// honoured NOR stripped — the client sees it, and the assertion below records |
| +// that as the truth rather than pretending otherwise. |
| +// |
| +// It stays a gap on purpose. Catching it would mean wrapping the client's |
| +// ResponseWriter to filter 1xx writes, and the upgrade path deliberately hands |
| +// the RAW ResponseWriter to the stdlib — a wrapper there re-opens the |
| +// WebSocket-breakage class this package has already paid for. Narrowing the |
| +// promise is the cheaper, honest trade. |
| +func TestPurgeHeaderOn1xxIsNotHonoured(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + // Declared on the 103 block only, then removed so the FINAL response |
| + // carries nothing — isolating the informational path. |
| + w.Header().Set("Carlos-Purge", "/early") |
| + w.WriteHeader(http.StatusEarlyHints) |
| + w.Header().Del("Carlos-Purge") |
| + io.WriteString(w, "ok") |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "early.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + seed(t, e.cache, "early.example.com", "/early", "", "still here") |
| + |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + var got1xx string |
| + req, err := http.NewRequest("POST", srv.URL+"/write", nil) |
| + if err != nil { |
| + t.Fatalf("new request: %v", err) |
| + } |
| + req.Host = "early.example.com" |
| + trace := &httptrace.ClientTrace{ |
| + Got1xxResponse: func(code int, h textproto.MIMEHeader) error { |
| + if code == http.StatusEarlyHints { |
| + got1xx = h.Get("Carlos-Purge") |
| + } |
| + return nil |
| + }, |
| + } |
| + req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("do: %v", err) |
| + } |
| + io.Copy(io.Discard, resp.Body) |
| + resp.Body.Close() |
| + |
| + // The load-bearing assertion: a 1xx declaration purges NOTHING. |
| + if !cached(e.cache, "early.example.com", "/early", "") { |
| + t.Fatal("a Carlos-Purge on a 103 was honoured — the contract says final-response headers only") |
| + } |
| + // And the documented consequence, pinned so a future stdlib change that |
| + // silently starts stripping it shows up here as a surprise rather than a |
| + // quiet behaviour drift. |
| + if got1xx != "/early" { |
| + t.Logf("note: the 103 block did not carry Carlos-Purge to the client (got %q); "+ |
| + "the contract permits either, but this used to reach the client", got1xx) |
| + } |
| + if got := resp.Header.Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("the FINAL response carried Carlos-Purge: %q", got) |
| + } |
| +} |
| + |
| +// TestPurgeHeaderInTrailerIsNotHonoured — the other half of the narrowing. A |
| +// header announced via Trailer and sent after the body is not in resp.Header |
| +// when ModifyResponse runs, so it is neither honoured nor stripped. |
| +func TestPurgeHeaderInTrailerIsNotHonoured(t *testing.T) { |
| + e, _ := purgeEdge(t, "trail.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Trailer", "Carlos-Purge") |
| + w.WriteHeader(http.StatusOK) |
| + io.WriteString(w, "body") |
| + // Set AFTER the body: net/http moves this into the trailer block. |
| + w.Header().Set("Carlos-Purge", "/trailed") |
| + }) |
| + seed(t, e.cache, "trail.example.com", "/trailed", "", "still here") |
| + |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + |
| + req, err := http.NewRequest("POST", srv.URL+"/write", nil) |
| + if err != nil { |
| + t.Fatalf("new request: %v", err) |
| + } |
| + req.Host = "trail.example.com" |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatalf("do: %v", err) |
| + } |
| + body, _ := io.ReadAll(resp.Body) |
| + resp.Body.Close() |
| + |
| + // Proves the backend actually ran and the trailer was actually sent — |
| + // without this the assertion below could pass because nothing happened. |
| + if string(body) != "body" { |
| + t.Fatalf("body = %q, want %q", body, "body") |
| + } |
| + if got := resp.Trailer.Get("Carlos-Purge"); got != "/trailed" { |
| + t.Fatalf("the trailer did not arrive (got %q) — this test proves nothing without it", got) |
| + } |
| + if !cached(e.cache, "trail.example.com", "/trailed", "") { |
| + t.Fatal("a Carlos-Purge sent as a TRAILER was honoured — the contract says final-response headers only") |
| + } |
| +} |
| + |
| +// TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook is the footgun the |
| +// contract now names explicitly. httputil.ReverseProxy calls |
| +// removeHopByHopHeaders(res.Header) BEFORE ModifyResponse, so an app that |
| +// names Carlos-Purge in its Connection header has its declaration deleted |
| +// before this package can ever see it — it purges nothing, silently, and the |
| +// app author has no way to tell from the outside. |
| +func TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook(t *testing.T) { |
| + e, _ := purgeEdge(t, "conn.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Connection", "Carlos-Purge") |
| + w.Header().Set("Carlos-Purge", "/conn") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "conn.example.com", "/conn", "", "still here") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://conn.example.com/write", nil)) |
| + |
| + if !cached(e.cache, "conn.example.com", "/conn", "") { |
| + t.Fatal("a Connection-listed Carlos-Purge was honoured — if the stdlib stopped " + |
| + "treating it as hop-by-hop, the contract note about Connection is now wrong") |
| + } |
| + if got := rec.Header().Get("Carlos-Purge"); got != "" { |
| + t.Fatalf("Carlos-Purge reached the client: %q", got) |
| + } |
| +} |
| + |
| +// --------------------------------------------------------------------------- |
| +// Log rate-limiting. |
| +// --------------------------------------------------------------------------- |
| + |
| +// TestPurgeHeaderComplaintLogIsRateLimited — every one of these lines is |
| +// triggered by a remote party's response on the request hot path, so an app |
| +// that emits a malformed declaration on every response would write one journal |
| +// line per request forever. Same first-then-every-300th shape as the registry |
| +// watcher in edge.go. |
| +func TestPurgeHeaderComplaintLogIsRateLimited(t *testing.T) { |
| + var buf bytes.Buffer |
| + log.SetOutput(&buf) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + |
| + e, _ := purgeEdge(t, "noisy.example.com", false, func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge", "a.example.com/smuggled") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + const requests = 12 |
| + for i := 0; i < requests; i++ { |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://noisy.example.com/write", nil)) |
| + } |
| + |
| + got := strings.Count(buf.String(), "malformed Carlos-Purge target(s)") |
| + if got != 1 { |
| + t.Fatalf("%d requests produced %d complaint lines, want exactly 1 (the first); log:\n%s", |
| + requests, got, buf.String()) |
| + } |
| + // The first is always logged — nothing is silently swallowed. |
| + if !strings.Contains(buf.String(), "occurrence #1") { |
| + t.Fatalf("the first occurrence was not logged with its count; log:\n%s", buf.String()) |
| + } |
| +} |