package edge import ( "bytes" "fmt" "log" "net/http" "net/http/httptest" "os" "slices" "strings" "testing" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // Two accounts on the apps domain: kmqa (the one under test) and xqzb (its // neighbour, the victim of a tossed cookie). const ( cookieApps = "oncarlos.com" cookieAcct = "kmqa" cookieOther = "xqzb" cookieHost = "shop.kmqa.oncarlos.com" ) // cookieEdge builds an edge (with a cache) over one addr route per host, all // backed by h, under PLATFORM_APPS_DOMAIN=oncarlos.com. Each route's Account // is acct. func cookieEdge(t *testing.T, acct string, h http.HandlerFunc, hosts ...string) *Edge { t.Helper() if !release.ValidAccount(cookieAcct) || !release.ValidAccount(cookieOther) { t.Fatal("test sqids must be valid account names") } t.Setenv("PLATFORM_APPS_DOMAIN", "OnCarlos.com.") backend := httptest.NewServer(h) t.Cleanup(backend.Close) reg := openReg(t) for _, host := range hosts { rt := registry.Route{Host: host, Kind: "instance", App: "shop", Account: acct, Addr: backend.Listener.Addr().String(), Status: "ready"} if err := reg.Upsert(rt); 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)) return e } func setCookies(lines ...string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { for _, l := range lines { w.Header().Add("Set-Cookie", l) } w.Write([]byte("ok")) } } func getCookies(t *testing.T, e *Edge, url string) []string { t.Helper() rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("GET", url, nil)) if rec.Code != 200 { t.Fatalf("GET %s: status %d", url, rec.Code) } return rec.Header().Values("Set-Cookie") } // The headline: a cookie scoped to the apps domain itself, or to another // account's namespace, comes out host-only; everything else about it — and // every cookie on the response, not just the first — is byte-for-byte. func TestCookieScopeRewritesDomainsOutsideTheAccount(t *testing.T) { sent := []string{ "a=1; Domain=oncarlos.com; Path=/; Secure", "b=2; path=/; DOMAIN=.OnCarlos.Com; HttpOnly", "c=3; Domain=xqzb.oncarlos.com", "d=4; Max-Age=60; domain = blog.xqzb.oncarlos.com ; SameSite=Lax", "e=5; Domain=example.org", "f=6; Domain=oncarlos.com.", "g=7; Domain=kmqa.oncarlos.com; Domain=oncarlos.com", } e := cookieEdge(t, cookieAcct, setCookies(sent...), cookieHost) got := getCookies(t, e, "http://"+cookieHost+"/") want := []string{ "a=1; Path=/; Secure", "b=2; path=/; HttpOnly", "c=3", "d=4; Max-Age=60; SameSite=Lax", "e=5", "f=6", "g=7", } if !slices.Equal(got, want) { t.Fatalf("Set-Cookie:\n got %q\nwant %q", got, want) } } // The account's own namespace (so its apps can share a cookie), the host // itself, a host-only cookie and an empty Domain all pass untouched. func TestCookieScopeKeepsTheAccountsOwnScope(t *testing.T) { sent := []string{ "a=1; Domain=kmqa.oncarlos.com; Path=/", "b=2; Domain=.KMQA.oncarlos.com", "c=3; Domain=shop.kmqa.oncarlos.com; Secure", "d=4; Domain=other.kmqa.oncarlos.com", "e=5; Path=/; HttpOnly", "f=6; Domain=; Path=/", "domain=oncarlos.com; Path=/", } e := cookieEdge(t, cookieAcct, setCookies(sent...), cookieHost) if got := getCookies(t, e, "http://"+cookieHost+":443/"); !slices.Equal(got, sent) { t.Fatalf("Set-Cookie changed:\n got %q\nwant %q", got, sent) } } // The namespace comes from the host's own sqid label AND the route's account // agreeing. A route whose row names another account (or none) keeps only its // host: strict, and narrower is the safe way to be wrong. func TestCookieScopeNamespaceNeedsTheRoutesAccount(t *testing.T) { for _, acct := range []string{cookieOther, ""} { e := cookieEdge(t, acct, setCookies("a=1; Domain=kmqa.oncarlos.com", "b=2; Domain=shop.kmqa.oncarlos.com"), cookieHost) got := getCookies(t, e, "http://"+cookieHost+"/") want := []string{"a=1", "b=2; Domain=shop.kmqa.oncarlos.com"} if !slices.Equal(got, want) { t.Fatalf("account %q: got %q, want %q", acct, got, want) } } } // A custom domain is its owner's registrable domain: nothing to enforce. func TestCookieScopeLeavesCustomDomainsAlone(t *testing.T) { sent := []string{"a=1; Domain=example.com; Path=/", "b=2; Domain=oncarlos.com"} e := cookieEdge(t, cookieAcct, setCookies(sent...), "shop.example.com") if got := getCookies(t, e, "http://shop.example.com/"); !slices.Equal(got, sent) { t.Fatalf("custom domain cookies changed: got %q", got) } } // No apps domain configured: the edge changes nothing anywhere. func TestCookieScopeOffWithoutAppsDomain(t *testing.T) { e := cookieEdge(t, cookieAcct, setCookies("a=1; Domain=oncarlos.com"), cookieHost) e.appsDomain = "" if got := getCookies(t, e, "http://"+cookieHost+"/"); !slices.Equal(got, []string{"a=1; Domain=oncarlos.com"}) { t.Fatalf("got %q", got) } } // A Set-Cookie sent as an undeclared trailer on a cacheable response reaches // the recorder as "Trailer:Set-Cookie", which the plain Set-Cookie refusal // never saw: the response was stored and its trailer replayed to every later // visitor. It is delivered rewritten, and never stored; a second visitor // gets a fresh response from the app, with its own cookie, not the first's. func TestCookieScopeTrailerCookieIsNeverReplayed(t *testing.T) { n := 0 e := cookieEdge(t, cookieAcct, func(w http.ResponseWriter, r *http.Request) { n++ w.Header().Set("Cache-Control", "max-age=600") w.Write([]byte("ok")) w.(http.Flusher).Flush() // chunked, so a trailer can follow w.Header().Set(http.TrailerPrefix+"Set-Cookie", fmt.Sprintf("t=%d; Domain=oncarlos.com; Path=/", n)) }, cookieHost) for i, want := range []string{"t=1; Path=/", "t=2; Path=/"} { rec := httptest.NewRecorder() e.ServeHTTP(rec, httptest.NewRequest("GET", "http://"+cookieHost+"/t", nil)) if got := rec.Result().Trailer.Values("Set-Cookie"); !slices.Equal(got, []string{want}) { t.Fatalf("visitor %d trailer: got %q, want %q", i+1, got, want) } if cachedAt(e, cookieHost, "/t") { t.Fatal("a response with a Set-Cookie trailer must never be cached") } } } // The same refusal, at maybeStore, for an app that names the trailer in any // spelling and on a response with no other per-visitor marker. func TestMaybeStoreRefusesTrailerSetCookie(t *testing.T) { e := cookieEdge(t, cookieAcct, setCookies(), cookieHost) for _, k := range []string{http.TrailerPrefix + "Set-Cookie", http.TrailerPrefix + "set-cookie"} { req := httptest.NewRequest("GET", "http://"+cookieHost+"/m", nil) rec := &recordingResponseWriter{ResponseWriter: httptest.NewRecorder(), request: req, cacheable: true, maxBody: 1 << 20} rec.Header().Set("Cache-Control", "max-age=600") rec.Header()[k] = []string{"sid=abc"} rec.WriteHeader(200) rec.Write([]byte("ok")) key := CacheKey{Host: cookieHost, Method: "GET", Path: "/m"} e.maybeStore(key, rec) if _, ok := e.cache.get(key); ok { t.Fatalf("stored a response carrying %q", k) } } } // The cache path delivers the rewritten cookie, and (an older rule) never // stores a Set-Cookie response at all. func TestCookieScopeOnTheCachePath(t *testing.T) { e := cookieEdge(t, cookieAcct, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "max-age=600") w.Header().Add("Set-Cookie", "a=1; Domain=oncarlos.com") w.Header().Add("Set-Cookie", "b=2; Domain=kmqa.oncarlos.com") w.Write([]byte("ok")) }, cookieHost) got := getCookies(t, e, "http://"+cookieHost+"/c") if want := []string{"a=1", "b=2; Domain=kmqa.oncarlos.com"}; !slices.Equal(got, want) { t.Fatalf("got %q, want %q", got, want) } if cachedAt(e, cookieHost, "/c") { t.Fatal("a Set-Cookie response must never be cached") } } // One log line per route per process, naming the route. func TestCookieScopeLogsOncePerRoute(t *testing.T) { var buf bytes.Buffer log.SetOutput(&buf) t.Cleanup(func() { log.SetOutput(os.Stderr) }) e := cookieEdge(t, cookieAcct, setCookies("a=1; Domain=oncarlos.com", "b=2; Domain=oncarlos.com"), cookieHost, "blog.kmqa.oncarlos.com") for range 3 { getCookies(t, e, "http://"+cookieHost+"/") } getCookies(t, e, "http://blog.kmqa.oncarlos.com/") out := buf.String() if n := strings.Count(out, "route "+cookieHost+" "); n != 1 { t.Fatalf("want one line for %s, got %d:\n%s", cookieHost, n, out) } if n := strings.Count(out, "route blog.kmqa.oncarlos.com "); n != 1 { t.Fatalf("want one line for blog, got %d:\n%s", n, out) } } func TestScopeSetCookieUnit(t *testing.T) { allowed := func(d string) bool { return d == "kmqa.oncarlos.com" } for _, tc := range []struct{ in, want string }{ {"a=1", "a=1"}, {"a=1;Domain=oncarlos.com;Path=/", "a=1;Path=/"}, {"a=1; Path=/; Domain=oncarlos.com", "a=1; Path=/"}, {"a=1; Domain=\t.oncarlos.com\t; Secure", "a=1; Secure"}, {"a=1; Domain=.; Secure", "a=1; Domain=.; Secure"}, {"a=1; Domain", "a=1; Domain"}, {"a=Domain=oncarlos.com; Secure", "a=Domain=oncarlos.com; Secure"}, {"a=1; Domain=kmqa.oncarlos.com", "a=1; Domain=kmqa.oncarlos.com"}, } { if got, _ := scopeSetCookie(tc.in, allowed); got != tc.want { t.Errorf("scopeSetCookie(%q) = %q, want %q", tc.in, got, tc.want) } } }