package gate import ( "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) func TestCheckPassword(t *testing.T) { if err := CheckPassword("short7!"); err == nil { t.Fatal("7 chars accepted") } if err := CheckPassword(strings.Repeat("x", 73)); err == nil { t.Fatal("73 bytes accepted — bcrypt would silently truncate") } if err := CheckPassword("longenough"); err != nil { t.Fatal(err) } } func TestHashThenVerifyPassword(t *testing.T) { h, err := Hash("correct horse") if err != nil { t.Fatal(err) } if !strings.HasPrefix(h, "$2a$10$") { t.Fatalf("hash = %q, want bcrypt cost 10", h) } if !comparePassword(h, "correct horse") || comparePassword(h, "wrong horse") { t.Fatal("comparePassword disagrees with Hash") } } func TestCookieRoundTrip(t *testing.T) { now := time.Unix(1_800_000_000, 0) v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime)) if !Verify(v, "site.example", "$2a$10$hash", now) { t.Fatal("fresh cookie refused") } if Verify(v, "other.example", "$2a$10$hash", now) { t.Fatal("cookie accepted for another host") } if Verify(v, "site.example", "$2a$10$rotated", now) { t.Fatal("cookie accepted after the password changed") } if Verify(v, "site.example", "$2a$10$hash", now.Add(Lifetime+time.Second)) { t.Fatal("expired cookie accepted") } if Verify("v1.garbage", "site.example", "$2a$10$hash", now) || Verify("", "site.example", "$2a$10$hash", now) { t.Fatal("malformed cookie accepted") } } func TestLimiterTenPerMinute(t *testing.T) { now := time.Unix(0, 0) l := NewLimiter(func() time.Time { return now }) for i := 0; i < 10; i++ { if !l.Allow("k") { t.Fatalf("attempt %d refused", i+1) } } if l.Allow("k") { t.Fatal("11th attempt allowed") } if !l.Allow("other") { t.Fatal("another key shares the bucket") } now = now.Add(6 * time.Second) if !l.Allow("k") { t.Fatal("no refill after 6s") } } func testGate() *Gate { g := New() g.now = func() time.Time { return time.Unix(1_800_000_000, 0) } return g } func TestAdmitNoCookieIsTheLoginPage(t *testing.T) { g := testGate() h, _ := Hash("correct horse") for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodHead} { rec := httptest.NewRecorder() r := httptest.NewRequest(method, "https://site.example/docs?x=1", nil) if g.Admit(rec, r, "site.example", h) { t.Fatalf("%s without a cookie admitted", method) } if rec.Code != http.StatusUnauthorized { t.Fatalf("%s status = %d, want 401", method, rec.Code) } if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { t.Fatalf("Cache-Control = %q", cc) } if rt := rec.Header().Get("X-Robots-Tag"); rt != "noindex, nofollow" { t.Fatalf("X-Robots-Tag = %q", rt) } body := rec.Body.String() if method == http.MethodHead { if body != "" { t.Fatal("HEAD carried a body") } continue } if !strings.Contains(body, "Password required") || !strings.Contains(body, "site.example") { t.Fatalf("page missing copy: %s", body) } if !strings.Contains(body, `name="next" value="/docs?x=1"`) { t.Fatalf("page does not carry next: %s", body) } if !strings.Contains(body, `action="/.carlos/gate"`) { t.Fatalf("form action wrong: %s", body) } } } func postLogin(g *Gate, host, hash, password, next string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() form := url.Values{"password": {password}, "next": {next}} r := httptest.NewRequest(http.MethodPost, "https://"+host+LoginPath, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = "203.0.113.9:4242" g.Admit(rec, r, host, hash) return rec } func TestAdmitRightPasswordSetsCookieAndRedirects(t *testing.T) { g := testGate() h, _ := Hash("correct horse") rec := postLogin(g, "site.example", h, "correct horse", "/docs?x=1") if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/docs?x=1" { t.Fatalf("got %d -> %q, want 303 -> /docs?x=1", rec.Code, rec.Header().Get("Location")) } var c *http.Cookie for _, k := range rec.Result().Cookies() { if k.Name == CookieName { c = k } } if c == nil { t.Fatal("no gate cookie set") } if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.MaxAge != int(Lifetime/time.Second) { t.Fatalf("cookie attributes: %+v", c) } if !Verify(c.Value, "site.example", h, g.now()) { t.Fatal("minted cookie does not verify") } // And the cookie is admitted, and stripped from what the app sees. rec2 := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil) r.Header.Set("Cookie", "theme=dark; "+CookieName+"="+c.Value+"; other=1") if !g.Admit(rec2, r, "site.example", h) { t.Fatal("valid cookie refused") } if got := r.Header.Get("Cookie"); got != "theme=dark; other=1" { t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", got) } } func TestAdmitBadNextBecomesRoot(t *testing.T) { g := testGate() h, _ := Hash("correct horse") for _, next := range []string{"//evil.example/", "https://evil.example/", "docs", ""} { rec := postLogin(g, "site.example", h, "correct horse", next) if loc := rec.Header().Get("Location"); loc != "/" { t.Fatalf("next %q -> Location %q, want /", next, loc) } } } func TestAdmitWrongPasswordThenLimit(t *testing.T) { g := testGate() h, _ := Hash("correct horse") compares := 0 g.compare = func(hash, password string) bool { compares++; return false } for i := 0; i < 10; i++ { rec := postLogin(g, "site.example", h, "nope nope", "/") if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "That password is not right.") { t.Fatalf("attempt %d: %d %s", i+1, rec.Code, rec.Body.String()) } } rec := postLogin(g, "site.example", h, "nope nope", "/") if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "60" { t.Fatalf("11th attempt: %d Retry-After=%q, want 429/60", rec.Code, rec.Header().Get("Retry-After")) } if !strings.Contains(rec.Body.String(), "Too many attempts. Try again in a minute.") { t.Fatalf("429 page copy: %s", rec.Body.String()) } if compares != 10 { t.Fatalf("bcrypt compared %d times, want 10 — the limiter must refuse BEFORE comparing", compares) } } func TestLoginPathOnGetIsJustAGatedRequest(t *testing.T) { g := testGate() h, _ := Hash("correct horse") rec := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "https://site.example"+LoginPath, nil) if g.Admit(rec, r, "site.example", h) || rec.Code != http.StatusUnauthorized { t.Fatalf("GET %s: admitted=%v code=%d", LoginPath, false, rec.Code) } } // SafeNext is the whole open-redirect surface, so pin it directly rather // than only through the three shapes the POST test happens to send. func TestSafeNextIsAPathOnThisHostOrRoot(t *testing.T) { bad := []string{ "//evil.example/", `/\evil.example/`, `/\/evil.example/`, "https://evil.example/", "javascript:alert(1)", `\\evil.example\`, "", "docs", "?x=1", "#frag", // Control bytes: net/http rewrites only CR and LF, and a browser // strips the rest before resolving, so a tab here would land as // "//evil.example/". "/\t/evil.example/", "/\r\n/evil.example/", "/\x00/evil.example/", "/\x7f/evil.example/", "/\x1f/evil.example/", // The login path itself: the app behind the curtain does not serve it. LoginPath, LoginPath + "?x=1", LoginPath + "#f", } for _, bad := range bad { if got := SafeNext(bad); got != "/" { t.Errorf("SafeNext(%q) = %q, want /", bad, got) } } for _, ok := range []string{"/", "/docs?x=1", "/a/b#c"} { if got := SafeNext(ok); got != ok { t.Errorf("SafeNext(%q) = %q, want it unchanged", ok, got) } } } // The MAC covers the expiry, so pushing the expiry out on a stolen cookie // must not buy another week. func TestCookieForgedExpiryAndTamperedMAC(t *testing.T) { now := time.Unix(1_800_000_000, 0) v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime)) parts := strings.Split(v, ".") if Verify(parts[0]+".9999999999."+parts[2], "site.example", "$2a$10$hash", now) { t.Fatal("expiry moved forward without re-signing was accepted") } if Verify(v+".x", "site.example", "$2a$10$hash", now) { t.Fatal("a fourth field was accepted") } if Verify("v2."+parts[1]+"."+parts[2], "site.example", "$2a$10$hash", now) { t.Fatal("another version was accepted") } if Verify(parts[0]+"."+parts[1]+".!!!", "site.example", "$2a$10$hash", now) { t.Fatal("a non-base64 MAC was accepted") } } func TestStripCookieDropsTheHeaderWhenTheGateCookieIsTheOnlyOne(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil) r.Header.Set("Cookie", CookieName+"=x") stripCookie(r) if _, ok := r.Header["Cookie"]; ok { t.Fatalf("Cookie header survived as %q", r.Header.Get("Cookie")) } } // A body over MaxFormBody fails to parse; that must cost no bcrypt and no // limiter token. The bucket is keyed on the sender's own address, so a junk // flood only ever starves the flooder — except behind the backhaul fold, // where a sibling edge's address stands in for every visitor through it, // and there this is what keeps a flood from reaching them. func TestOversizedFormBodyIsRefusedWithoutSpendingAnAttempt(t *testing.T) { g := testGate() h, _ := Hash("correct horse") compares := 0 g.compare = func(hash, password string) bool { compares++; return true } rec := httptest.NewRecorder() r := httptest.NewRequest(http.MethodPost, "https://site.example"+LoginPath, strings.NewReader("password="+strings.Repeat("a", 64<<10))) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") r.RemoteAddr = "203.0.113.9:4242" g.Admit(rec, r, "site.example", h) if rec.Code != http.StatusUnauthorized { t.Fatalf("oversized body -> %d, want 401", rec.Code) } if compares != 0 { t.Fatal("an unparseable body reached bcrypt") } if !g.limiter.Allow(clientKey(r) + "\x1f" + "site.example") { t.Fatal("an unparseable body spent an attempt") } } // Tenant hosts share a wildcard apex, so a sibling host — or an XSS on one // — can plant a second carlos_gate scoped to .apex, and the browser sends // it first. Taking only the first cookie would lock the visitor out of a // site they hold a good cookie for, with no way to clear it. func TestAdmitAcceptsAnyValidGateCookieNotJustTheFirst(t *testing.T) { g := testGate() h, _ := Hash("correct horse") good := Mint("site.example", h, g.now().Add(Lifetime)) rec := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil) r.Header.Set("Cookie", CookieName+"=v1.9999999999.junk; theme=dark; "+CookieName+"="+good) if !g.Admit(rec, r, "site.example", h) { t.Fatal("a planted decoy cookie locked the visitor out") } got := r.Header.Get("Cookie") if strings.Contains(got, CookieName) { t.Fatalf("upstream Cookie = %q, want every gate cookie stripped", got) } if got != "theme=dark" { t.Fatalf("upstream Cookie = %q, want the other cookies untouched", got) } } func postLoginFrom(g *Gate, reqHost, host, hash, password, next, origin string) *httptest.ResponseRecorder { rec := httptest.NewRecorder() form := url.Values{"password": {password}, "next": {next}} r := httptest.NewRequest(http.MethodPost, "https://"+reqHost+LoginPath, strings.NewReader(form.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") if origin != "" { r.Header.Set("Origin", origin) } r.RemoteAddr = "203.0.113.9:4242" g.Admit(rec, r, host, hash) return rec } // The login POST needs no cookie, so SameSite does not cover it: without an // Origin check a hidden form on an attacker's page could auto-submit eleven // guesses and park a visitor at 429. func TestLoginRefusesACrossOriginPostWithoutSpendingAnAttempt(t *testing.T) { g := testGate() h, _ := Hash("correct horse") compares := 0 g.compare = func(hash, password string) bool { compares++; return true } for _, origin := range []string{"https://evil.example", "null", "https://evil.example:443", "http://site.example.evil.example"} { rec := postLoginFrom(g, "site.example", "site.example", h, "correct horse", "/docs", origin) if rec.Code != http.StatusForbidden { t.Fatalf("Origin %q -> %d, want 403", origin, rec.Code) } if strings.Contains(rec.Body.String(), msgWrong) || strings.Contains(rec.Body.String(), msgTooMany) { t.Fatalf("Origin %q: the 403 page judged the password: %s", origin, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "Password required") { t.Fatalf("Origin %q: the 403 is not the login page: %s", origin, rec.Body.String()) } } if compares != 0 { t.Fatalf("bcrypt compared %d times on cross-origin posts, want 0", compares) } // The bucket is untouched: a visitor who was targeted can still make // all ten of their own attempts. for i := 0; i < 10; i++ { if rec := postLogin(g, "site.example", h, "correct horse", "/"); rec.Code == http.StatusTooManyRequests { t.Fatalf("attempt %d was rate-limited — the cross-origin posts drained the bucket", i+1) } } } func TestLoginAllowsSameOriginAndAbsentOrigin(t *testing.T) { g := testGate() h, _ := Hash("correct horse") cases := []struct{ name, reqHost, origin string }{ {"same origin", "site.example", "https://site.example"}, {"absent origin (curl)", "site.example", ""}, {"origin and host both carry the port", "site.example:8443", "https://site.example:8443"}, {"port on one side only", "site.example:8443", "https://site.example"}, {"case-insensitive host", "site.example", "https://SITE.example"}, // An alias posts from its own name, so the comparison is against // r.Host and not the route's primary hostname. {"alias posts from the alias", "alias.example", "https://alias.example"}, } for _, c := range cases { rec := postLoginFrom(g, c.reqHost, "site.example", h, "correct horse", "/docs", c.origin) if rec.Code != http.StatusSeeOther { t.Errorf("%s: %d, want 303", c.name, rec.Code) } } } func TestSuccessfulLoginIsNoStoreAndNoindex(t *testing.T) { g := testGate() h, _ := Hash("correct horse") rec := postLogin(g, "site.example", h, "correct horse", "/docs") if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { t.Errorf("Cache-Control = %q", cc) } if rt := rec.Header().Get("X-Robots-Tag"); rt != "noindex, nofollow" { t.Errorf("X-Robots-Tag = %q", rt) } } func TestCookieExpiryEncodingIsCanonical(t *testing.T) { now := time.Unix(1_800_000_000, 0) v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime)) parts := strings.Split(v, ".") if Verify(parts[0]+".+"+parts[1]+"."+parts[2], "site.example", "$2a$10$hash", now) { t.Fatal(`a "+"-signed expiry was accepted`) } }