| 1 | package edge |
| 2 | |
| 3 | import ( |
| 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. |
| 30 | func 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. |
| 56 | func 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 | |
| 67 | func 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. |
| 75 | func 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. |
| 108 | func 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. |
| 128 | func 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. |
| 155 | func 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. |
| 190 | func 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. |
| 214 | func 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. |
| 238 | func 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. |
| 274 | func 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. |
| 297 | func 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. |
| 322 | func 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. |
| 342 | func 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. |
| 362 | func 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. |
| 386 | func 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. |
| 405 | func 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. |
| 433 | func 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. |
| 469 | func 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. |
| 496 | func 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. |
| 515 | func 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. |
| 543 | func 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). |
| 603 | func 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. |
| 633 | func 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. |
| 654 | func 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. |
| 681 | func 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. |
| 701 | func 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. |
| 751 | func 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. |
| 786 | func 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". |
| 811 | func 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. |
| 839 | func 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. |
| 866 | func 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. |
| 892 | func 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. |
| 946 | func 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. |
| 990 | func 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. |
| 1056 | func 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. |
| 1100 | func 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. |
| 1129 | func 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. |
| 1166 | func 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. |
| 1186 | func 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. |
| 1195 | func 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. |
| 1233 | func 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. |
| 1268 | func 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. |
| 1296 | func 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. |
| 1325 | func 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. |
| 1371 | func 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. |
| 1414 | func 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. |
| 1442 | func 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. |
| 1474 | func 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. |
| 1513 | func 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. |
| 1532 | func 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. |
| 1559 | func 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. |
| 1582 | func 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. |
| 1625 | func 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. |
| 1657 | func 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. |
| 1683 | func 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. |
| 1727 | func 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. |
| 1772 | func 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. |
| 1811 | func 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. |
| 1860 | func 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. |
| 1918 | func 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 | |