| 1 | package edge |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "fmt" |
| 6 | "log" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "slices" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/registry" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | ) |
| 17 | |
| 18 | // Two accounts on the apps domain: kmqa (the one under test) and xqzb (its |
| 19 | // neighbour, the victim of a tossed cookie). |
| 20 | const ( |
| 21 | cookieApps = "oncarlos.com" |
| 22 | cookieAcct = "kmqa" |
| 23 | cookieOther = "xqzb" |
| 24 | cookieHost = "shop.kmqa.oncarlos.com" |
| 25 | ) |
| 26 | |
| 27 | // cookieEdge builds an edge (with a cache) over one addr route per host, all |
| 28 | // backed by h, under PLATFORM_APPS_DOMAIN=oncarlos.com. Each route's Account |
| 29 | // is acct. |
| 30 | func cookieEdge(t *testing.T, acct string, h http.HandlerFunc, hosts ...string) *Edge { |
| 31 | t.Helper() |
| 32 | if !release.ValidAccount(cookieAcct) || !release.ValidAccount(cookieOther) { |
| 33 | t.Fatal("test sqids must be valid account names") |
| 34 | } |
| 35 | t.Setenv("PLATFORM_APPS_DOMAIN", "OnCarlos.com.") |
| 36 | backend := httptest.NewServer(h) |
| 37 | t.Cleanup(backend.Close) |
| 38 | reg := openReg(t) |
| 39 | for _, host := range hosts { |
| 40 | rt := registry.Route{Host: host, Kind: "instance", App: "shop", Account: acct, Addr: backend.Listener.Addr().String(), Status: "ready"} |
| 41 | if err := reg.Upsert(rt); err != nil { |
| 42 | t.Fatalf("upsert: %v", err) |
| 43 | } |
| 44 | } |
| 45 | e, err := New(reg, nil) |
| 46 | if err != nil { |
| 47 | t.Fatalf("New: %v", err) |
| 48 | } |
| 49 | e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| 50 | return e |
| 51 | } |
| 52 | |
| 53 | func setCookies(lines ...string) http.HandlerFunc { |
| 54 | return func(w http.ResponseWriter, r *http.Request) { |
| 55 | for _, l := range lines { |
| 56 | w.Header().Add("Set-Cookie", l) |
| 57 | } |
| 58 | w.Write([]byte("ok")) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | func getCookies(t *testing.T, e *Edge, url string) []string { |
| 63 | t.Helper() |
| 64 | rec := httptest.NewRecorder() |
| 65 | e.ServeHTTP(rec, httptest.NewRequest("GET", url, nil)) |
| 66 | if rec.Code != 200 { |
| 67 | t.Fatalf("GET %s: status %d", url, rec.Code) |
| 68 | } |
| 69 | return rec.Header().Values("Set-Cookie") |
| 70 | } |
| 71 | |
| 72 | // The headline: a cookie scoped to the apps domain itself, or to another |
| 73 | // account's namespace, comes out host-only; everything else about it — and |
| 74 | // every cookie on the response, not just the first — is byte-for-byte. |
| 75 | func TestCookieScopeRewritesDomainsOutsideTheAccount(t *testing.T) { |
| 76 | sent := []string{ |
| 77 | "a=1; Domain=oncarlos.com; Path=/; Secure", |
| 78 | "b=2; path=/; DOMAIN=.OnCarlos.Com; HttpOnly", |
| 79 | "c=3; Domain=xqzb.oncarlos.com", |
| 80 | "d=4; Max-Age=60; domain = blog.xqzb.oncarlos.com ; SameSite=Lax", |
| 81 | "e=5; Domain=example.org", |
| 82 | "f=6; Domain=oncarlos.com.", |
| 83 | "g=7; Domain=kmqa.oncarlos.com; Domain=oncarlos.com", |
| 84 | } |
| 85 | e := cookieEdge(t, cookieAcct, setCookies(sent...), cookieHost) |
| 86 | got := getCookies(t, e, "http://"+cookieHost+"/") |
| 87 | want := []string{ |
| 88 | "a=1; Path=/; Secure", |
| 89 | "b=2; path=/; HttpOnly", |
| 90 | "c=3", |
| 91 | "d=4; Max-Age=60; SameSite=Lax", |
| 92 | "e=5", |
| 93 | "f=6", |
| 94 | "g=7", |
| 95 | } |
| 96 | if !slices.Equal(got, want) { |
| 97 | t.Fatalf("Set-Cookie:\n got %q\nwant %q", got, want) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // The account's own namespace (so its apps can share a cookie), the host |
| 102 | // itself, a host-only cookie and an empty Domain all pass untouched. |
| 103 | func TestCookieScopeKeepsTheAccountsOwnScope(t *testing.T) { |
| 104 | sent := []string{ |
| 105 | "a=1; Domain=kmqa.oncarlos.com; Path=/", |
| 106 | "b=2; Domain=.KMQA.oncarlos.com", |
| 107 | "c=3; Domain=shop.kmqa.oncarlos.com; Secure", |
| 108 | "d=4; Domain=other.kmqa.oncarlos.com", |
| 109 | "e=5; Path=/; HttpOnly", |
| 110 | "f=6; Domain=; Path=/", |
| 111 | "domain=oncarlos.com; Path=/", |
| 112 | } |
| 113 | e := cookieEdge(t, cookieAcct, setCookies(sent...), cookieHost) |
| 114 | if got := getCookies(t, e, "http://"+cookieHost+":443/"); !slices.Equal(got, sent) { |
| 115 | t.Fatalf("Set-Cookie changed:\n got %q\nwant %q", got, sent) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // The namespace comes from the host's own sqid label AND the route's account |
| 120 | // agreeing. A route whose row names another account (or none) keeps only its |
| 121 | // host: strict, and narrower is the safe way to be wrong. |
| 122 | func TestCookieScopeNamespaceNeedsTheRoutesAccount(t *testing.T) { |
| 123 | for _, acct := range []string{cookieOther, ""} { |
| 124 | e := cookieEdge(t, acct, setCookies("a=1; Domain=kmqa.oncarlos.com", "b=2; Domain=shop.kmqa.oncarlos.com"), cookieHost) |
| 125 | got := getCookies(t, e, "http://"+cookieHost+"/") |
| 126 | want := []string{"a=1", "b=2; Domain=shop.kmqa.oncarlos.com"} |
| 127 | if !slices.Equal(got, want) { |
| 128 | t.Fatalf("account %q: got %q, want %q", acct, got, want) |
| 129 | } |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | // A custom domain is its owner's registrable domain: nothing to enforce. |
| 134 | func TestCookieScopeLeavesCustomDomainsAlone(t *testing.T) { |
| 135 | sent := []string{"a=1; Domain=example.com; Path=/", "b=2; Domain=oncarlos.com"} |
| 136 | e := cookieEdge(t, cookieAcct, setCookies(sent...), "shop.example.com") |
| 137 | if got := getCookies(t, e, "http://shop.example.com/"); !slices.Equal(got, sent) { |
| 138 | t.Fatalf("custom domain cookies changed: got %q", got) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | // No apps domain configured: the edge changes nothing anywhere. |
| 143 | func TestCookieScopeOffWithoutAppsDomain(t *testing.T) { |
| 144 | e := cookieEdge(t, cookieAcct, setCookies("a=1; Domain=oncarlos.com"), cookieHost) |
| 145 | e.appsDomain = "" |
| 146 | if got := getCookies(t, e, "http://"+cookieHost+"/"); !slices.Equal(got, []string{"a=1; Domain=oncarlos.com"}) { |
| 147 | t.Fatalf("got %q", got) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // A Set-Cookie sent as an undeclared trailer on a cacheable response reaches |
| 152 | // the recorder as "Trailer:Set-Cookie", which the plain Set-Cookie refusal |
| 153 | // never saw: the response was stored and its trailer replayed to every later |
| 154 | // visitor. It is delivered rewritten, and never stored; a second visitor |
| 155 | // gets a fresh response from the app, with its own cookie, not the first's. |
| 156 | func TestCookieScopeTrailerCookieIsNeverReplayed(t *testing.T) { |
| 157 | n := 0 |
| 158 | e := cookieEdge(t, cookieAcct, func(w http.ResponseWriter, r *http.Request) { |
| 159 | n++ |
| 160 | w.Header().Set("Cache-Control", "max-age=600") |
| 161 | w.Write([]byte("ok")) |
| 162 | w.(http.Flusher).Flush() // chunked, so a trailer can follow |
| 163 | w.Header().Set(http.TrailerPrefix+"Set-Cookie", fmt.Sprintf("t=%d; Domain=oncarlos.com; Path=/", n)) |
| 164 | }, cookieHost) |
| 165 | for i, want := range []string{"t=1; Path=/", "t=2; Path=/"} { |
| 166 | rec := httptest.NewRecorder() |
| 167 | e.ServeHTTP(rec, httptest.NewRequest("GET", "http://"+cookieHost+"/t", nil)) |
| 168 | if got := rec.Result().Trailer.Values("Set-Cookie"); !slices.Equal(got, []string{want}) { |
| 169 | t.Fatalf("visitor %d trailer: got %q, want %q", i+1, got, want) |
| 170 | } |
| 171 | if cachedAt(e, cookieHost, "/t") { |
| 172 | t.Fatal("a response with a Set-Cookie trailer must never be cached") |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // The same refusal, at maybeStore, for an app that names the trailer in any |
| 178 | // spelling and on a response with no other per-visitor marker. |
| 179 | func TestMaybeStoreRefusesTrailerSetCookie(t *testing.T) { |
| 180 | e := cookieEdge(t, cookieAcct, setCookies(), cookieHost) |
| 181 | for _, k := range []string{http.TrailerPrefix + "Set-Cookie", http.TrailerPrefix + "set-cookie"} { |
| 182 | req := httptest.NewRequest("GET", "http://"+cookieHost+"/m", nil) |
| 183 | rec := &recordingResponseWriter{ResponseWriter: httptest.NewRecorder(), request: req, cacheable: true, maxBody: 1 << 20} |
| 184 | rec.Header().Set("Cache-Control", "max-age=600") |
| 185 | rec.Header()[k] = []string{"sid=abc"} |
| 186 | rec.WriteHeader(200) |
| 187 | rec.Write([]byte("ok")) |
| 188 | key := CacheKey{Host: cookieHost, Method: "GET", Path: "/m"} |
| 189 | e.maybeStore(key, rec) |
| 190 | if _, ok := e.cache.get(key); ok { |
| 191 | t.Fatalf("stored a response carrying %q", k) |
| 192 | } |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | // The cache path delivers the rewritten cookie, and (an older rule) never |
| 197 | // stores a Set-Cookie response at all. |
| 198 | func TestCookieScopeOnTheCachePath(t *testing.T) { |
| 199 | e := cookieEdge(t, cookieAcct, func(w http.ResponseWriter, r *http.Request) { |
| 200 | w.Header().Set("Cache-Control", "max-age=600") |
| 201 | w.Header().Add("Set-Cookie", "a=1; Domain=oncarlos.com") |
| 202 | w.Header().Add("Set-Cookie", "b=2; Domain=kmqa.oncarlos.com") |
| 203 | w.Write([]byte("ok")) |
| 204 | }, cookieHost) |
| 205 | got := getCookies(t, e, "http://"+cookieHost+"/c") |
| 206 | if want := []string{"a=1", "b=2; Domain=kmqa.oncarlos.com"}; !slices.Equal(got, want) { |
| 207 | t.Fatalf("got %q, want %q", got, want) |
| 208 | } |
| 209 | if cachedAt(e, cookieHost, "/c") { |
| 210 | t.Fatal("a Set-Cookie response must never be cached") |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | // One log line per route per process, naming the route. |
| 215 | func TestCookieScopeLogsOncePerRoute(t *testing.T) { |
| 216 | var buf bytes.Buffer |
| 217 | log.SetOutput(&buf) |
| 218 | t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| 219 | e := cookieEdge(t, cookieAcct, setCookies("a=1; Domain=oncarlos.com", "b=2; Domain=oncarlos.com"), cookieHost, "blog.kmqa.oncarlos.com") |
| 220 | for range 3 { |
| 221 | getCookies(t, e, "http://"+cookieHost+"/") |
| 222 | } |
| 223 | getCookies(t, e, "http://blog.kmqa.oncarlos.com/") |
| 224 | out := buf.String() |
| 225 | if n := strings.Count(out, "route "+cookieHost+" "); n != 1 { |
| 226 | t.Fatalf("want one line for %s, got %d:\n%s", cookieHost, n, out) |
| 227 | } |
| 228 | if n := strings.Count(out, "route blog.kmqa.oncarlos.com "); n != 1 { |
| 229 | t.Fatalf("want one line for blog, got %d:\n%s", n, out) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestScopeSetCookieUnit(t *testing.T) { |
| 234 | allowed := func(d string) bool { return d == "kmqa.oncarlos.com" } |
| 235 | for _, tc := range []struct{ in, want string }{ |
| 236 | {"a=1", "a=1"}, |
| 237 | {"a=1;Domain=oncarlos.com;Path=/", "a=1;Path=/"}, |
| 238 | {"a=1; Path=/; Domain=oncarlos.com", "a=1; Path=/"}, |
| 239 | {"a=1; Domain=\t.oncarlos.com\t; Secure", "a=1; Secure"}, |
| 240 | {"a=1; Domain=.; Secure", "a=1; Domain=.; Secure"}, |
| 241 | {"a=1; Domain", "a=1; Domain"}, |
| 242 | {"a=Domain=oncarlos.com; Secure", "a=Domain=oncarlos.com; Secure"}, |
| 243 | {"a=1; Domain=kmqa.oncarlos.com", "a=1; Domain=kmqa.oncarlos.com"}, |
| 244 | } { |
| 245 | if got, _ := scopeSetCookie(tc.in, allowed); got != tc.want { |
| 246 | t.Errorf("scopeSetCookie(%q) = %q, want %q", tc.in, got, tc.want) |
| 247 | } |
| 248 | } |
| 249 | } |
| 250 | |