carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "bufio"
5 "bytes"
6 "errors"
7 "fmt"
8 "io"
9 "log"
10 "net"
11 "net/http"
12 "net/http/httptest"
13 "net/http/httptrace"
14 "net/textproto"
15 "os"
16 "path/filepath"
17 "strings"
18 "syscall"
19 "testing"
20 "time"
21
22 "github.com/carlosframework/platform/internal/registry"
23 "github.com/carlosframework/platform/internal/release"
24)
25
26// purgeEdge builds an edge with a cache in front of one addr-backed route for
27// host, whose upstream runs h. hibernate=false is the direct-proxy shape (a
28// plain `routed` app behind the edge — tito production), true routes the
29// request through the activator instead.
30func purgeEdge(t *testing.T, host string, hibernate bool, h http.HandlerFunc) (*Edge, *registry.Registry) {
31 t.Helper()
32 backend := httptest.NewServer(h)
33 t.Cleanup(backend.Close)
34 reg := openReg(t)
35 rt := registry.Route{Host: host, Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready", Hibernate: hibernate}
36 if err := reg.Upsert(rt); err != nil {
37 t.Fatalf("upsert: %v", err)
38 }
39 var act Activator
40 if hibernate {
41 act = &fakeActivator{}
42 }
43 e, err := New(reg, act)
44 if err != nil {
45 t.Fatalf("New: %v", err)
46 }
47 // EnableCache deliberately AFTER New (which already ran Reload) — the
48 // production ordering in RunWithActivator. A purge hook that captured
49 // e.cache at reload time would capture nil and never purge anything.
50 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
51 return e, reg
52}
53
54// seed puts a trivially-cacheable entry for one URL so a purge has something
55// to remove.
56func seed(t *testing.T, c *Cache, host, path, rawQuery, body string) {
57 t.Helper()
58 c.Put(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: rawQuery}, "", &CachedResponse{
59 StatusCode: 200,
60 Header: http.Header{"Content-Type": {"text/html"}},
61 Body: []byte(body),
62 StoredAt: time.Now(),
63 TTL: time.Minute,
64 })
65}
66
67func cached(c *Cache, host, path, rawQuery string) bool {
68 _, ok := c.get(CacheKey{Host: host, Method: "GET", Path: path, RawQuery: rawQuery})
69 return ok
70}
71
72// TestPurgeHeaderOnPOSTPurgesAndIsStripped is the headline contract: a write
73// (POST — never a cacheable method, so it never touches the GET/HEAD caching
74// path at all) purges the URLs it names, and the client never sees the header.
75func TestPurgeHeaderOnPOSTPurgesAndIsStripped(t *testing.T) {
76 e, _ := purgeEdge(t, "w.example.com", false, func(w http.ResponseWriter, r *http.Request) {
77 w.Header().Set("Carlos-Purge", "/events/42, /events/42?lang=fr")
78 w.Header().Set("Location", "/events/42")
79 w.WriteHeader(http.StatusFound)
80 })
81 seed(t, e.cache, "w.example.com", "/events/42", "", "old")
82 seed(t, e.cache, "w.example.com", "/events/42", "lang=fr", "vieux")
83 seed(t, e.cache, "w.example.com", "/events/43", "", "untouched")
84
85 rec := httptest.NewRecorder()
86 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://w.example.com/events/42/edit", strings.NewReader("x=1")))
87
88 if rec.Code != http.StatusFound {
89 t.Fatalf("status = %d, want 302", rec.Code)
90 }
91 if got := rec.Header().Get("Carlos-Purge"); got != "" {
92 t.Fatalf("Carlos-Purge reached the client: %q", got)
93 }
94 if cached(e.cache, "w.example.com", "/events/42", "") {
95 t.Fatal("/events/42 still cached after purge")
96 }
97 if cached(e.cache, "w.example.com", "/events/42", "lang=fr") {
98 t.Fatal("/events/42?lang=fr still cached after purge")
99 }
100 if !cached(e.cache, "w.example.com", "/events/43", "") {
101 t.Fatal("/events/43 was purged but nothing named it")
102 }
103}
104
105// TestPurgeHeaderOnDirectProxyPath pins the shape this feature exists for:
106// an addr-backed, NON-hibernating route (tito production) — no activator in
107// the picture at all.
108func TestPurgeHeaderOnDirectProxyPath(t *testing.T) {
109 e, _ := purgeEdge(t, "tito.example.com", false, func(w http.ResponseWriter, r *http.Request) {
110 w.Header().Set("Carlos-Purge", "/tickets")
111 io.WriteString(w, "ok")
112 })
113 seed(t, e.cache, "tito.example.com", "/tickets", "", "old")
114
115 rec := httptest.NewRecorder()
116 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://tito.example.com/tickets/buy", nil))
117
118 if cached(e.cache, "tito.example.com", "/tickets", "") {
119 t.Fatal("direct-proxy path did not honour Carlos-Purge")
120 }
121 if got := rec.Header().Get("Carlos-Purge"); got != "" {
122 t.Fatalf("Carlos-Purge reached the client: %q", got)
123 }
124}
125
126// TestPurgeHeaderOnHibernatingPath proves the same on the activator path,
127// where the activator wraps the very same proxy.
128func TestPurgeHeaderOnHibernatingPath(t *testing.T) {
129 e, _ := purgeEdge(t, "hib.example.com", true, func(w http.ResponseWriter, r *http.Request) {
130 w.Header().Set("Carlos-Purge", "/tickets")
131 io.WriteString(w, "ok")
132 })
133 seed(t, e.cache, "hib.example.com", "/tickets", "", "old")
134
135 rec := httptest.NewRecorder()
136 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://hib.example.com/tickets/buy", nil))
137
138 if cached(e.cache, "hib.example.com", "/tickets", "") {
139 t.Fatal("hibernating/activator path did not honour Carlos-Purge")
140 }
141 if got := rec.Header().Get("Carlos-Purge"); got != "" {
142 t.Fatalf("Carlos-Purge reached the client: %q", got)
143 }
144}
145
146// TestPurgeAppliesBeforeStore is the ordering regression: a cacheable GET
147// whose own response purges its own URL must end with the NEW response
148// cached, not with an empty entry. Purge runs in ModifyResponse, strictly
149// before maybeStore sees the response — reverse the two and this GET purges
150// exactly what it just stored, so the URL can never stay cached and a
151// hibernating route wakes on every single request.
152//
153// The URL is deliberately NOT pre-seeded: a seeded entry would be a cache hit
154// and the request would never reach the backend at all.
155func TestPurgeAppliesBeforeStore(t *testing.T) {
156 e, _ := purgeEdge(t, "order.example.com", false, func(w http.ResponseWriter, r *http.Request) {
157 w.Header().Set("Carlos-Purge", "/page, /index")
158 w.Header().Set("Cache-Control", "max-age=60")
159 io.WriteString(w, "fresh")
160 })
161 seed(t, e.cache, "order.example.com", "/index", "", "stale index")
162
163 rec := httptest.NewRecorder()
164 e.ServeHTTP(rec, httptest.NewRequest("GET", "http://order.example.com/page", nil))
165 if rec.Body.String() != "fresh" {
166 t.Fatalf("body = %q, want %q", rec.Body.String(), "fresh")
167 }
168
169 got, ok := e.cache.get(CacheKey{Host: "order.example.com", Method: "GET", Path: "/page"})
170 if !ok {
171 t.Fatal("nothing cached: the purge ran AFTER maybeStore and deleted what it just stored")
172 }
173 if string(got.Body) != "fresh" {
174 t.Fatalf("cached body = %q, want %q", got.Body, "fresh")
175 }
176 if v := got.Header.Get("Carlos-Purge"); v != "" {
177 t.Fatalf("Carlos-Purge was stored in the cached response: %q", v)
178 }
179 // The other target in the same declaration really was purged — proving
180 // the entry above survived because of ordering, not because the purge
181 // silently did nothing.
182 if cached(e.cache, "order.example.com", "/index", "") {
183 t.Fatal("/index survived: the purge did not run at all")
184 }
185}
186
187// TestPurgeHeaderStrippedFromCachedReplay — the second half of "the client
188// never sees it": a response stored and later replayed from cache must not
189// carry the header either.
190func TestPurgeHeaderStrippedFromCachedReplay(t *testing.T) {
191 e, _ := purgeEdge(t, "replay.example.com", false, func(w http.ResponseWriter, r *http.Request) {
192 w.Header().Set("Carlos-Purge", "/other")
193 w.Header().Set("Cache-Control", "max-age=60")
194 io.WriteString(w, "page")
195 })
196
197 rec1 := httptest.NewRecorder()
198 e.ServeHTTP(rec1, httptest.NewRequest("GET", "http://replay.example.com/page", nil))
199 if got := rec1.Header().Get("Carlos-Purge"); got != "" {
200 t.Fatalf("pass-through response carried Carlos-Purge: %q", got)
201 }
202 rec2 := httptest.NewRecorder()
203 e.ServeHTTP(rec2, httptest.NewRequest("GET", "http://replay.example.com/page", nil))
204 if rec2.Body.String() != "page" {
205 t.Fatalf("second request body = %q, want a cache hit of %q", rec2.Body.String(), "page")
206 }
207 if got := rec2.Header().Get("Carlos-Purge"); got != "" {
208 t.Fatalf("cached replay carried Carlos-Purge: %q", got)
209 }
210}
211
212// TestPurgeHeaderStarPurgesHost — `*` maps to Cache.PurgeHost, and only for
213// the host being served.
214func TestPurgeHeaderStarPurgesHost(t *testing.T) {
215 e, _ := purgeEdge(t, "star.example.com", false, func(w http.ResponseWriter, r *http.Request) {
216 w.Header().Set("Carlos-Purge", "*")
217 w.WriteHeader(http.StatusNoContent)
218 })
219 seed(t, e.cache, "star.example.com", "/a", "", "a")
220 seed(t, e.cache, "star.example.com", "/b", "x=1", "b")
221 seed(t, e.cache, "other.example.com", "/a", "", "other")
222
223 rec := httptest.NewRecorder()
224 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://star.example.com/deploy", nil))
225
226 if cached(e.cache, "star.example.com", "/a", "") || cached(e.cache, "star.example.com", "/b", "x=1") {
227 t.Fatal("* did not purge the whole host")
228 }
229 if !cached(e.cache, "other.example.com", "/a", "") {
230 t.Fatal("* purged another host's entries")
231 }
232}
233
234// TestPurgeHeaderCannotTouchAnotherHost — the isolation half. Every shape
235// that smuggles an authority must be IGNORED, never reinterpreted: neither
236// the named foreign host's entry nor the serving host's same-path entry may
237// be touched by them.
238func TestPurgeHeaderCannotTouchAnotherHost(t *testing.T) {
239 smugglers := []string{
240 "https://a.example.com/x",
241 "http://a.example.com/x",
242 "//a.example.com/x",
243 `/\a.example.com/x`,
244 "a.example.com/x",
245 "x",
246 "",
247 "a.example.com",
248 "*.a.example.com",
249 }
250 for _, target := range smugglers {
251 t.Run(fmt.Sprintf("%q", target), func(t *testing.T) {
252 e, _ := purgeEdge(t, "b.example.com", false, func(w http.ResponseWriter, r *http.Request) {
253 w.Header().Set("Carlos-Purge", target)
254 w.WriteHeader(http.StatusOK)
255 })
256 seed(t, e.cache, "a.example.com", "/x", "", "host A")
257 seed(t, e.cache, "b.example.com", "/x", "", "host B")
258
259 rec := httptest.NewRecorder()
260 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://b.example.com/write", nil))
261
262 if !cached(e.cache, "a.example.com", "/x", "") {
263 t.Fatalf("target %q purged another host's entry", target)
264 }
265 if !cached(e.cache, "b.example.com", "/x", "") {
266 t.Fatalf("target %q was reinterpreted as the path /x on the serving host", target)
267 }
268 })
269 }
270}
271
272// TestPurgeHeaderMixedValidAndSmuggled — a rejected target does not stop the
273// valid ones beside it from being honoured.
274func TestPurgeHeaderMixedValidAndSmuggled(t *testing.T) {
275 e, _ := purgeEdge(t, "mix.example.com", false, func(w http.ResponseWriter, r *http.Request) {
276 w.Header().Set("Carlos-Purge", "https://a.example.com/x, /good, //a.example.com/y")
277 w.WriteHeader(http.StatusOK)
278 })
279 seed(t, e.cache, "a.example.com", "/x", "", "A x")
280 seed(t, e.cache, "a.example.com", "/y", "", "A y")
281 seed(t, e.cache, "mix.example.com", "/good", "", "good")
282
283 rec := httptest.NewRecorder()
284 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://mix.example.com/write", nil))
285
286 if cached(e.cache, "mix.example.com", "/good", "") {
287 t.Fatal("/good was not purged: one bad target discarded the whole header")
288 }
289 if !cached(e.cache, "a.example.com", "/x", "") || !cached(e.cache, "a.example.com", "/y", "") {
290 t.Fatal("a smuggled target reached another host")
291 }
292}
293
294// TestListValuedPurgeHeader — the fourth instance of this package's
295// list-valued-header bug class, caught before merge this time. The header
296// split across two Add() calls must be fully honoured.
297func TestListValuedPurgeHeader(t *testing.T) {
298 e, _ := purgeEdge(t, "list.example.com", false, func(w http.ResponseWriter, r *http.Request) {
299 w.Header().Add("Carlos-Purge", "/a")
300 w.Header().Add("Carlos-Purge", "/b, /c")
301 w.WriteHeader(http.StatusOK)
302 })
303 seed(t, e.cache, "list.example.com", "/a", "", "a")
304 seed(t, e.cache, "list.example.com", "/b", "", "b")
305 seed(t, e.cache, "list.example.com", "/c", "", "c")
306
307 rec := httptest.NewRecorder()
308 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://list.example.com/write", nil))
309
310 for _, p := range []string{"/a", "/b", "/c"} {
311 if cached(e.cache, "list.example.com", p, "") {
312 t.Fatalf("%s survived: only the first Carlos-Purge line was read", p)
313 }
314 }
315 if got := rec.Header().Values("Carlos-Purge"); len(got) != 0 {
316 t.Fatalf("Carlos-Purge lines reached the client: %v", got)
317 }
318}
319
320// TestListValuedPurgeHeaderStarOnSecondLine — `*` hiding on a later line
321// still purges the whole host.
322func TestListValuedPurgeHeaderStarOnSecondLine(t *testing.T) {
323 e, _ := purgeEdge(t, "list2.example.com", false, func(w http.ResponseWriter, r *http.Request) {
324 w.Header().Add("Carlos-Purge", "/a")
325 w.Header().Add("Carlos-Purge", "*")
326 w.WriteHeader(http.StatusOK)
327 })
328 seed(t, e.cache, "list2.example.com", "/a", "", "a")
329 seed(t, e.cache, "list2.example.com", "/z", "", "z")
330
331 rec := httptest.NewRecorder()
332 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://list2.example.com/write", nil))
333 _ = rec
334
335 if cached(e.cache, "list2.example.com", "/z", "") {
336 t.Fatal("* on the second header line was not honoured")
337 }
338}
339
340// TestPurgeHeaderNonCanonicalCase — Go canonicalizes header names off the
341// wire, so an app writing the lowercase form still lands on Carlos-Purge.
342func TestPurgeHeaderNonCanonicalCase(t *testing.T) {
343 e, _ := purgeEdge(t, "case.example.com", false, func(w http.ResponseWriter, r *http.Request) {
344 w.Header()["carlos-purge"] = []string{"/a"}
345 w.WriteHeader(http.StatusOK)
346 })
347 seed(t, e.cache, "case.example.com", "/a", "", "a")
348
349 rec := httptest.NewRecorder()
350 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://case.example.com/write", nil))
351
352 if cached(e.cache, "case.example.com", "/a", "") {
353 t.Fatal("lowercase carlos-purge was not honoured")
354 }
355 if got := rec.Header().Get("Carlos-Purge"); got != "" {
356 t.Fatalf("Carlos-Purge reached the client: %q", got)
357 }
358}
359
360// TestPurgeHeaderEmptyAndWhitespaceValues — an empty header, and a header of
361// nothing but separators and spaces, purge nothing and break nothing.
362func TestPurgeHeaderEmptyAndWhitespaceValues(t *testing.T) {
363 for _, value := range []string{"", " ", ",,,", " , , "} {
364 t.Run(fmt.Sprintf("%q", value), func(t *testing.T) {
365 e, _ := purgeEdge(t, "empty.example.com", false, func(w http.ResponseWriter, r *http.Request) {
366 w.Header().Set("Carlos-Purge", value)
367 w.WriteHeader(http.StatusOK)
368 })
369 seed(t, e.cache, "empty.example.com", "/a", "", "a")
370
371 rec := httptest.NewRecorder()
372 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://empty.example.com/write", nil))
373
374 if !cached(e.cache, "empty.example.com", "/a", "") {
375 t.Fatalf("value %q purged something", value)
376 }
377 if got := rec.Header().Get("Carlos-Purge"); got != "" {
378 t.Fatalf("Carlos-Purge reached the client: %q", got)
379 }
380 })
381 }
382}
383
384// TestPurgeHeaderSurroundingWhitespace — targets are trimmed, so the
385// human-readable ", " form and a tab-padded one both work.
386func TestPurgeHeaderSurroundingWhitespace(t *testing.T) {
387 e, _ := purgeEdge(t, "ws.example.com", false, func(w http.ResponseWriter, r *http.Request) {
388 w.Header().Set("Carlos-Purge", " /a ,\t/b\t")
389 w.WriteHeader(http.StatusOK)
390 })
391 seed(t, e.cache, "ws.example.com", "/a", "", "a")
392 seed(t, e.cache, "ws.example.com", "/b", "", "b")
393
394 rec := httptest.NewRecorder()
395 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ws.example.com/write", nil))
396 _ = rec
397
398 if cached(e.cache, "ws.example.com", "/a", "") || cached(e.cache, "ws.example.com", "/b", "") {
399 t.Fatal("whitespace-padded targets were not honoured")
400 }
401}
402
403// TestPurgeHeaderOnNon2xx — the purge is decoupled from cacheability: a 302
404// and a 404 both purge, and neither is stored.
405func TestPurgeHeaderOnNon2xx(t *testing.T) {
406 for _, status := range []int{http.StatusFound, http.StatusNotFound, http.StatusInternalServerError} {
407 t.Run(http.StatusText(status), func(t *testing.T) {
408 e, _ := purgeEdge(t, "nx.example.com", false, func(w http.ResponseWriter, r *http.Request) {
409 w.Header().Set("Carlos-Purge", "/a")
410 w.Header().Set("Cache-Control", "max-age=60")
411 w.WriteHeader(status)
412 io.WriteString(w, "body")
413 })
414 seed(t, e.cache, "nx.example.com", "/a", "", "a")
415
416 rec := httptest.NewRecorder()
417 e.ServeHTTP(rec, httptest.NewRequest("GET", "http://nx.example.com/thing", nil))
418 if rec.Code != status {
419 t.Fatalf("status = %d, want %d", rec.Code, status)
420 }
421 if cached(e.cache, "nx.example.com", "/a", "") {
422 t.Fatalf("a %d response did not purge", status)
423 }
424 if cached(e.cache, "nx.example.com", "/thing", "") {
425 t.Fatalf("a %d response was cached", status)
426 }
427 })
428 }
429}
430
431// TestPurgeHeaderBoundsDegradeToHost — over either bound the edge over-purges
432// (whole host) rather than partially applying. Freshness above cleverness.
433func TestPurgeHeaderBoundsDegradeToHost(t *testing.T) {
434 tooMany := make([]string, maxPurgeTargets+1)
435 for i := range tooMany {
436 tooMany[i] = fmt.Sprintf("/p%d", i)
437 }
438 cases := map[string]string{
439 "too many targets": strings.Join(tooMany, ","),
440 "oversized header": "/" + strings.Repeat("a", maxPurgeHeaderBytes),
441 }
442 for name, value := range cases {
443 t.Run(name, func(t *testing.T) {
444 e, _ := purgeEdge(t, "cap.example.com", false, func(w http.ResponseWriter, r *http.Request) {
445 w.Header().Set("Carlos-Purge", value)
446 w.WriteHeader(http.StatusOK)
447 })
448 seed(t, e.cache, "cap.example.com", "/untouched", "", "u")
449 seed(t, e.cache, "other.example.com", "/untouched", "", "o")
450
451 rec := httptest.NewRecorder()
452 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://cap.example.com/write", nil))
453
454 if cached(e.cache, "cap.example.com", "/untouched", "") {
455 t.Fatal("over-bound header did not degrade to a whole-host purge")
456 }
457 if !cached(e.cache, "other.example.com", "/untouched", "") {
458 t.Fatal("degrading to a host purge crossed the host boundary")
459 }
460 if got := rec.Header().Get("Carlos-Purge"); got != "" {
461 t.Fatalf("Carlos-Purge reached the client: %q", got)
462 }
463 })
464 }
465}
466
467// TestPurgeHeaderAtBound — exactly maxPurgeTargets is honoured target by
468// target, not degraded. Pins the boundary from the other side.
469func TestPurgeHeaderAtBound(t *testing.T) {
470 targets := make([]string, maxPurgeTargets)
471 for i := range targets {
472 targets[i] = fmt.Sprintf("/p%d", i)
473 }
474 e, _ := purgeEdge(t, "atcap.example.com", false, func(w http.ResponseWriter, r *http.Request) {
475 w.Header().Set("Carlos-Purge", strings.Join(targets, ","))
476 w.WriteHeader(http.StatusOK)
477 })
478 seed(t, e.cache, "atcap.example.com", "/p0", "", "p0")
479 seed(t, e.cache, "atcap.example.com", "/untouched", "", "u")
480
481 rec := httptest.NewRecorder()
482 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://atcap.example.com/write", nil))
483 _ = rec
484
485 if cached(e.cache, "atcap.example.com", "/p0", "") {
486 t.Fatal("/p0 was not purged at exactly the target bound")
487 }
488 if !cached(e.cache, "atcap.example.com", "/untouched", "") {
489 t.Fatal("exactly maxPurgeTargets degraded to a whole-host purge")
490 }
491}
492
493// TestPurgeHeaderNeverReadFromRequest — a client-supplied Carlos-Purge is
494// exactly the externally-reachable purge endpoint the parent spec rejects as
495// a wake-storm vector. It must do nothing.
496func TestPurgeHeaderNeverReadFromRequest(t *testing.T) {
497 e, _ := purgeEdge(t, "req.example.com", false, func(w http.ResponseWriter, r *http.Request) {
498 io.WriteString(w, "ok")
499 })
500 seed(t, e.cache, "req.example.com", "/a", "", "a")
501
502 req := httptest.NewRequest("POST", "http://req.example.com/write", nil)
503 req.Header.Set("Carlos-Purge", "*")
504 rec := httptest.NewRecorder()
505 e.ServeHTTP(rec, req)
506
507 if !cached(e.cache, "req.example.com", "/a", "") {
508 t.Fatal("a request-borne Carlos-Purge purged the cache")
509 }
510}
511
512// TestPurgeHeaderWithoutCacheIsStillStripped — with caching disabled there is
513// nothing to purge, but the header is an edge-internal signal and must still
514// never reach a client.
515func TestPurgeHeaderWithoutCacheIsStillStripped(t *testing.T) {
516 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
517 w.Header().Set("Carlos-Purge", "/a")
518 io.WriteString(w, "ok")
519 }))
520 t.Cleanup(backend.Close)
521 reg := openReg(t)
522 if err := reg.Upsert(registry.Route{Host: "nocache.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
523 t.Fatalf("upsert: %v", err)
524 }
525 e, err := New(reg, nil)
526 if err != nil {
527 t.Fatalf("New: %v", err)
528 }
529
530 rec := httptest.NewRecorder()
531 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://nocache.example.com/write", nil))
532 if got := rec.Header().Get("Carlos-Purge"); got != "" {
533 t.Fatalf("Carlos-Purge reached the client with caching disabled: %q", got)
534 }
535}
536
537// TestPurgeHeaderOnUpgradeBypassPath — the upgrade path skips caching AND the
538// recordingResponseWriter entirely (edge.go's isUpgradeRequest branch), so it
539// is the one proxied path where a purge hook placed anywhere but
540// ModifyResponse would silently not fire. This drives a REAL 101 handshake
541// over a real socket, which is the only way that branch is exercised end to
542// end.
543func TestPurgeHeaderOnUpgradeBypassPath(t *testing.T) {
544 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
545 conn, buf, err := w.(http.Hijacker).Hijack()
546 if err != nil {
547 t.Errorf("backend hijack: %v", err)
548 return
549 }
550 defer conn.Close()
551 fmt.Fprint(buf, "HTTP/1.1 101 Switching Protocols\r\n"+
552 "Upgrade: carlos-test\r\n"+
553 "Connection: Upgrade\r\n"+
554 "Carlos-Purge: /live\r\n\r\n")
555 buf.Flush()
556 // Hold the tunnel open long enough for the client to read the
557 // handshake response.
558 io.Copy(io.Discard, buf)
559 }))
560 t.Cleanup(backend.Close)
561
562 reg := openReg(t)
563 if err := reg.Upsert(registry.Route{Host: "ws.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
564 t.Fatalf("upsert: %v", err)
565 }
566 e, err := New(reg, nil)
567 if err != nil {
568 t.Fatalf("New: %v", err)
569 }
570 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
571 seed(t, e.cache, "ws.example.com", "/live", "", "live")
572
573 srv := httptest.NewServer(e)
574 t.Cleanup(srv.Close)
575
576 conn, err := net.Dial("tcp", srv.Listener.Addr().String())
577 if err != nil {
578 t.Fatalf("dial edge: %v", err)
579 }
580 defer conn.Close()
581 fmt.Fprint(conn, "GET /socket HTTP/1.1\r\n"+
582 "Host: ws.example.com\r\n"+
583 "Upgrade: carlos-test\r\n"+
584 "Connection: Upgrade\r\n\r\n")
585 conn.SetReadDeadline(time.Now().Add(10 * time.Second))
586 resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
587 if err != nil {
588 t.Fatalf("read upgrade response: %v", err)
589 }
590 if resp.StatusCode != http.StatusSwitchingProtocols {
591 t.Fatalf("status = %d, want 101", resp.StatusCode)
592 }
593 if got := resp.Header.Get("Carlos-Purge"); got != "" {
594 t.Fatalf("Carlos-Purge survived onto the upgrade response: %q", got)
595 }
596 if cached(e.cache, "ws.example.com", "/live", "") {
597 t.Fatal("upgrade-bypass path did not honour Carlos-Purge")
598 }
599}
600
601// TestPurgeHeaderPurgesEveryVaryVariant — a target names a URL, and purging
602// it drops ALL stored variants of that URL (existing PurgeURL semantics).
603func TestPurgeHeaderPurgesEveryVaryVariant(t *testing.T) {
604 e, _ := purgeEdge(t, "vary.example.com", false, func(w http.ResponseWriter, r *http.Request) {
605 w.Header().Set("Carlos-Purge", "/page")
606 w.WriteHeader(http.StatusOK)
607 })
608 base := CacheKey{Host: "vary.example.com", Method: "GET", Path: "/page"}
609 for _, lang := range []string{"en", "fr"} {
610 k := base
611 k.VaryValue = "Accept-Language=" + lang + ";"
612 e.cache.Put(k, "Accept-Language", &CachedResponse{
613 StatusCode: 200, Header: http.Header{}, Body: []byte(lang),
614 StoredAt: time.Now(), TTL: time.Minute,
615 })
616 }
617
618 rec := httptest.NewRecorder()
619 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://vary.example.com/write", nil))
620 _ = rec
621
622 for _, lang := range []string{"en", "fr"} {
623 k := base
624 k.VaryValue = "Accept-Language=" + lang + ";"
625 if _, ok := e.cache.get(k); ok {
626 t.Fatalf("Vary variant %q survived a URL purge", lang)
627 }
628 }
629}
630
631// TestPurgeHeaderHEADEntriesToo — PurgeURL drops GET and HEAD together; a
632// write invalidates both.
633func TestPurgeHeaderHEADEntriesToo(t *testing.T) {
634 e, _ := purgeEdge(t, "head.example.com", false, func(w http.ResponseWriter, r *http.Request) {
635 w.Header().Set("Carlos-Purge", "/page")
636 w.WriteHeader(http.StatusOK)
637 })
638 k := CacheKey{Host: "head.example.com", Method: "HEAD", Path: "/page"}
639 e.cache.Put(k, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, StoredAt: time.Now(), TTL: time.Minute})
640
641 rec := httptest.NewRecorder()
642 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://head.example.com/write", nil))
643 _ = rec
644
645 if _, ok := e.cache.get(k); ok {
646 t.Fatal("the HEAD entry for a purged URL survived")
647 }
648}
649
650// TestValidPurgeTarget is the unit-level table for the shape gate, including
651// the paths that must stay VALID — a query string, a path containing a colon
652// (which is not an authority once the value starts with a single slash), and
653// a trailing slash.
654func TestValidPurgeTarget(t *testing.T) {
655 valid := []string{"/", "/a", "/a/b/", "/events/42", "/events/42?lang=fr", "/a:b", "/x://y", "/a%20b", "/a?b=c&d=e"}
656 invalid := []string{"", "*", "a", "a/b", "//host", `/\host`, "http://h/x", "https://h/x", "//", `//\`, "/a b", "/a\tb", "/a\nb", "/a\x00b", "/a\x7f"}
657 for _, v := range valid {
658 if !validPurgeTarget(v) {
659 t.Errorf("validPurgeTarget(%q) = false, want true", v)
660 }
661 }
662 for _, v := range invalid {
663 if validPurgeTarget(v) {
664 t.Errorf("validPurgeTarget(%q) = true, want false", v)
665 }
666 }
667}
668
669// TestPurgeHeaderScrubbedFromRequestBeforeUpstream — Carlos-Purge is an
670// edge-internal signal in BOTH directions. TestPurgeHeaderNeverReadFromRequest
671// pins that the edge itself ignores a client-supplied one; this pins that the
672// client-supplied one never reaches the APP either.
673//
674// It matters because the app is the other half of this contract. An app that
675// reflects request headers onto its response (a debug echo, a framework's
676// "copy these through" list, a CORS mirror) would turn a client-supplied
677// Carlos-Purge into an app-declared one — and that one IS honoured, which
678// hands any client on the internet the purge channel the whole design exists
679// to keep unreachable. Scrubbing it on the way in means no app can be tricked
680// into laundering it, regardless of how the app behaves.
681func TestPurgeHeaderScrubbedFromRequestBeforeUpstream(t *testing.T) {
682 seen := make(chan string, 1)
683 e, _ := purgeEdge(t, "scrub.example.com", false, func(w http.ResponseWriter, r *http.Request) {
684 seen <- strings.Join(r.Header.Values(PurgeHeader), "|")
685 io.WriteString(w, "ok")
686 })
687
688 req := httptest.NewRequest("POST", "http://scrub.example.com/write", nil)
689 req.Header.Add(PurgeHeader, "*")
690 req.Header.Add(PurgeHeader, "/a")
691 rec := httptest.NewRecorder()
692 e.ServeHTTP(rec, req)
693
694 if got := <-seen; got != "" {
695 t.Fatalf("upstream saw Carlos-Purge %q — an echoing app would launder it into a real purge", got)
696 }
697}
698
699// The same scrub on the unix-socket proxy: unixProxy and tcpProxy have
700// separate Rewrite funcs, so a fix applied to one is not applied to the other.
701func TestPurgeHeaderScrubbedFromRequestOverUnixSocket(t *testing.T) {
702 sock := filepath.Join(t.TempDir(), "u.sock")
703 ln, err := net.Listen("unix", sock)
704 if err != nil {
705 if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") {
706 t.Skipf("unix-socket listeners not permitted in this environment: %v", err)
707 }
708 t.Fatalf("listen unix: %v", err)
709 }
710 seen := make(chan string, 1)
711 backend := &httptest.Server{
712 Listener: ln,
713 Config: &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
714 seen <- strings.Join(r.Header.Values(PurgeHeader), "|")
715 io.WriteString(w, "ok")
716 })},
717 }
718 backend.Start()
719 t.Cleanup(backend.Close)
720
721 reg := openReg(t)
722 if err := reg.Upsert(registry.Route{Host: "sockscrub.example.com", Kind: "instance", Socket: sock, Status: "ready"}); err != nil {
723 t.Fatalf("upsert: %v", err)
724 }
725 e, err := New(reg, nil)
726 if err != nil {
727 t.Fatalf("New: %v", err)
728 }
729 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
730
731 req := httptest.NewRequest("POST", "http://sockscrub.example.com/write", nil)
732 req.Header.Set(PurgeHeader, "*")
733 rec := httptest.NewRecorder()
734 e.ServeHTTP(rec, req)
735
736 if got := <-seen; got != "" {
737 t.Fatalf("unix-socket upstream saw Carlos-Purge %q", got)
738 }
739}
740
741// ---------------------------------------------------------------------------
742// Query-variant semantics: a bare path means EVERY query variant.
743// ---------------------------------------------------------------------------
744
745// TestPurgeHeaderBarePathPurgesEveryQueryVariant is the contract change. An
746// app that writes /events/42 and declares "/events/42" means the page is
747// stale — all of it. Keying the purge on the exact empty query left
748// /events/42?utm_source=… (a campaign link) and /events/42?lang=fr serving the
749// pre-write body until their TTLs expired, which are precisely the copies a
750// reader arrives on.
751func TestPurgeHeaderBarePathPurgesEveryQueryVariant(t *testing.T) {
752 e, _ := purgeEdge(t, "q.example.com", false, func(w http.ResponseWriter, r *http.Request) {
753 w.Header().Set("Carlos-Purge", "/events/42")
754 w.WriteHeader(http.StatusOK)
755 })
756 seed(t, e.cache, "q.example.com", "/events/42", "", "bare")
757 seed(t, e.cache, "q.example.com", "/events/42", "utm_source=newsletter", "utm")
758 seed(t, e.cache, "q.example.com", "/events/42", "lang=fr", "fr")
759 // A HEAD entry under a query, to prove both method groups of byPath are
760 // swept and not just GET.
761 headKey := CacheKey{Host: "q.example.com", Method: "HEAD", Path: "/events/42", RawQuery: "lang=fr"}
762 e.cache.Put(headKey, "", &CachedResponse{StatusCode: 200, Header: http.Header{}, StoredAt: time.Now(), TTL: time.Minute})
763 // Neighbours that share a query but not the path must be untouched.
764 seed(t, e.cache, "q.example.com", "/events/43", "lang=fr", "neighbour")
765
766 rec := httptest.NewRecorder()
767 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://q.example.com/events/42/edit", nil))
768 _ = rec
769
770 for _, rq := range []string{"", "utm_source=newsletter", "lang=fr"} {
771 if cached(e.cache, "q.example.com", "/events/42", rq) {
772 t.Fatalf("/events/42?%s survived a bare-path purge", rq)
773 }
774 }
775 if _, ok := e.cache.get(headKey); ok {
776 t.Fatal("the HEAD entry under a query survived a bare-path purge")
777 }
778 if !cached(e.cache, "q.example.com", "/events/43", "lang=fr") {
779 t.Fatal("a bare-path purge crossed into a different path")
780 }
781}
782
783// TestPurgeHeaderQueryFormPurgesOnlyThatVariant — the other half of the
784// contract: WITH a "?" the target is exact, so an app that really does mean
785// "only the French one" still has that.
786func TestPurgeHeaderQueryFormPurgesOnlyThatVariant(t *testing.T) {
787 e, _ := purgeEdge(t, "qx.example.com", false, func(w http.ResponseWriter, r *http.Request) {
788 w.Header().Set("Carlos-Purge", "/events/42?lang=fr")
789 w.WriteHeader(http.StatusOK)
790 })
791 seed(t, e.cache, "qx.example.com", "/events/42", "", "bare")
792 seed(t, e.cache, "qx.example.com", "/events/42", "lang=fr", "fr")
793 seed(t, e.cache, "qx.example.com", "/events/42", "lang=de", "de")
794
795 rec := httptest.NewRecorder()
796 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://qx.example.com/write", nil))
797 _ = rec
798
799 if cached(e.cache, "qx.example.com", "/events/42", "lang=fr") {
800 t.Fatal("the named variant was not purged")
801 }
802 for _, rq := range []string{"", "lang=de"} {
803 if !cached(e.cache, "qx.example.com", "/events/42", rq) {
804 t.Fatalf("/events/42?%s was purged, but a ?-form target is exact", rq)
805 }
806 }
807}
808
809// TestPurgeHeaderEmptyQueryFormIsExact — "/events/42?" is how an app asks for
810// the unqueried variant ALONE, now that the bare form means "all of them".
811func TestPurgeHeaderEmptyQueryFormIsExact(t *testing.T) {
812 e, _ := purgeEdge(t, "qe.example.com", false, func(w http.ResponseWriter, r *http.Request) {
813 w.Header().Set("Carlos-Purge", "/events/42?")
814 w.WriteHeader(http.StatusOK)
815 })
816 seed(t, e.cache, "qe.example.com", "/events/42", "", "bare")
817 seed(t, e.cache, "qe.example.com", "/events/42", "lang=fr", "fr")
818
819 rec := httptest.NewRecorder()
820 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://qe.example.com/write", nil))
821 _ = rec
822
823 if cached(e.cache, "qe.example.com", "/events/42", "") {
824 t.Fatal(`"/events/42?" did not purge the empty-query variant`)
825 }
826 if !cached(e.cache, "qe.example.com", "/events/42", "lang=fr") {
827 t.Fatal(`"/events/42?" purged a queried variant — the ?-form is exact`)
828 }
829}
830
831// ---------------------------------------------------------------------------
832// Percent-encoding: path decoded, query raw — matching how the key is stored.
833// ---------------------------------------------------------------------------
834
835// TestPurgeHeaderPercentEncodedPathDecodes — edge.go stores CacheKey.Path from
836// r.URL.Path, which net/url has ALREADY decoded. A target is therefore
837// PathUnescape'd before it is used as a key, or "/caf%C3%A9" would purge
838// nothing at all while looking like it worked.
839func TestPurgeHeaderPercentEncodedPathDecodes(t *testing.T) {
840 for _, tc := range []struct{ target, stored string }{
841 {"/a%20b", "/a b"},
842 {"/caf%C3%A9", "/café"},
843 {"/a%2Fb", "/a/b"},
844 } {
845 t.Run(tc.target, func(t *testing.T) {
846 e, _ := purgeEdge(t, "pct.example.com", false, func(w http.ResponseWriter, r *http.Request) {
847 w.Header().Set("Carlos-Purge", tc.target)
848 w.WriteHeader(http.StatusOK)
849 })
850 seed(t, e.cache, "pct.example.com", tc.stored, "", "old")
851
852 rec := httptest.NewRecorder()
853 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://pct.example.com/write", nil))
854 _ = rec
855
856 if cached(e.cache, "pct.example.com", tc.stored, "") {
857 t.Fatalf("target %q did not purge the entry stored under %q", tc.target, tc.stored)
858 }
859 })
860 }
861}
862
863// TestPurgeHeaderQueryIsComparedRaw pins the other side of the asymmetry.
864// CacheKey.RawQuery is r.URL.RawQuery — the query byte-for-byte as received,
865// never decoded — so a declared query must match RAW, not decoded.
866func TestPurgeHeaderQueryIsComparedRaw(t *testing.T) {
867 e, _ := purgeEdge(t, "rawq.example.com", false, func(w http.ResponseWriter, r *http.Request) {
868 w.Header().Set("Carlos-Purge", "/s?q=caf%C3%A9, /s2?q=caf%C3%A9")
869 w.WriteHeader(http.StatusOK)
870 })
871 // Stored exactly as a request would arrive percent-encoded: raw matches raw.
872 seed(t, e.cache, "rawq.example.com", "/s", "q=caf%C3%A9", "encoded")
873 // Stored under a DECODED query. Nothing can arrive this way over the wire,
874 // and the raw-vs-raw comparison must not reach it.
875 seed(t, e.cache, "rawq.example.com", "/s2", "q=café", "decoded")
876
877 rec := httptest.NewRecorder()
878 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://rawq.example.com/write", nil))
879 _ = rec
880
881 if cached(e.cache, "rawq.example.com", "/s", "q=caf%C3%A9") {
882 t.Fatal("a raw-encoded query target did not match the raw-encoded stored key")
883 }
884 if !cached(e.cache, "rawq.example.com", "/s2", "q=café") {
885 t.Fatal("the query half was decoded before comparison — store and purge must agree on RAW")
886 }
887}
888
889// TestPurgeTargetKey is the unit table for the decode layer: what survives the
890// raw gate can still be refused once decoded, because decoding manufactures
891// the very shapes the raw gate exists to refuse.
892func TestPurgeTargetKey(t *testing.T) {
893 type want struct {
894 path string
895 rawQuery string
896 exact bool
897 }
898 ok := map[string]want{
899 "/a": {"/a", "", false},
900 "/events/42": {"/events/42", "", false},
901 "/events/42?lang=fr": {"/events/42", "lang=fr", true},
902 "/events/42?": {"/events/42", "", true},
903 "/a%20b": {"/a b", "", false}, // decoded space is a real path byte
904 "/caf%C3%A9": {"/café", "", false}, // decoded UTF-8
905 "/a%2Fb": {"/a/b", "", false}, // encoded slash decodes mid-path
906 "/s?q=caf%C3%A9": {"/s", "q=caf%C3%A9", true}, // query kept RAW
907 "/a:b": {"/a:b", "", false},
908 }
909 for target, w := range ok {
910 p, q, exact, good := purgeTargetKey(target)
911 if !good {
912 t.Errorf("purgeTargetKey(%q) refused a valid target", target)
913 continue
914 }
915 if p != w.path || q != w.rawQuery || exact != w.exact {
916 t.Errorf("purgeTargetKey(%q) = (%q, %q, %v), want (%q, %q, %v)",
917 target, p, q, exact, w.path, w.rawQuery, w.exact)
918 }
919 }
920 // Refused: undecodable, and decoded-into-a-smuggle or a control byte.
921 bad := []string{
922 "/a%zz", // not hex
923 "/a%A", // truncated escape
924 "/%2Fhost", // decodes to //host — protocol-relative
925 "/%2F%2Fhost", // decodes to ///host
926 `/%5Chost`, // decodes to /\host — the backslash smuggle
927 "/a%00b", // decoded NUL
928 "/a%0ab", // decoded LF — log-injection shaped
929 "/a%09b", // decoded TAB
930 "/a%7f", // decoded DEL
931 "//host", // refused by the raw gate already
932 "/a b", // raw space: malformed as declared
933 "",
934 "*",
935 }
936 for _, target := range bad {
937 if _, _, _, good := purgeTargetKey(target); good {
938 t.Errorf("purgeTargetKey(%q) accepted a target it must refuse", target)
939 }
940 }
941}
942
943// TestPurgeHeaderDecodedSmuggleIsRefusedEndToEnd — the decode re-check at the
944// level that matters: a target that decodes into a protocol-relative shape
945// must purge nothing, not be honoured as a path.
946func TestPurgeHeaderDecodedSmuggleIsRefusedEndToEnd(t *testing.T) {
947 // "/%2Fa.example.com/x" decodes to "//a.example.com/x" — leading slash plus
948 // one decoded slash. (Note "/%2F%2F…" would decode to THREE slashes and
949 // match nothing, which would make this test pass vacuously.)
950 e, _ := purgeEdge(t, "dec.example.com", false, func(w http.ResponseWriter, r *http.Request) {
951 w.Header().Set("Carlos-Purge", "/%2Fa.example.com/x, /good")
952 w.WriteHeader(http.StatusOK)
953 })
954 seed(t, e.cache, "a.example.com", "/x", "", "host A")
955 seed(t, e.cache, "dec.example.com", "//a.example.com/x", "", "decoded shape")
956 seed(t, e.cache, "dec.example.com", "/good", "", "good")
957
958 rec := httptest.NewRecorder()
959 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://dec.example.com/write", nil))
960 _ = rec
961
962 if !cached(e.cache, "a.example.com", "/x", "") {
963 t.Fatal("a decoded smuggle reached another host")
964 }
965 if !cached(e.cache, "dec.example.com", "//a.example.com/x", "") {
966 t.Fatal("a target that decodes to //host was honoured as a path")
967 }
968 if cached(e.cache, "dec.example.com", "/good", "") {
969 t.Fatal("the valid target beside it was discarded")
970 }
971}
972
973// ---------------------------------------------------------------------------
974// The narrowed contract: FINAL response headers only.
975// ---------------------------------------------------------------------------
976
977// TestPurgeHeaderOn1xxIsNotHonoured pins a documented GAP rather than a fix.
978//
979// ModifyResponse is handed the FINAL response only; the stdlib forwards 1xx
980// informational blocks to the client on a separate path that never reaches
981// this package. So a Carlos-Purge on a 103 Early Hints block is neither
982// honoured NOR stripped — the client sees it, and the assertion below records
983// that as the truth rather than pretending otherwise.
984//
985// It stays a gap on purpose. Catching it would mean wrapping the client's
986// ResponseWriter to filter 1xx writes, and the upgrade path deliberately hands
987// the RAW ResponseWriter to the stdlib — a wrapper there re-opens the
988// WebSocket-breakage class this package has already paid for. Narrowing the
989// promise is the cheaper, honest trade.
990func TestPurgeHeaderOn1xxIsNotHonoured(t *testing.T) {
991 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
992 // Declared on the 103 block only, then removed so the FINAL response
993 // carries nothing — isolating the informational path.
994 w.Header().Set("Carlos-Purge", "/early")
995 w.WriteHeader(http.StatusEarlyHints)
996 w.Header().Del("Carlos-Purge")
997 io.WriteString(w, "ok")
998 }))
999 t.Cleanup(backend.Close)
1000
1001 reg := openReg(t)
1002 if err := reg.Upsert(registry.Route{Host: "early.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
1003 t.Fatalf("upsert: %v", err)
1004 }
1005 e, err := New(reg, nil)
1006 if err != nil {
1007 t.Fatalf("New: %v", err)
1008 }
1009 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
1010 seed(t, e.cache, "early.example.com", "/early", "", "still here")
1011
1012 srv := httptest.NewServer(e)
1013 t.Cleanup(srv.Close)
1014
1015 var got1xx string
1016 req, err := http.NewRequest("POST", srv.URL+"/write", nil)
1017 if err != nil {
1018 t.Fatalf("new request: %v", err)
1019 }
1020 req.Host = "early.example.com"
1021 trace := &httptrace.ClientTrace{
1022 Got1xxResponse: func(code int, h textproto.MIMEHeader) error {
1023 if code == http.StatusEarlyHints {
1024 got1xx = h.Get("Carlos-Purge")
1025 }
1026 return nil
1027 },
1028 }
1029 req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
1030 resp, err := http.DefaultClient.Do(req)
1031 if err != nil {
1032 t.Fatalf("do: %v", err)
1033 }
1034 io.Copy(io.Discard, resp.Body)
1035 resp.Body.Close()
1036
1037 // The load-bearing assertion: a 1xx declaration purges NOTHING.
1038 if !cached(e.cache, "early.example.com", "/early", "") {
1039 t.Fatal("a Carlos-Purge on a 103 was honoured — the contract says final-response headers only")
1040 }
1041 // And the documented consequence, pinned so a future stdlib change that
1042 // silently starts stripping it shows up here as a surprise rather than a
1043 // quiet behaviour drift.
1044 if got1xx != "/early" {
1045 t.Logf("note: the 103 block did not carry Carlos-Purge to the client (got %q); "+
1046 "the contract permits either, but this used to reach the client", got1xx)
1047 }
1048 if got := resp.Header.Get("Carlos-Purge"); got != "" {
1049 t.Fatalf("the FINAL response carried Carlos-Purge: %q", got)
1050 }
1051}
1052
1053// TestPurgeHeaderInTrailerIsNotHonoured — the other half of the narrowing. A
1054// header announced via Trailer and sent after the body is not in resp.Header
1055// when ModifyResponse runs, so it is neither honoured nor stripped.
1056func TestPurgeHeaderInTrailerIsNotHonoured(t *testing.T) {
1057 e, _ := purgeEdge(t, "trail.example.com", false, func(w http.ResponseWriter, r *http.Request) {
1058 w.Header().Set("Trailer", "Carlos-Purge")
1059 w.WriteHeader(http.StatusOK)
1060 io.WriteString(w, "body")
1061 // Set AFTER the body: net/http moves this into the trailer block.
1062 w.Header().Set("Carlos-Purge", "/trailed")
1063 })
1064 seed(t, e.cache, "trail.example.com", "/trailed", "", "still here")
1065
1066 srv := httptest.NewServer(e)
1067 t.Cleanup(srv.Close)
1068
1069 req, err := http.NewRequest("POST", srv.URL+"/write", nil)
1070 if err != nil {
1071 t.Fatalf("new request: %v", err)
1072 }
1073 req.Host = "trail.example.com"
1074 resp, err := http.DefaultClient.Do(req)
1075 if err != nil {
1076 t.Fatalf("do: %v", err)
1077 }
1078 body, _ := io.ReadAll(resp.Body)
1079 resp.Body.Close()
1080
1081 // Proves the backend actually ran and the trailer was actually sent —
1082 // without this the assertion below could pass because nothing happened.
1083 if string(body) != "body" {
1084 t.Fatalf("body = %q, want %q", body, "body")
1085 }
1086 if got := resp.Trailer.Get("Carlos-Purge"); got != "/trailed" {
1087 t.Fatalf("the trailer did not arrive (got %q) — this test proves nothing without it", got)
1088 }
1089 if !cached(e.cache, "trail.example.com", "/trailed", "") {
1090 t.Fatal("a Carlos-Purge sent as a TRAILER was honoured — the contract says final-response headers only")
1091 }
1092}
1093
1094// TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook is the footgun the
1095// contract now names explicitly. httputil.ReverseProxy calls
1096// removeHopByHopHeaders(res.Header) BEFORE ModifyResponse, so an app that
1097// names Carlos-Purge in its Connection header has its declaration deleted
1098// before this package can ever see it — it purges nothing, silently, and the
1099// app author has no way to tell from the outside.
1100func TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook(t *testing.T) {
1101 e, _ := purgeEdge(t, "conn.example.com", false, func(w http.ResponseWriter, r *http.Request) {
1102 w.Header().Set("Connection", "Carlos-Purge")
1103 w.Header().Set("Carlos-Purge", "/conn")
1104 w.WriteHeader(http.StatusOK)
1105 })
1106 seed(t, e.cache, "conn.example.com", "/conn", "", "still here")
1107
1108 rec := httptest.NewRecorder()
1109 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://conn.example.com/write", nil))
1110
1111 if !cached(e.cache, "conn.example.com", "/conn", "") {
1112 t.Fatal("a Connection-listed Carlos-Purge was honoured — if the stdlib stopped " +
1113 "treating it as hop-by-hop, the contract note about Connection is now wrong")
1114 }
1115 if got := rec.Header().Get("Carlos-Purge"); got != "" {
1116 t.Fatalf("Carlos-Purge reached the client: %q", got)
1117 }
1118}
1119
1120// ---------------------------------------------------------------------------
1121// Log rate-limiting.
1122// ---------------------------------------------------------------------------
1123
1124// TestPurgeHeaderComplaintLogIsRateLimited — every one of these lines is
1125// triggered by a remote party's response on the request hot path, so an app
1126// that emits a malformed declaration on every response would write one journal
1127// line per request forever. Same first-then-every-300th shape as the registry
1128// watcher in edge.go.
1129func TestPurgeHeaderComplaintLogIsRateLimited(t *testing.T) {
1130 var buf bytes.Buffer
1131 log.SetOutput(&buf)
1132 t.Cleanup(func() { log.SetOutput(os.Stderr) })
1133
1134 e, _ := purgeEdge(t, "noisy.example.com", false, func(w http.ResponseWriter, r *http.Request) {
1135 w.Header().Set("Carlos-Purge", "a.example.com/smuggled")
1136 w.WriteHeader(http.StatusOK)
1137 })
1138 const requests = 12
1139 for i := 0; i < requests; i++ {
1140 rec := httptest.NewRecorder()
1141 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://noisy.example.com/write", nil))
1142 }
1143
1144 got := strings.Count(buf.String(), "malformed Carlos-Purge target(s)")
1145 if got != 1 {
1146 t.Fatalf("%d requests produced %d complaint lines, want exactly 1 (the first); log:\n%s",
1147 requests, got, buf.String())
1148 }
1149 // The first is always logged — nothing is silently swallowed.
1150 if !strings.Contains(buf.String(), "occurrence #1") {
1151 t.Fatalf("the first occurrence was not logged with its count; log:\n%s", buf.String())
1152 }
1153}
1154
1155// ---------------------------------------------------------------------------
1156// Carlos-Purge-Host: redirecting one response's declaration to a SIBLING route
1157// of the same app and account (spec §6.4).
1158// ---------------------------------------------------------------------------
1159
1160// purgeOverrideEdge builds a cached edge over two ready routes sharing one
1161// backend handler — the Tito topology in miniature. `a` is the route the
1162// request is served on (the root domain, where admin edits and Stripe webhooks
1163// land); `b` is the sibling whose public pages are the cached ones
1164// ({slug}.go.tito.io). Callers set Host/App/Account on each; everything else is
1165// filled in here.
1166func purgeOverrideEdge(t *testing.T, a, b registry.Route, h http.HandlerFunc) (*Edge, *registry.Registry) {
1167 t.Helper()
1168 backend := httptest.NewServer(h)
1169 t.Cleanup(backend.Close)
1170 reg := openReg(t)
1171 for _, rt := range []registry.Route{a, b} {
1172 rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready"
1173 if err := reg.Upsert(rt); err != nil {
1174 t.Fatalf("upsert %s: %v", rt.Host, err)
1175 }
1176 }
1177 e, err := New(reg, nil)
1178 if err != nil {
1179 t.Fatalf("New: %v", err)
1180 }
1181 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
1182 return e, reg
1183}
1184
1185// route is shorthand for the three fields these tests actually vary.
1186func route(host, app, account string) registry.Route {
1187 return registry.Route{Host: host, App: app, Account: account}
1188}
1189
1190// TestPurgeHostOverridePurgesTheSiblingRoute is the headline of this feature:
1191// the admin/webhook write arrives on the root-domain route while the stale
1192// pages live on the per-tenant route. Same app, same account, so the
1193// declaration is redirected there — and the serving host's own entries are
1194// left alone, because the app named exactly one host and it was not this one.
1195func TestPurgeHostOverridePurgesTheSiblingRoute(t *testing.T) {
1196 e, _ := purgeOverrideEdge(t,
1197 route("go.tito.example", "tito", "tenanta"),
1198 route("acme.go.tito.example", "tito", "tenanta"),
1199 func(w http.ResponseWriter, r *http.Request) {
1200 w.Header().Set("Carlos-Purge-Host", "acme.go.tito.example")
1201 w.Header().Set("Carlos-Purge", "/events/42, /events/42?lang=fr")
1202 w.WriteHeader(http.StatusFound)
1203 })
1204 seed(t, e.cache, "acme.go.tito.example", "/events/42", "", "old")
1205 seed(t, e.cache, "acme.go.tito.example", "/events/42", "lang=fr", "vieux")
1206 seed(t, e.cache, "acme.go.tito.example", "/events/43", "", "untouched")
1207 seed(t, e.cache, "go.tito.example", "/events/42", "", "serving host")
1208
1209 rec := httptest.NewRecorder()
1210 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://go.tito.example/admin/events/42", nil))
1211
1212 if cached(e.cache, "acme.go.tito.example", "/events/42", "") {
1213 t.Fatal("the override host's /events/42 survived")
1214 }
1215 if cached(e.cache, "acme.go.tito.example", "/events/42", "lang=fr") {
1216 t.Fatal("the override host's /events/42?lang=fr survived")
1217 }
1218 if !cached(e.cache, "acme.go.tito.example", "/events/43", "") {
1219 t.Fatal("/events/43 was purged but nothing named it")
1220 }
1221 if !cached(e.cache, "go.tito.example", "/events/42", "") {
1222 t.Fatal("the SERVING host was purged too — the override redirects, it does not fan out")
1223 }
1224 if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
1225 t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
1226 }
1227}
1228
1229// TestPurgeHostOverrideRefusedAcrossApps — the boundary this feature must
1230// never cross. A different app on the same box is a different tenant, and the
1231// whole declaration is dropped rather than falling back to the serving host: a
1232// half-honoured purge is worse than none, because it looks like it worked.
1233func TestPurgeHostOverrideRefusedAcrossApps(t *testing.T) {
1234 e, _ := purgeOverrideEdge(t,
1235 route("a.example.com", "app-x", "tenanta"),
1236 route("b.example.com", "app-y", "tenanta"),
1237 func(w http.ResponseWriter, r *http.Request) {
1238 w.Header().Set("Carlos-Purge-Host", "b.example.com")
1239 w.Header().Set("Carlos-Purge", "/x")
1240 w.WriteHeader(http.StatusOK)
1241 })
1242 seed(t, e.cache, "a.example.com", "/x", "", "A")
1243 seed(t, e.cache, "b.example.com", "/x", "", "B")
1244
1245 rec := httptest.NewRecorder()
1246 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1247
1248 if !cached(e.cache, "b.example.com", "/x", "") {
1249 t.Fatal("a route of a DIFFERENT APP was purged across the tenancy boundary")
1250 }
1251 if !cached(e.cache, "a.example.com", "/x", "") {
1252 t.Fatal("the refused declaration fell back to the serving host instead of being dropped")
1253 }
1254 if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
1255 t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
1256 }
1257}
1258
1259// TestPurgeHostOverrideRefusedAcrossAccounts — same app name, different
1260// account. App equality alone is not the boundary: two accounts can run the
1261// same app, and one tenant's admin must not reach the other's cache.
1262//
1263// Both fixture accounts must be VALID sqids (release.ValidAccount —
1264// ^[a-z]{3,16}$). The comparison runs through release.RouteAccount, which maps
1265// anything unset or name-shaped onto the built-in ops account, so two
1266// placeholder spellings like "acct-1"/"acct-2" would both resolve to ops and
1267// this test would assert a boundary it was not actually crossing.
1268func TestPurgeHostOverrideRefusedAcrossAccounts(t *testing.T) {
1269 e, _ := purgeOverrideEdge(t,
1270 route("one.example.com", "tito", "tenanta"),
1271 route("two.example.com", "tito", "tenantb"),
1272 func(w http.ResponseWriter, r *http.Request) {
1273 w.Header().Set("Carlos-Purge-Host", "two.example.com")
1274 w.Header().Set("Carlos-Purge", "/x")
1275 w.WriteHeader(http.StatusOK)
1276 })
1277 seed(t, e.cache, "one.example.com", "/x", "", "one")
1278 seed(t, e.cache, "two.example.com", "/x", "", "two")
1279
1280 rec := httptest.NewRecorder()
1281 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://one.example.com/write", nil))
1282
1283 if !cached(e.cache, "two.example.com", "/x", "") {
1284 t.Fatal("a route of a DIFFERENT ACCOUNT was purged")
1285 }
1286 if !cached(e.cache, "one.example.com", "/x", "") {
1287 t.Fatal("the refused declaration fell back to the serving host")
1288 }
1289}
1290
1291// TestPurgeHostOverrideUnknownHostIsRefused — a host the route table does not
1292// know cannot be validated against anything, so the declaration is dropped.
1293// Note the seeded entry: the cache is keyed by host with no reference to the
1294// route table, so an unvalidated override would happily purge a host that has
1295// no route at all.
1296func TestPurgeHostOverrideUnknownHostIsRefused(t *testing.T) {
1297 e, _ := purgeOverrideEdge(t,
1298 route("known.example.com", "tito", "tenanta"),
1299 route("sibling.example.com", "tito", "tenanta"),
1300 func(w http.ResponseWriter, r *http.Request) {
1301 w.Header().Set("Carlos-Purge-Host", "ghost.example.com")
1302 w.Header().Set("Carlos-Purge", "/x")
1303 w.WriteHeader(http.StatusOK)
1304 })
1305 seed(t, e.cache, "ghost.example.com", "/x", "", "ghost")
1306 seed(t, e.cache, "known.example.com", "/x", "", "known")
1307
1308 rec := httptest.NewRecorder()
1309 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://known.example.com/write", nil))
1310
1311 if !cached(e.cache, "ghost.example.com", "/x", "") {
1312 t.Fatal("an unrouted host's entries were purged")
1313 }
1314 if !cached(e.cache, "known.example.com", "/x", "") {
1315 t.Fatal("the refused declaration fell back to the serving host")
1316 }
1317}
1318
1319// TestPurgeHostOverrideTwoValuesPurgeTheServedOne — this used to pin "at most ONE
1320// host", with two names refused as malformed. #173 made the header a list, so
1321// two names now mean "purge both" — on separate lines or comma-joined on one
1322// (RFC 9110 §5.3). c.example.com has no route here, so it is skipped rather
1323// than purged (see TestPurgeHostListSkipsUnservedHosts), and the serving host
1324// a.example.com is untouched because neither spelling names it.
1325func TestPurgeHostOverrideTwoValuesPurgeTheServedOne(t *testing.T) {
1326 for _, tc := range []struct {
1327 name string
1328 set func(http.Header)
1329 }{
1330 {"two lines", func(h http.Header) {
1331 h.Add("Carlos-Purge-Host", "b.example.com")
1332 h.Add("Carlos-Purge-Host", "c.example.com")
1333 }},
1334 {"comma joined", func(h http.Header) {
1335 h.Set("Carlos-Purge-Host", "b.example.com, c.example.com")
1336 }},
1337 } {
1338 t.Run(tc.name, func(t *testing.T) {
1339 e, _ := purgeOverrideEdge(t,
1340 route("a.example.com", "tito", "tenanta"),
1341 route("b.example.com", "tito", "tenanta"),
1342 func(w http.ResponseWriter, r *http.Request) {
1343 tc.set(w.Header())
1344 w.Header().Set("Carlos-Purge", "/x")
1345 w.WriteHeader(http.StatusOK)
1346 })
1347 seed(t, e.cache, "a.example.com", "/x", "", "A")
1348 seed(t, e.cache, "b.example.com", "/x", "", "B")
1349 seed(t, e.cache, "c.example.com", "/x", "", "C")
1350
1351 rec := httptest.NewRecorder()
1352 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1353
1354 if cached(e.cache, "b.example.com", "/x", "") {
1355 t.Fatal("b.example.com survived a list that named it")
1356 }
1357 for _, h := range []string{"a.example.com", "c.example.com"} {
1358 if !cached(e.cache, h, "/x", "") {
1359 t.Fatalf("%s was purged, but it is unnamed or unserved", h)
1360 }
1361 }
1362 })
1363 }
1364}
1365
1366// TestPurgeHostOverrideMalformedValuesRefused — the value is a bare hostname
1367// and nothing else. Every shape below is refused outright rather than
1368// normalized down to a hostname: an authority with a port is a different
1369// string from a host key, and "strip it and carry on" is how a purge lands
1370// somewhere its author did not name.
1371func TestPurgeHostOverrideMalformedValuesRefused(t *testing.T) {
1372 for _, bad := range []string{
1373 "b.example.com:443",
1374 "https://b.example.com",
1375 "//b.example.com",
1376 `b.example.com\`,
1377 "B.example.com",
1378 "b.example.com/",
1379 "b.example.com/x",
1380 "b example.com",
1381 "user@b.example.com",
1382 "b.example.com?x=1",
1383 "b.example.com#f",
1384 "",
1385 } {
1386 t.Run(fmt.Sprintf("%q", bad), func(t *testing.T) {
1387 e, _ := purgeOverrideEdge(t,
1388 route("a.example.com", "tito", "tenanta"),
1389 route("b.example.com", "tito", "tenanta"),
1390 func(w http.ResponseWriter, r *http.Request) {
1391 w.Header().Set("Carlos-Purge-Host", bad)
1392 w.Header().Set("Carlos-Purge", "/x")
1393 w.WriteHeader(http.StatusOK)
1394 })
1395 seed(t, e.cache, "a.example.com", "/x", "", "A")
1396 seed(t, e.cache, "b.example.com", "/x", "", "B")
1397
1398 rec := httptest.NewRecorder()
1399 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1400
1401 if !cached(e.cache, "b.example.com", "/x", "") {
1402 t.Fatalf("Carlos-Purge-Host %q was normalized into a real host and honoured", bad)
1403 }
1404 if !cached(e.cache, "a.example.com", "/x", "") {
1405 t.Fatalf("Carlos-Purge-Host %q fell back to the serving host", bad)
1406 }
1407 })
1408 }
1409}
1410
1411// TestPurgeHostOverrideStarPurgesTheOverrideHost — "*" is a target like any
1412// other, so the override redirects it too: the whole SIBLING host is purged
1413// and the serving host is untouched.
1414func TestPurgeHostOverrideStarPurgesTheOverrideHost(t *testing.T) {
1415 e, _ := purgeOverrideEdge(t,
1416 route("a.example.com", "tito", "tenanta"),
1417 route("b.example.com", "tito", "tenanta"),
1418 func(w http.ResponseWriter, r *http.Request) {
1419 w.Header().Set("Carlos-Purge-Host", "b.example.com")
1420 w.Header().Set("Carlos-Purge", "*")
1421 w.WriteHeader(http.StatusNoContent)
1422 })
1423 seed(t, e.cache, "b.example.com", "/one", "", "1")
1424 seed(t, e.cache, "b.example.com", "/two", "x=1", "2")
1425 seed(t, e.cache, "a.example.com", "/one", "", "serving")
1426
1427 rec := httptest.NewRecorder()
1428 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/deploy", nil))
1429
1430 if cached(e.cache, "b.example.com", "/one", "") || cached(e.cache, "b.example.com", "/two", "x=1") {
1431 t.Fatal("* did not purge the whole OVERRIDE host")
1432 }
1433 if !cached(e.cache, "a.example.com", "/one", "") {
1434 t.Fatal("* purged the serving host as well as the override host")
1435 }
1436}
1437
1438// TestPurgeHostOverrideCapDegradeHitsTheOverrideHost — the degrade path
1439// (§6.1's bounds) must follow the override too. Degrading to the SERVING
1440// host's PurgeHost would be the worst of both: it wipes a host nobody asked
1441// about and leaves the stale pages the app was trying to invalidate.
1442func TestPurgeHostOverrideCapDegradeHitsTheOverrideHost(t *testing.T) {
1443 targets := make([]string, maxPurgeTargets+1)
1444 for i := range targets {
1445 targets[i] = fmt.Sprintf("/p/%d", i)
1446 }
1447 e, _ := purgeOverrideEdge(t,
1448 route("a.example.com", "tito", "tenanta"),
1449 route("b.example.com", "tito", "tenanta"),
1450 func(w http.ResponseWriter, r *http.Request) {
1451 w.Header().Set("Carlos-Purge-Host", "b.example.com")
1452 w.Header().Set("Carlos-Purge", strings.Join(targets, ", "))
1453 w.WriteHeader(http.StatusOK)
1454 })
1455 seed(t, e.cache, "b.example.com", "/unnamed", "", "B")
1456 seed(t, e.cache, "a.example.com", "/unnamed", "", "A")
1457
1458 rec := httptest.NewRecorder()
1459 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1460
1461 if cached(e.cache, "b.example.com", "/unnamed", "") {
1462 t.Fatal("the cap degrade did not purge the OVERRIDE host")
1463 }
1464 if !cached(e.cache, "a.example.com", "/unnamed", "") {
1465 t.Fatal("the cap degrade purged the SERVING host instead of the override host")
1466 }
1467}
1468
1469// TestPurgeHostOverrideResolvesAgainstTheCurrentRouteTable — the override is
1470// validated against e.current() at RESPONSE time, not against a table snapshot
1471// taken when the proxy was built. A modifier that captured the map at build
1472// time would hold the table as it was BEFORE its own reload, so a route added
1473// by the very reload that rebuilt this proxy would be invisible to it.
1474func TestPurgeHostOverrideResolvesAgainstTheCurrentRouteTable(t *testing.T) {
1475 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1476 w.Header().Set("Carlos-Purge-Host", "late.example.com")
1477 w.Header().Set("Carlos-Purge", "/x")
1478 w.WriteHeader(http.StatusOK)
1479 }))
1480 t.Cleanup(backend.Close)
1481 reg := openReg(t)
1482 addr := backend.Listener.Addr().String()
1483 if err := reg.Upsert(registry.Route{Host: "early.example.com", App: "tito", Account: "tenanta", Kind: "instance", Addr: addr, Status: "ready"}); err != nil {
1484 t.Fatalf("upsert: %v", err)
1485 }
1486 e, err := New(reg, nil)
1487 if err != nil {
1488 t.Fatalf("New: %v", err)
1489 }
1490 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
1491 seed(t, e.cache, "late.example.com", "/x", "", "late")
1492
1493 // The sibling arrives after the edge was built, exactly as `carlos add`
1494 // does in production; the watcher's Reload picks it up.
1495 if err := reg.Upsert(registry.Route{Host: "late.example.com", App: "tito", Account: "tenanta", Kind: "instance", Addr: addr, Status: "ready"}); err != nil {
1496 t.Fatalf("upsert late: %v", err)
1497 }
1498 if err := e.Reload(); err != nil {
1499 t.Fatalf("reload: %v", err)
1500 }
1501
1502 rec := httptest.NewRecorder()
1503 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://early.example.com/write", nil))
1504
1505 if cached(e.cache, "late.example.com", "/x", "") {
1506 t.Fatal("a route added by the reload that rebuilt this proxy was invisible to the override check")
1507 }
1508}
1509
1510// TestPurgeHostHeaderStrippedWithNoTargets — the strip is unconditional. A
1511// response carrying only Carlos-Purge-Host declares no purge at all, and the
1512// header still must not reach a client.
1513func TestPurgeHostHeaderStrippedWithNoTargets(t *testing.T) {
1514 e, _ := purgeOverrideEdge(t,
1515 route("a.example.com", "tito", "tenanta"),
1516 route("b.example.com", "tito", "tenanta"),
1517 func(w http.ResponseWriter, r *http.Request) {
1518 w.Header().Set("Carlos-Purge-Host", "b.example.com")
1519 io.WriteString(w, "ok")
1520 })
1521
1522 rec := httptest.NewRecorder()
1523 e.ServeHTTP(rec, httptest.NewRequest("GET", "http://a.example.com/page", nil))
1524 if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
1525 t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
1526 }
1527}
1528
1529// TestPurgeHostHeaderWithoutCacheIsStillStripped — same as its Carlos-Purge
1530// sibling: with caching disabled there is nothing to redirect, but the header
1531// is an edge-internal signal and must never be forwarded.
1532func TestPurgeHostHeaderWithoutCacheIsStillStripped(t *testing.T) {
1533 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1534 w.Header().Set("Carlos-Purge-Host", "other.example.com")
1535 w.Header().Set("Carlos-Purge", "/a")
1536 io.WriteString(w, "ok")
1537 }))
1538 t.Cleanup(backend.Close)
1539 reg := openReg(t)
1540 if err := reg.Upsert(registry.Route{Host: "nocache2.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
1541 t.Fatalf("upsert: %v", err)
1542 }
1543 e, err := New(reg, nil)
1544 if err != nil {
1545 t.Fatalf("New: %v", err)
1546 }
1547
1548 rec := httptest.NewRecorder()
1549 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://nocache2.example.com/write", nil))
1550 if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
1551 t.Fatalf("Carlos-Purge-Host reached the client with caching disabled: %q", got)
1552 }
1553}
1554
1555// TestPurgeHostHeaderScrubbedFromRequestBeforeUpstream — reflection is still
1556// the attack. A cross-host purge inside one app is a purge an outsider must
1557// not be able to trigger, so the inbound scrub covers this header exactly as
1558// it covers Carlos-Purge.
1559func TestPurgeHostHeaderScrubbedFromRequestBeforeUpstream(t *testing.T) {
1560 seen := make(chan string, 1)
1561 e, _ := purgeOverrideEdge(t,
1562 route("a.example.com", "tito", "tenanta"),
1563 route("b.example.com", "tito", "tenanta"),
1564 func(w http.ResponseWriter, r *http.Request) {
1565 seen <- strings.Join(r.Header.Values(PurgeHostHeader), "|")
1566 io.WriteString(w, "ok")
1567 })
1568
1569 req := httptest.NewRequest("POST", "http://a.example.com/write", nil)
1570 req.Header.Add(PurgeHostHeader, "b.example.com")
1571 req.Header.Add(PurgeHostHeader, "c.example.com")
1572 rec := httptest.NewRecorder()
1573 e.ServeHTTP(rec, req)
1574
1575 if got := <-seen; got != "" {
1576 t.Fatalf("upstream saw Carlos-Purge-Host %q — an echoing app would launder it into a cross-host purge", got)
1577 }
1578}
1579
1580// The same scrub on the unix-socket proxy: unixProxy and tcpProxy have
1581// separate Rewrite funcs, so a fix applied to one is not applied to the other.
1582func TestPurgeHostHeaderScrubbedFromRequestOverUnixSocket(t *testing.T) {
1583 sock := filepath.Join(t.TempDir(), "u.sock")
1584 ln, err := net.Listen("unix", sock)
1585 if err != nil {
1586 if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") {
1587 t.Skipf("unix-socket listeners not permitted in this environment: %v", err)
1588 }
1589 t.Fatalf("listen unix: %v", err)
1590 }
1591 seen := make(chan string, 1)
1592 backend := &httptest.Server{
1593 Listener: ln,
1594 Config: &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1595 seen <- strings.Join(r.Header.Values(PurgeHostHeader), "|")
1596 io.WriteString(w, "ok")
1597 })},
1598 }
1599 backend.Start()
1600 t.Cleanup(backend.Close)
1601
1602 reg := openReg(t)
1603 if err := reg.Upsert(registry.Route{Host: "sockscrub2.example.com", Kind: "instance", Socket: sock, Status: "ready"}); err != nil {
1604 t.Fatalf("upsert: %v", err)
1605 }
1606 e, err := New(reg, nil)
1607 if err != nil {
1608 t.Fatalf("New: %v", err)
1609 }
1610 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
1611
1612 req := httptest.NewRequest("POST", "http://sockscrub2.example.com/write", nil)
1613 req.Header.Set(PurgeHostHeader, "other.example.com")
1614 rec := httptest.NewRecorder()
1615 e.ServeHTTP(rec, req)
1616
1617 if got := <-seen; got != "" {
1618 t.Fatalf("unix-socket upstream saw Carlos-Purge-Host %q", got)
1619 }
1620}
1621
1622// TestPurgeHostOverrideRefusalIsLoggedRateLimited — refusals are driven by a
1623// remote party's response on the request hot path, so they share the
1624// first-then-every-300th shape every other purge complaint uses.
1625func TestPurgeHostOverrideRefusalIsLoggedRateLimited(t *testing.T) {
1626 var buf bytes.Buffer
1627 log.SetOutput(&buf)
1628 t.Cleanup(func() { log.SetOutput(os.Stderr) })
1629
1630 e, _ := purgeOverrideEdge(t,
1631 route("a.example.com", "app-x", "tenanta"),
1632 route("b.example.com", "app-y", "tenanta"),
1633 func(w http.ResponseWriter, r *http.Request) {
1634 w.Header().Set("Carlos-Purge-Host", "b.example.com")
1635 w.Header().Set("Carlos-Purge", "/x")
1636 w.WriteHeader(http.StatusOK)
1637 })
1638 const requests = 12
1639 for i := 0; i < requests; i++ {
1640 rec := httptest.NewRecorder()
1641 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1642 }
1643
1644 got := strings.Count(buf.String(), "Carlos-Purge-Host")
1645 if got != 1 {
1646 t.Fatalf("%d requests produced %d complaint lines, want exactly 1 (the first); log:\n%s",
1647 requests, got, buf.String())
1648 }
1649 if !strings.Contains(buf.String(), "occurrence #1") {
1650 t.Fatalf("the first occurrence was not logged with its count; log:\n%s", buf.String())
1651 }
1652}
1653
1654// TestPurgeHostOverrideNamingTheServingHostIsANoOp — naming your own host is
1655// trivially same-app/same-account and must behave exactly as no override at
1656// all, so an app can emit the header unconditionally from shared code.
1657func TestPurgeHostOverrideNamingTheServingHostIsANoOp(t *testing.T) {
1658 e, _ := purgeOverrideEdge(t,
1659 route("a.example.com", "tito", "tenanta"),
1660 route("b.example.com", "tito", "tenanta"),
1661 func(w http.ResponseWriter, r *http.Request) {
1662 w.Header().Set("Carlos-Purge-Host", "a.example.com")
1663 w.Header().Set("Carlos-Purge", "/x")
1664 w.WriteHeader(http.StatusOK)
1665 })
1666 seed(t, e.cache, "a.example.com", "/x", "", "A")
1667 seed(t, e.cache, "b.example.com", "/x", "", "B")
1668
1669 rec := httptest.NewRecorder()
1670 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1671
1672 if cached(e.cache, "a.example.com", "/x", "") {
1673 t.Fatal("naming the serving host did not purge it")
1674 }
1675 if !cached(e.cache, "b.example.com", "/x", "") {
1676 t.Fatal("naming the serving host purged the sibling")
1677 }
1678}
1679
1680// TestPurgeHostOverrideRefusedWithoutAnApp — a route with no App has no
1681// releases and no provable tenancy, so "" == "" is not an app match. Without
1682// this, two unrelated App-less routes on one box could purge each other.
1683func TestPurgeHostOverrideRefusedWithoutAnApp(t *testing.T) {
1684 e, _ := purgeOverrideEdge(t,
1685 route("a.example.com", "", ""),
1686 route("b.example.com", "", ""),
1687 func(w http.ResponseWriter, r *http.Request) {
1688 w.Header().Set("Carlos-Purge-Host", "b.example.com")
1689 w.Header().Set("Carlos-Purge", "/x")
1690 w.WriteHeader(http.StatusOK)
1691 })
1692 seed(t, e.cache, "a.example.com", "/x", "", "A")
1693 seed(t, e.cache, "b.example.com", "/x", "", "B")
1694
1695 rec := httptest.NewRecorder()
1696 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1697
1698 if !cached(e.cache, "b.example.com", "/x", "") {
1699 t.Fatal("two App-less routes purged each other")
1700 }
1701 if !cached(e.cache, "a.example.com", "/x", "") {
1702 t.Fatal("the refused declaration fell back to the serving host")
1703 }
1704}
1705
1706// ---------------------------------------------------------------------------
1707// Carlos-Purge-Host: the review wave's additions (findings 1-4, 7).
1708// ---------------------------------------------------------------------------
1709
1710// TestPurgeHostHeaderListedInConnectionRetargetsAtTheServingHost pins the
1711// worst footgun this header has, and it is deliberately an assertion of
1712// TODAY'S behaviour rather than a fix.
1713//
1714// httputil.ReverseProxy calls removeHopByHopHeaders(res.Header) BEFORE
1715// ModifyResponse. An app that names Carlos-Purge-Host in its Connection header
1716// therefore has the REDIRECT deleted while the Carlos-Purge line beside it
1717// SURVIVES — so the declaration is silently retargeted at the serving host.
1718// That is a half-honoured purge, the exact outcome the all-or-nothing rule
1719// exists to prevent, and the edge cannot refuse it: the evidence was destroyed
1720// before this package was ever called, so there is nothing left to detect. It
1721// is a contract obligation on the app (see PurgeHostHeader's doc comment and
1722// spec §6.4), not something the edge can enforce.
1723//
1724// Compare TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook: naming
1725// Carlos-Purge that way merely LOSES the purge, which is the benign direction.
1726// Naming this one clears the wrong host and looks like it worked.
1727func TestPurgeHostHeaderListedInConnectionRetargetsAtTheServingHost(t *testing.T) {
1728 e, _ := purgeOverrideEdge(t,
1729 route("a.example.com", "tito", "tenanta"),
1730 route("b.example.com", "tito", "tenanta"),
1731 func(w http.ResponseWriter, r *http.Request) {
1732 w.Header().Set("Connection", "Carlos-Purge-Host")
1733 w.Header().Set("Carlos-Purge-Host", "b.example.com")
1734 w.Header().Set("Carlos-Purge", "/x")
1735 w.WriteHeader(http.StatusOK)
1736 })
1737 seed(t, e.cache, "a.example.com", "/x", "", "A")
1738 seed(t, e.cache, "b.example.com", "/x", "", "B")
1739
1740 rec := httptest.NewRecorder()
1741 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1742
1743 if cached(e.cache, "a.example.com", "/x", "") {
1744 t.Fatal("the SERVING host was NOT purged — the stdlib appears to have stopped " +
1745 "deleting Connection-listed headers before ModifyResponse, which would make " +
1746 "the Connection warning in PurgeHostHeader's contract and spec §6.4 wrong")
1747 }
1748 if !cached(e.cache, "b.example.com", "/x", "") {
1749 t.Fatal("the OVERRIDE host was purged — the hop-by-hop strip no longer eats " +
1750 "Carlos-Purge-Host, so the half-honoured-purge footgun is gone and the " +
1751 "contract note about it should be revisited")
1752 }
1753 if got := rec.Header().Get("Carlos-Purge"); got != "" {
1754 t.Fatalf("Carlos-Purge reached the client: %q", got)
1755 }
1756 if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
1757 t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
1758 }
1759}
1760
1761// TestPurgeHostOverrideAccountIsResolvedNotComparedRaw — the account halves
1762// are compared through release.RouteAccount, the same function that decides
1763// which account's bucket keys a route's app lives under, not as raw column
1764// bytes.
1765//
1766// An unset account column resolves to the built-in ops account
1767// (release.OpsAccount), so a route stamped "" and a route stamped "bab" are
1768// the SAME tenant everywhere else on the box — same key space, same releases.
1769// A raw comparison splits that one account in two and refuses a purge between
1770// two routes that provably share everything. This is the pre-multi-account
1771// registry state a real box can still be carrying.
1772func TestPurgeHostOverrideAccountIsResolvedNotComparedRaw(t *testing.T) {
1773 e, _ := purgeOverrideEdge(t,
1774 route("a.example.com", "tito", ""),
1775 route("b.example.com", "tito", release.OpsAccount),
1776 func(w http.ResponseWriter, r *http.Request) {
1777 w.Header().Set("Carlos-Purge-Host", "b.example.com")
1778 w.Header().Set("Carlos-Purge", "/x")
1779 w.WriteHeader(http.StatusOK)
1780 })
1781 seed(t, e.cache, "a.example.com", "/x", "", "A")
1782 seed(t, e.cache, "b.example.com", "/x", "", "B")
1783
1784 rec := httptest.NewRecorder()
1785 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1786
1787 if cached(e.cache, "b.example.com", "/x", "") {
1788 t.Fatalf("the override was refused between account %q and account %q, which "+
1789 "release.RouteAccount resolves to the same account (%q)",
1790 "", release.OpsAccount, release.RouteAccount(route("b.example.com", "tito", "")))
1791 }
1792 if !cached(e.cache, "a.example.com", "/x", "") {
1793 t.Fatal("the SERVING host was purged too — the override redirects, it does not fan out")
1794 }
1795}
1796
1797// TestPurgeHostOverrideMalformedShapesRefusedEvenAsRealRouteKeys makes the
1798// shape gate's table NON-VACUOUS.
1799//
1800// TestPurgeHostOverrideMalformedValuesRefused above proves the 12 shapes are
1801// refused, but it cannot prove WHO refuses them: none of those strings is a
1802// route key in its fixture, so the e.current() lookup would reject every one
1803// of them even with validPurgeOverrideHost deleted entirely. Replace the
1804// gate's body with `return true` and that test stays green.
1805//
1806// These two shapes are the ones a route table can genuinely contain — nothing
1807// validates a host column, so an uppercase key or a host:port key can be
1808// upserted — so here they ARE real routes, with a matching app and account.
1809// The lookup now SUCCEEDS and the tenancy check PASSES; the shape gate is the
1810// only thing left that can refuse them. The same mutation turns this test red.
1811func TestPurgeHostOverrideMalformedShapesRefusedEvenAsRealRouteKeys(t *testing.T) {
1812 for _, bad := range []string{"B.example.com", "b.example.com:8443"} {
1813 t.Run(fmt.Sprintf("%q", bad), func(t *testing.T) {
1814 e, _ := purgeOverrideEdge(t,
1815 route("a.example.com", "tito", "tenanta"),
1816 route(bad, "tito", "tenanta"),
1817 func(w http.ResponseWriter, r *http.Request) {
1818 w.Header().Set("Carlos-Purge-Host", bad)
1819 w.Header().Set("Carlos-Purge", "/x")
1820 w.WriteHeader(http.StatusOK)
1821 })
1822 // The guard that keeps this test honest: if the fixture ever stops
1823 // putting the malformed spelling into the table as a real route, the
1824 // lookup starts doing the refusing again and the assertions below go
1825 // back to proving nothing.
1826 if _, ok := e.current()[bad]; !ok {
1827 t.Fatalf("fixture is vacuous: %q is not a route key, so the route-table "+
1828 "lookup would refuse it whatever the shape gate does", bad)
1829 }
1830 seed(t, e.cache, "a.example.com", "/x", "", "A")
1831 seed(t, e.cache, bad, "/x", "", "B")
1832
1833 rec := httptest.NewRecorder()
1834 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1835
1836 if !cached(e.cache, bad, "/x", "") {
1837 t.Fatalf("Carlos-Purge-Host %q was honoured — it resolves to a real route "+
1838 "with a matching app and account, so only validPurgeOverrideHost "+
1839 "stands between this declaration and a purge", bad)
1840 }
1841 if !cached(e.cache, "a.example.com", "/x", "") {
1842 t.Fatalf("Carlos-Purge-Host %q fell back to the serving host", bad)
1843 }
1844 })
1845 }
1846}
1847
1848// TestPurgeHostOverrideRefusedWhenTheServingRouteChangedSinceCapture — the
1849// serving route is a value CAPTURED when the proxy was built, and a reload can
1850// land between that capture and this response.
1851//
1852// The backend below re-stamps the serving route into a different account and
1853// reloads the edge WHILE the request is in flight — the proxy running
1854// ModifyResponse for this response still carries the pre-swap identity. That
1855// stale identity must not be enough to authorise a cross-host purge: the edge
1856// re-reads the serving host from e.current() and refuses when it has changed
1857// or gone. Without that check the captured (tito, tenanta) still matches the
1858// sibling, and the sibling is purged on the strength of an identity the box no
1859// longer serves.
1860func TestPurgeHostOverrideRefusedWhenTheServingRouteChangedSinceCapture(t *testing.T) {
1861 var (
1862 e *Edge
1863 reg *registry.Registry
1864 addr string
1865 )
1866 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1867 // The route table moves under the in-flight request.
1868 if err := reg.Upsert(registry.Route{
1869 Host: "a.example.com", App: "tito", Account: "tenantb",
1870 Kind: "instance", Addr: addr, Status: "ready",
1871 }); err != nil {
1872 t.Errorf("upsert during flight: %v", err)
1873 }
1874 if err := e.Reload(); err != nil {
1875 t.Errorf("reload during flight: %v", err)
1876 }
1877 w.Header().Set("Carlos-Purge-Host", "b.example.com")
1878 w.Header().Set("Carlos-Purge", "/x")
1879 w.WriteHeader(http.StatusOK)
1880 }))
1881 t.Cleanup(backend.Close)
1882 addr = backend.Listener.Addr().String()
1883 reg = openReg(t)
1884 for _, h := range []string{"a.example.com", "b.example.com"} {
1885 if err := reg.Upsert(registry.Route{
1886 Host: h, App: "tito", Account: "tenanta",
1887 Kind: "instance", Addr: addr, Status: "ready",
1888 }); err != nil {
1889 t.Fatalf("upsert %s: %v", h, err)
1890 }
1891 }
1892 var err error
1893 if e, err = New(reg, nil); err != nil {
1894 t.Fatalf("New: %v", err)
1895 }
1896 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
1897 seed(t, e.cache, "a.example.com", "/x", "", "A")
1898 seed(t, e.cache, "b.example.com", "/x", "", "B")
1899
1900 rec := httptest.NewRecorder()
1901 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1902
1903 if !cached(e.cache, "b.example.com", "/x", "") {
1904 t.Fatal("the sibling was purged on the strength of a STALE captured identity — " +
1905 "the serving route had already moved to another account")
1906 }
1907 if !cached(e.cache, "a.example.com", "/x", "") {
1908 t.Fatal("the refused declaration fell back to the serving host")
1909 }
1910}
1911
1912// TestPurgeHostOverrideNonReadyRouteIsRefused — the override resolves against
1913// e.current(), which Reload builds from READY routes only. A route that exists
1914// in the registry but is still 'creating' (or has 'failed') is therefore not
1915// nameable, and this pins that the ready-filter is load-bearing for the gate
1916// rather than incidental: an unready route has no working backend, so purging
1917// its cache would be acting on a tenancy the edge is not currently serving.
1918func TestPurgeHostOverrideNonReadyRouteIsRefused(t *testing.T) {
1919 backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1920 w.Header().Set("Carlos-Purge-Host", "creating.example.com")
1921 w.Header().Set("Carlos-Purge", "/x")
1922 w.WriteHeader(http.StatusOK)
1923 }))
1924 t.Cleanup(backend.Close)
1925 reg := openReg(t)
1926 addr := backend.Listener.Addr().String()
1927 if err := reg.Upsert(registry.Route{
1928 Host: "a.example.com", App: "tito", Account: "tenanta",
1929 Kind: "instance", Addr: addr, Status: "ready",
1930 }); err != nil {
1931 t.Fatalf("upsert serving: %v", err)
1932 }
1933 // Same app, same account: the ONLY thing wrong with it is that it is not
1934 // ready, so it would sail through the tenancy check if Reload had let it in.
1935 if err := reg.Upsert(registry.Route{
1936 Host: "creating.example.com", App: "tito", Account: "tenanta",
1937 Kind: "instance", Addr: addr, Status: "creating",
1938 }); err != nil {
1939 t.Fatalf("upsert sibling: %v", err)
1940 }
1941 e, err := New(reg, nil)
1942 if err != nil {
1943 t.Fatalf("New: %v", err)
1944 }
1945 e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
1946 if _, ok := e.current()["creating.example.com"]; ok {
1947 t.Fatal("fixture: the unready route reached the served map, so this proves nothing")
1948 }
1949 seed(t, e.cache, "a.example.com", "/x", "", "A")
1950 seed(t, e.cache, "creating.example.com", "/x", "", "B")
1951
1952 rec := httptest.NewRecorder()
1953 e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
1954
1955 if !cached(e.cache, "creating.example.com", "/x", "") {
1956 t.Fatal("a registry route that is NOT ready was purged — Reload's ready-filter " +
1957 "is what confines the override to hosts this edge actually serves")
1958 }
1959 if !cached(e.cache, "a.example.com", "/x", "") {
1960 t.Fatal("the refused declaration fell back to the serving host")
1961 }
1962}
1963