carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package gate
2
3import (
4 "net/http"
5 "net/http/httptest"
6 "net/url"
7 "strings"
8 "testing"
9 "time"
10)
11
12func TestCheckPassword(t *testing.T) {
13 if err := CheckPassword("short7!"); err == nil {
14 t.Fatal("7 chars accepted")
15 }
16 if err := CheckPassword(strings.Repeat("x", 73)); err == nil {
17 t.Fatal("73 bytes accepted — bcrypt would silently truncate")
18 }
19 if err := CheckPassword("longenough"); err != nil {
20 t.Fatal(err)
21 }
22}
23
24func TestHashThenVerifyPassword(t *testing.T) {
25 h, err := Hash("correct horse")
26 if err != nil {
27 t.Fatal(err)
28 }
29 if !strings.HasPrefix(h, "$2a$10$") {
30 t.Fatalf("hash = %q, want bcrypt cost 10", h)
31 }
32 if !comparePassword(h, "correct horse") || comparePassword(h, "wrong horse") {
33 t.Fatal("comparePassword disagrees with Hash")
34 }
35}
36
37func TestCookieRoundTrip(t *testing.T) {
38 now := time.Unix(1_800_000_000, 0)
39 v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime))
40 if !Verify(v, "site.example", "$2a$10$hash", now) {
41 t.Fatal("fresh cookie refused")
42 }
43 if Verify(v, "other.example", "$2a$10$hash", now) {
44 t.Fatal("cookie accepted for another host")
45 }
46 if Verify(v, "site.example", "$2a$10$rotated", now) {
47 t.Fatal("cookie accepted after the password changed")
48 }
49 if Verify(v, "site.example", "$2a$10$hash", now.Add(Lifetime+time.Second)) {
50 t.Fatal("expired cookie accepted")
51 }
52 if Verify("v1.garbage", "site.example", "$2a$10$hash", now) || Verify("", "site.example", "$2a$10$hash", now) {
53 t.Fatal("malformed cookie accepted")
54 }
55}
56
57func TestLimiterTenPerMinute(t *testing.T) {
58 now := time.Unix(0, 0)
59 l := NewLimiter(func() time.Time { return now })
60 for i := 0; i < 10; i++ {
61 if !l.Allow("k") {
62 t.Fatalf("attempt %d refused", i+1)
63 }
64 }
65 if l.Allow("k") {
66 t.Fatal("11th attempt allowed")
67 }
68 if !l.Allow("other") {
69 t.Fatal("another key shares the bucket")
70 }
71 now = now.Add(6 * time.Second)
72 if !l.Allow("k") {
73 t.Fatal("no refill after 6s")
74 }
75}
76
77func testGate() *Gate {
78 g := New()
79 g.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
80 return g
81}
82
83func TestAdmitNoCookieIsTheLoginPage(t *testing.T) {
84 g := testGate()
85 h, _ := Hash("correct horse")
86 for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodHead} {
87 rec := httptest.NewRecorder()
88 r := httptest.NewRequest(method, "https://site.example/docs?x=1", nil)
89 if g.Admit(rec, r, "site.example", h) {
90 t.Fatalf("%s without a cookie admitted", method)
91 }
92 if rec.Code != http.StatusUnauthorized {
93 t.Fatalf("%s status = %d, want 401", method, rec.Code)
94 }
95 if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
96 t.Fatalf("Cache-Control = %q", cc)
97 }
98 if rt := rec.Header().Get("X-Robots-Tag"); rt != "noindex, nofollow" {
99 t.Fatalf("X-Robots-Tag = %q", rt)
100 }
101 body := rec.Body.String()
102 if method == http.MethodHead {
103 if body != "" {
104 t.Fatal("HEAD carried a body")
105 }
106 continue
107 }
108 if !strings.Contains(body, "Password required") || !strings.Contains(body, "site.example") {
109 t.Fatalf("page missing copy: %s", body)
110 }
111 if !strings.Contains(body, `name="next" value="/docs?x=1"`) {
112 t.Fatalf("page does not carry next: %s", body)
113 }
114 if !strings.Contains(body, `action="/.carlos/gate"`) {
115 t.Fatalf("form action wrong: %s", body)
116 }
117 }
118}
119
120func postLogin(g *Gate, host, hash, password, next string) *httptest.ResponseRecorder {
121 rec := httptest.NewRecorder()
122 form := url.Values{"password": {password}, "next": {next}}
123 r := httptest.NewRequest(http.MethodPost, "https://"+host+LoginPath, strings.NewReader(form.Encode()))
124 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
125 r.RemoteAddr = "203.0.113.9:4242"
126 g.Admit(rec, r, host, hash)
127 return rec
128}
129
130func TestAdmitRightPasswordSetsCookieAndRedirects(t *testing.T) {
131 g := testGate()
132 h, _ := Hash("correct horse")
133 rec := postLogin(g, "site.example", h, "correct horse", "/docs?x=1")
134 if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/docs?x=1" {
135 t.Fatalf("got %d -> %q, want 303 -> /docs?x=1", rec.Code, rec.Header().Get("Location"))
136 }
137 var c *http.Cookie
138 for _, k := range rec.Result().Cookies() {
139 if k.Name == CookieName {
140 c = k
141 }
142 }
143 if c == nil {
144 t.Fatal("no gate cookie set")
145 }
146 if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.MaxAge != int(Lifetime/time.Second) {
147 t.Fatalf("cookie attributes: %+v", c)
148 }
149 if !Verify(c.Value, "site.example", h, g.now()) {
150 t.Fatal("minted cookie does not verify")
151 }
152 // And the cookie is admitted, and stripped from what the app sees.
153 rec2 := httptest.NewRecorder()
154 r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil)
155 r.Header.Set("Cookie", "theme=dark; "+CookieName+"="+c.Value+"; other=1")
156 if !g.Admit(rec2, r, "site.example", h) {
157 t.Fatal("valid cookie refused")
158 }
159 if got := r.Header.Get("Cookie"); got != "theme=dark; other=1" {
160 t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", got)
161 }
162}
163
164func TestAdmitBadNextBecomesRoot(t *testing.T) {
165 g := testGate()
166 h, _ := Hash("correct horse")
167 for _, next := range []string{"//evil.example/", "https://evil.example/", "docs", ""} {
168 rec := postLogin(g, "site.example", h, "correct horse", next)
169 if loc := rec.Header().Get("Location"); loc != "/" {
170 t.Fatalf("next %q -> Location %q, want /", next, loc)
171 }
172 }
173}
174
175func TestAdmitWrongPasswordThenLimit(t *testing.T) {
176 g := testGate()
177 h, _ := Hash("correct horse")
178 compares := 0
179 g.compare = func(hash, password string) bool { compares++; return false }
180 for i := 0; i < 10; i++ {
181 rec := postLogin(g, "site.example", h, "nope nope", "/")
182 if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "That password is not right.") {
183 t.Fatalf("attempt %d: %d %s", i+1, rec.Code, rec.Body.String())
184 }
185 }
186 rec := postLogin(g, "site.example", h, "nope nope", "/")
187 if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "60" {
188 t.Fatalf("11th attempt: %d Retry-After=%q, want 429/60", rec.Code, rec.Header().Get("Retry-After"))
189 }
190 if !strings.Contains(rec.Body.String(), "Too many attempts. Try again in a minute.") {
191 t.Fatalf("429 page copy: %s", rec.Body.String())
192 }
193 if compares != 10 {
194 t.Fatalf("bcrypt compared %d times, want 10 — the limiter must refuse BEFORE comparing", compares)
195 }
196}
197
198func TestLoginPathOnGetIsJustAGatedRequest(t *testing.T) {
199 g := testGate()
200 h, _ := Hash("correct horse")
201 rec := httptest.NewRecorder()
202 r := httptest.NewRequest(http.MethodGet, "https://site.example"+LoginPath, nil)
203 if g.Admit(rec, r, "site.example", h) || rec.Code != http.StatusUnauthorized {
204 t.Fatalf("GET %s: admitted=%v code=%d", LoginPath, false, rec.Code)
205 }
206}
207
208// SafeNext is the whole open-redirect surface, so pin it directly rather
209// than only through the three shapes the POST test happens to send.
210func TestSafeNextIsAPathOnThisHostOrRoot(t *testing.T) {
211 bad := []string{
212 "//evil.example/", `/\evil.example/`, `/\/evil.example/`,
213 "https://evil.example/", "javascript:alert(1)", `\\evil.example\`,
214 "", "docs", "?x=1", "#frag",
215 // Control bytes: net/http rewrites only CR and LF, and a browser
216 // strips the rest before resolving, so a tab here would land as
217 // "//evil.example/".
218 "/\t/evil.example/", "/\r\n/evil.example/", "/\x00/evil.example/",
219 "/\x7f/evil.example/", "/\x1f/evil.example/",
220 // The login path itself: the app behind the curtain does not serve it.
221 LoginPath, LoginPath + "?x=1", LoginPath + "#f",
222 }
223 for _, bad := range bad {
224 if got := SafeNext(bad); got != "/" {
225 t.Errorf("SafeNext(%q) = %q, want /", bad, got)
226 }
227 }
228 for _, ok := range []string{"/", "/docs?x=1", "/a/b#c"} {
229 if got := SafeNext(ok); got != ok {
230 t.Errorf("SafeNext(%q) = %q, want it unchanged", ok, got)
231 }
232 }
233}
234
235// The MAC covers the expiry, so pushing the expiry out on a stolen cookie
236// must not buy another week.
237func TestCookieForgedExpiryAndTamperedMAC(t *testing.T) {
238 now := time.Unix(1_800_000_000, 0)
239 v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime))
240 parts := strings.Split(v, ".")
241 if Verify(parts[0]+".9999999999."+parts[2], "site.example", "$2a$10$hash", now) {
242 t.Fatal("expiry moved forward without re-signing was accepted")
243 }
244 if Verify(v+".x", "site.example", "$2a$10$hash", now) {
245 t.Fatal("a fourth field was accepted")
246 }
247 if Verify("v2."+parts[1]+"."+parts[2], "site.example", "$2a$10$hash", now) {
248 t.Fatal("another version was accepted")
249 }
250 if Verify(parts[0]+"."+parts[1]+".!!!", "site.example", "$2a$10$hash", now) {
251 t.Fatal("a non-base64 MAC was accepted")
252 }
253}
254
255func TestStripCookieDropsTheHeaderWhenTheGateCookieIsTheOnlyOne(t *testing.T) {
256 r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil)
257 r.Header.Set("Cookie", CookieName+"=x")
258 stripCookie(r)
259 if _, ok := r.Header["Cookie"]; ok {
260 t.Fatalf("Cookie header survived as %q", r.Header.Get("Cookie"))
261 }
262}
263
264// A body over MaxFormBody fails to parse; that must cost no bcrypt and no
265// limiter token. The bucket is keyed on the sender's own address, so a junk
266// flood only ever starves the flooder — except behind the backhaul fold,
267// where a sibling edge's address stands in for every visitor through it,
268// and there this is what keeps a flood from reaching them.
269func TestOversizedFormBodyIsRefusedWithoutSpendingAnAttempt(t *testing.T) {
270 g := testGate()
271 h, _ := Hash("correct horse")
272 compares := 0
273 g.compare = func(hash, password string) bool { compares++; return true }
274 rec := httptest.NewRecorder()
275 r := httptest.NewRequest(http.MethodPost, "https://site.example"+LoginPath, strings.NewReader("password="+strings.Repeat("a", 64<<10)))
276 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
277 r.RemoteAddr = "203.0.113.9:4242"
278 g.Admit(rec, r, "site.example", h)
279 if rec.Code != http.StatusUnauthorized {
280 t.Fatalf("oversized body -> %d, want 401", rec.Code)
281 }
282 if compares != 0 {
283 t.Fatal("an unparseable body reached bcrypt")
284 }
285 if !g.limiter.Allow(clientKey(r) + "\x1f" + "site.example") {
286 t.Fatal("an unparseable body spent an attempt")
287 }
288}
289
290// Tenant hosts share a wildcard apex, so a sibling host — or an XSS on one
291// — can plant a second carlos_gate scoped to .apex, and the browser sends
292// it first. Taking only the first cookie would lock the visitor out of a
293// site they hold a good cookie for, with no way to clear it.
294func TestAdmitAcceptsAnyValidGateCookieNotJustTheFirst(t *testing.T) {
295 g := testGate()
296 h, _ := Hash("correct horse")
297 good := Mint("site.example", h, g.now().Add(Lifetime))
298 rec := httptest.NewRecorder()
299 r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil)
300 r.Header.Set("Cookie", CookieName+"=v1.9999999999.junk; theme=dark; "+CookieName+"="+good)
301 if !g.Admit(rec, r, "site.example", h) {
302 t.Fatal("a planted decoy cookie locked the visitor out")
303 }
304 got := r.Header.Get("Cookie")
305 if strings.Contains(got, CookieName) {
306 t.Fatalf("upstream Cookie = %q, want every gate cookie stripped", got)
307 }
308 if got != "theme=dark" {
309 t.Fatalf("upstream Cookie = %q, want the other cookies untouched", got)
310 }
311}
312
313func postLoginFrom(g *Gate, reqHost, host, hash, password, next, origin string) *httptest.ResponseRecorder {
314 rec := httptest.NewRecorder()
315 form := url.Values{"password": {password}, "next": {next}}
316 r := httptest.NewRequest(http.MethodPost, "https://"+reqHost+LoginPath, strings.NewReader(form.Encode()))
317 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
318 if origin != "" {
319 r.Header.Set("Origin", origin)
320 }
321 r.RemoteAddr = "203.0.113.9:4242"
322 g.Admit(rec, r, host, hash)
323 return rec
324}
325
326// The login POST needs no cookie, so SameSite does not cover it: without an
327// Origin check a hidden form on an attacker's page could auto-submit eleven
328// guesses and park a visitor at 429.
329func TestLoginRefusesACrossOriginPostWithoutSpendingAnAttempt(t *testing.T) {
330 g := testGate()
331 h, _ := Hash("correct horse")
332 compares := 0
333 g.compare = func(hash, password string) bool { compares++; return true }
334
335 for _, origin := range []string{"https://evil.example", "null", "https://evil.example:443", "http://site.example.evil.example"} {
336 rec := postLoginFrom(g, "site.example", "site.example", h, "correct horse", "/docs", origin)
337 if rec.Code != http.StatusForbidden {
338 t.Fatalf("Origin %q -> %d, want 403", origin, rec.Code)
339 }
340 if strings.Contains(rec.Body.String(), msgWrong) || strings.Contains(rec.Body.String(), msgTooMany) {
341 t.Fatalf("Origin %q: the 403 page judged the password: %s", origin, rec.Body.String())
342 }
343 if !strings.Contains(rec.Body.String(), "Password required") {
344 t.Fatalf("Origin %q: the 403 is not the login page: %s", origin, rec.Body.String())
345 }
346 }
347 if compares != 0 {
348 t.Fatalf("bcrypt compared %d times on cross-origin posts, want 0", compares)
349 }
350 // The bucket is untouched: a visitor who was targeted can still make
351 // all ten of their own attempts.
352 for i := 0; i < 10; i++ {
353 if rec := postLogin(g, "site.example", h, "correct horse", "/"); rec.Code == http.StatusTooManyRequests {
354 t.Fatalf("attempt %d was rate-limited — the cross-origin posts drained the bucket", i+1)
355 }
356 }
357}
358
359func TestLoginAllowsSameOriginAndAbsentOrigin(t *testing.T) {
360 g := testGate()
361 h, _ := Hash("correct horse")
362 cases := []struct{ name, reqHost, origin string }{
363 {"same origin", "site.example", "https://site.example"},
364 {"absent origin (curl)", "site.example", ""},
365 {"origin and host both carry the port", "site.example:8443", "https://site.example:8443"},
366 {"port on one side only", "site.example:8443", "https://site.example"},
367 {"case-insensitive host", "site.example", "https://SITE.example"},
368 // An alias posts from its own name, so the comparison is against
369 // r.Host and not the route's primary hostname.
370 {"alias posts from the alias", "alias.example", "https://alias.example"},
371 }
372 for _, c := range cases {
373 rec := postLoginFrom(g, c.reqHost, "site.example", h, "correct horse", "/docs", c.origin)
374 if rec.Code != http.StatusSeeOther {
375 t.Errorf("%s: %d, want 303", c.name, rec.Code)
376 }
377 }
378}
379
380func TestSuccessfulLoginIsNoStoreAndNoindex(t *testing.T) {
381 g := testGate()
382 h, _ := Hash("correct horse")
383 rec := postLogin(g, "site.example", h, "correct horse", "/docs")
384 if cc := rec.Header().Get("Cache-Control"); cc != "no-store" {
385 t.Errorf("Cache-Control = %q", cc)
386 }
387 if rt := rec.Header().Get("X-Robots-Tag"); rt != "noindex, nofollow" {
388 t.Errorf("X-Robots-Tag = %q", rt)
389 }
390}
391
392func TestCookieExpiryEncodingIsCanonical(t *testing.T) {
393 now := time.Unix(1_800_000_000, 0)
394 v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime))
395 parts := strings.Split(v, ".")
396 if Verify(parts[0]+".+"+parts[1]+"."+parts[2], "site.example", "$2a$10$hash", now) {
397 t.Fatal(`a "+"-signed expiry was accepted`)
398 }
399}
400