carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "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).
20const (
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.
30func 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
53func 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
62func 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.
75func 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.
103func 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.
122func 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.
134func 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.
143func 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.
156func 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.
179func 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.
198func 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.
215func 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
233func 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