package controller import ( "html/template" "net/http" "net/http/httptest" "strings" "testing" "git.aetherial.dev/aeth/keiji/pkg/auth" "git.aetherial.dev/aeth/keiji/pkg/storage" "github.com/gin-gonic/gin" ) type securitySource struct{} func (securitySource) AdminUsername() string { return "admin" } func (securitySource) AdminPassword() string { return "password" } type securityRepo struct{ storage.DocumentIO } func (securityRepo) GetAdminTables() storage.AdminPage { return storage.AdminPage{} } func (securityRepo) GetNavBarLinks() []storage.NavBarItem { return nil } func (securityRepo) GetDropdownElements() []storage.LinkPair { return nil } func TestAuthCookieSecurity(t *testing.T) { gin.SetMode(gin.TestMode) for _, insecure := range []bool{false, true} { t.Run(map[bool]string{false: "production", true: "development"}[insecure], func(t *testing.T) { if insecure { t.Setenv("KEIJI_INSECURE_COOKIE", "true") } else { t.Setenv("KEIJI_INSECURE_COOKIE", "") } c := NewController("example.com", securityRepo{}, nil, securitySource{}) r := gin.New() r.SetHTMLTemplate(template.Must(template.New("admin").Parse(`{{define "admin"}}ok{{end}}`))) r.POST("/login", c.Auth) req := httptest.NewRequest("POST", "/login", strings.NewReader(`{"username":"admin","password":"password"}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) cookies := rec.Result().Cookies() if rec.Code != 200 || len(cookies) != 1 { t.Fatalf("status %d cookies %v", rec.Code, cookies) } cookie := cookies[0] if cookie.Name != AUTH_COOKIE_NAME || cookie.Domain != "" || cookie.Path != "/" || cookie.Secure == insecure || !cookie.HttpOnly || cookie.SameSite != http.SameSiteLaxMode || cookie.MaxAge != 1200 || !c.Cache.Read(cookie.Value) { t.Fatalf("bad cookie: %+v", cookie) } }) } } func TestSameOrigin(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("KEIJI_INSECURE_COOKIE", "") r := gin.New() r.Any("/", SameOrigin, func(c *gin.Context) { c.Status(204) }) for _, tc := range []struct { method, origin, referer string want int }{ {"GET", "", "", 204}, {"POST", "https://example.com", "", 204}, {"PATCH", "", "https://example.com/edit?q=x", 204}, {"DELETE", "https://evil.com", "https://example.com/", 403}, {"POST", "null", "", 403}, {"POST", "", "", 403}, {"POST", "http://example.com", "", 403}, {"POST", "https://example.com.evil.com", "", 403}, {"POST", "https://example.com/path", "", 403}, {"POST", "https://example.com:444", "", 403}, } { req := httptest.NewRequest(tc.method, "https://example.com/", nil) req.Header.Set("Origin", tc.origin) req.Header.Set("Referer", tc.referer) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != tc.want { t.Errorf("%s %q %q: %d want %d", tc.method, tc.origin, tc.referer, rec.Code, tc.want) } } t.Setenv("KEIJI_INSECURE_COOKIE", "true") req := httptest.NewRequest("POST", "http://example.com/", nil) req.Header.Set("Origin", "http://example.com") rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != 204 { t.Fatal(rec.Code) } } func TestLoginRateLimitProxy(t *testing.T) { gin.SetMode(gin.TestMode) for _, trusted := range []bool{false, true} { t.Run(map[bool]string{false: "untrusted", true: "trusted"}[trusted], func(t *testing.T) { if trusted { t.Setenv("KEIJI_TRUSTED_PROXIES", "10.0.0.0/8") } else { t.Setenv("KEIJI_TRUSTED_PROXIES", "") } r := gin.New() r.POST("/", LoginRateLimit(), func(c *gin.Context) { c.Status(204) }) request := func(remote, xff string) int { req := httptest.NewRequest("POST", "/", nil) req.RemoteAddr = remote req.Header.Set("X-Forwarded-For", xff) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) return rec.Code } for i := 0; i < 10; i++ { if got := request("10.0.0.1:123", "1.1.1.1"); got != 204 { t.Fatal(got) } } if got := request("10.0.0.1:123", "1.1.1.1"); got != 429 { t.Fatal(got) } want := 429 if trusted { want = 204 } if got := request("10.0.0.1:123", "2.2.2.2"); got != want { t.Fatalf("got %d want %d", got, want) } if trusted { if got := request("10.0.0.1:123", "2.2.2.2, 1.1.1.1"); got != 429 { t.Fatalf("spoofed chain: %d", got) } } }) } } func TestAuthenticationStopsHandler(t *testing.T) { gin.SetMode(gin.TestMode) c := &Controller{Cache: auth.NewCache()} r := gin.New() r.GET("/admin", c.IsAuthenticated, func(c *gin.Context) { c.Status(204) }) rec := httptest.NewRecorder() r.ServeHTTP(rec, httptest.NewRequest("GET", "/admin", nil)) if rec.Code != 302 || rec.Header().Get("Location") != "/login" { t.Fatalf("%d %v", rec.Code, rec.Header()) } } func TestAuthenticatedAdminRequiresOriginForMutation(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("KEIJI_INSECURE_COOKIE", "") c := &Controller{Cache: auth.NewCache()} session, err := auth.Authorize(&auth.Credentials{Username: "admin", Password: "password"}, c.Cache, securitySource{}) if err != nil { t.Fatal(err) } r := gin.New() group := r.Group("/admin", c.IsAuthenticated, SameOrigin) group.GET("/test", func(c *gin.Context) { c.Status(204) }) group.POST("/test", func(c *gin.Context) { c.Status(204) }) group.PATCH("/test", func(c *gin.Context) { c.Status(204) }) group.DELETE("/test", func(c *gin.Context) { c.Status(204) }) for _, tc := range []struct { method, origin string want int }{{"GET", "", 204}, {"POST", "", 403}, {"POST", "https://evil.example", 403}, {"POST", "https://example.com", 204}, {"PATCH", "https://example.com", 204}, {"DELETE", "https://example.com", 204}} { req := httptest.NewRequest(tc.method, "https://example.com/admin/test", nil) req.AddCookie(&http.Cookie{Name: AUTH_COOKIE_NAME, Value: session}) req.Header.Set("Origin", tc.origin) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != tc.want { t.Errorf("%s origin %q: got %d want %d", tc.method, tc.origin, rec.Code, tc.want) } } }