| index c09526e08..492479c7e 100644 |
| --- a/internal/edge/edge_test.go |
| +++ b/internal/edge/edge_test.go |
| @@ -1007,6 +1007,87 @@ func TestServeHTTPConditionalVaryDoesNotPoisonURL(t *testing.T) { |
| } |
| } |
| |
| +// The same root cause on the Authorization rule, but in the read of the |
| +// REQUEST'S Authorization header rather than the response's Cache-Control: |
| +// http.Header.Get returns only the first line, so a request that splits |
| +// Authorization across two lines (an empty first line, the real credential on |
| +// the second — Go's HTTP server accepts exactly this from a client) made Get |
| +// see "" and the request looked unauthenticated. The RFC 9111 §3.5 refusal |
| +// never fired and a credentialed response got cached at the shared edge for |
| +// everyone. |
| +func TestServeHTTPRefusesAuthorizationSplitAcrossHeaderLines(t *testing.T) { |
| + e := cacheProbeEdge(t, "splitauthreq.example.com", func(path string, h http.Header) { |
| + if path == "/public" { |
| + h.Set("Cache-Control", "max-age=60, public") |
| + return |
| + } |
| + h.Set("Cache-Control", "max-age=60") |
| + }) |
| + |
| + req := httptest.NewRequest("GET", "http://splitauthreq.example.com/private", nil) |
| + req.Header.Add("Authorization", "") |
| + req.Header.Add("Authorization", "Bearer secret-token") |
| + e.ServeHTTP(httptest.NewRecorder(), req) |
| + |
| + if n := cachedVariants(e, "splitauthreq.example.com", "/private"); n != 0 { |
| + t.Fatalf("%d variants cached for a request whose Authorization arrived on a second header line, want 0 — a multi-line Authorization is still an Authorization", n) |
| + } |
| + |
| + // Positive control: the same split-Authorization request, but the |
| + // response says public — proving the refusal isn't just breaking caching |
| + // wholesale. |
| + pubReq := httptest.NewRequest("GET", "http://splitauthreq.example.com/public", nil) |
| + pubReq.Header.Add("Authorization", "") |
| + pubReq.Header.Add("Authorization", "Bearer secret-token") |
| + e.ServeHTTP(httptest.NewRecorder(), pubReq) |
| + |
| + if !cachedAt(e, "splitauthreq.example.com", "/public") { |
| + t.Fatal("Cache-Control: public must still be cached even with a split-line Authorization present") |
| + } |
| +} |
| + |
| +// RFC 9111 §4.1 / RFC 9110 §12.5.5: Vary: * means the response is NEVER |
| +// reusable from a shared cache — it signals that the response depends on |
| +// something not captured by request headers at all. The token-scanning loop |
| +// treated "*" as if it were an ordinary header name: varyValue computed the |
| +// constant string "*=;" for every request regardless of who they were, so one |
| +// variant was stored and replayed to everyone. |
| +func TestServeHTTPRefusesVaryStar(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "max-age=60") |
| + w.Header().Set("Vary", "*") |
| + w.WriteHeader(200) |
| + w.Write([]byte("dashboard for " + r.Header.Get("Cookie"))) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "varystar.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatalf("upsert: %v", err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + const url = "http://varystar.example.com/dashboard" |
| + |
| + alice := getWith(t, e, url, "Cookie", "sid=alice").Body.String() |
| + if alice != "dashboard for sid=alice" { |
| + t.Fatalf("alice body = %q, want %q", alice, "dashboard for sid=alice") |
| + } |
| + if n := cachedVariants(e, "varystar.example.com", "/dashboard"); n != 0 { |
| + // Errorf, not Fatalf: the replay assertion below is the live |
| + // consequence and is worth reporting in the same run. |
| + t.Errorf("%d variants cached for a response carrying Vary: *, want 0 — Vary: * means never reusable from a shared cache", n) |
| + } |
| + |
| + bob := getWith(t, e, url, "Cookie", "sid=bob").Body.String() |
| + if bob != "dashboard for sid=bob" { |
| + t.Fatalf("bob was served %q — one visitor's response was replayed to another despite Vary: *", bob) |
| + } |
| +} |
| + |
| // hijackableRecorder embeds httptest.ResponseRecorder (which deliberately |
| // does NOT implement http.Hijacker) and adds a no-op Hijack, standing in for |
| // a real connection's ResponseWriter — the minimum shape needed to prove |