init
This commit is contained in:
commit
b15b95781c
108 changed files with 14802 additions and 0 deletions
194
internal/http/security_integration_test.go
Normal file
194
internal/http/security_integration_test.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
package httpserver_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"update_server/internal/apikeys"
|
||||
"update_server/internal/config"
|
||||
"update_server/internal/db"
|
||||
)
|
||||
|
||||
func TestAdminPOSTRejectsMissingOrInvalidCSRFToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, _ := newTestRouterWithStore(t)
|
||||
sessionCookie := loginAsAdmin(t, router)
|
||||
|
||||
missingRecorder := performRequest(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, sessionCookie)
|
||||
if missingRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected missing csrf token to return 403, got %d with body %s", missingRecorder.Code, missingRecorder.Body.String())
|
||||
}
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router, sessionCookie)
|
||||
invalidRecorder := performRequest(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
"csrf_token": {"definitely-wrong"},
|
||||
}, sessionCookie, csrfCookie)
|
||||
if invalidRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected invalid csrf token to return 403, got %d with body %s", invalidRecorder.Code, invalidRecorder.Body.String())
|
||||
}
|
||||
|
||||
location := submitForm(t, router, http.MethodPost, "/admin/projects", url.Values{
|
||||
"name": {"Desktop App"},
|
||||
"slug": {"desktop-app"},
|
||||
}, http.StatusSeeOther, sessionCookie)
|
||||
if location == "" {
|
||||
t.Fatal("expected valid csrf-protected form to redirect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeadersAndCookieDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, cfg, store := newTestRouterWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.BaseURL = "https://updates.example.com"
|
||||
cfg.SecureCookies = true
|
||||
})
|
||||
|
||||
loginPageRecorder := performRequest(t, router, http.MethodGet, "/admin/login", nil)
|
||||
if loginPageRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected login page to load, got %d", loginPageRecorder.Code)
|
||||
}
|
||||
|
||||
assertHeaderContains(t, loginPageRecorder, "Cache-Control", "no-store")
|
||||
assertHeaderEquals(t, loginPageRecorder, "X-Frame-Options", "DENY")
|
||||
assertHeaderEquals(t, loginPageRecorder, "X-Content-Type-Options", "nosniff")
|
||||
assertHeaderEquals(t, loginPageRecorder, "Referrer-Policy", "no-referrer")
|
||||
assertHeaderContains(t, loginPageRecorder, "Content-Security-Policy", "frame-ancestors 'none'")
|
||||
assertHeaderContains(t, loginPageRecorder, "Strict-Transport-Security", "max-age=31536000")
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
if csrfCookie.Path != "/admin" {
|
||||
t.Fatalf("expected csrf cookie path /admin, got %q", csrfCookie.Path)
|
||||
}
|
||||
if !csrfCookie.HttpOnly {
|
||||
t.Fatal("expected csrf cookie to be HttpOnly")
|
||||
}
|
||||
if !csrfCookie.Secure {
|
||||
t.Fatal("expected csrf cookie to be Secure when APP_BASE_URL is https")
|
||||
}
|
||||
if csrfCookie.SameSite != http.SameSiteStrictMode {
|
||||
t.Fatalf("expected csrf cookie SameSite=Strict, got %v", csrfCookie.SameSite)
|
||||
}
|
||||
|
||||
loginRecorder := performRequest(t, router, http.MethodPost, "/admin/login", url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"correct horse battery staple"},
|
||||
"next": {"/admin"},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}, csrfCookie)
|
||||
if loginRecorder.Code != http.StatusSeeOther {
|
||||
t.Fatalf("expected login to redirect, got %d with body %s", loginRecorder.Code, loginRecorder.Body.String())
|
||||
}
|
||||
|
||||
var sessionCookie *http.Cookie
|
||||
for _, cookie := range loginRecorder.Result().Cookies() {
|
||||
if cookie.Name == cfg.SessionCookieName {
|
||||
sessionCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("expected session cookie after login")
|
||||
}
|
||||
if sessionCookie.Path != "/admin" {
|
||||
t.Fatalf("expected session cookie path /admin, got %q", sessionCookie.Path)
|
||||
}
|
||||
if !sessionCookie.HttpOnly {
|
||||
t.Fatal("expected session cookie to be HttpOnly")
|
||||
}
|
||||
if !sessionCookie.Secure {
|
||||
t.Fatal("expected session cookie to be Secure")
|
||||
}
|
||||
if sessionCookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("expected session cookie SameSite=Lax, got %v", sessionCookie.SameSite)
|
||||
}
|
||||
|
||||
keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Clients",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create api key: %v", err)
|
||||
}
|
||||
|
||||
apiRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey)
|
||||
if apiRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected api project listing to succeed, got %d with body %s", apiRecorder.Code, apiRecorder.Body.String())
|
||||
}
|
||||
|
||||
assertHeaderContains(t, apiRecorder, "Cache-Control", "no-store")
|
||||
assertHeaderContains(t, apiRecorder, "Vary", "Authorization")
|
||||
assertHeaderEquals(t, apiRecorder, "X-Content-Type-Options", "nosniff")
|
||||
assertHeaderEquals(t, apiRecorder, "X-Frame-Options", "DENY")
|
||||
assertHeaderContains(t, apiRecorder, "X-Robots-Tag", "noindex")
|
||||
}
|
||||
|
||||
func TestLoginRateLimitReturnsTooManyRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, _ := newTestRouterWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.LoginRateLimitPerMinute = 60
|
||||
cfg.LoginRateLimitBurst = 2
|
||||
})
|
||||
|
||||
csrfCookie := ensureCSRFCookie(t, router)
|
||||
form := url.Values{
|
||||
"email": {"admin@example.com"},
|
||||
"password": {"wrong-password"},
|
||||
"next": {"/admin"},
|
||||
"csrf_token": {csrfCookie.Value},
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
recorder := performRequest(t, router, http.MethodPost, "/admin/login", form, csrfCookie)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected attempt %d to return 401, got %d with body %s", attempt+1, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
limitedRecorder := performRequest(t, router, http.MethodPost, "/admin/login", form, csrfCookie)
|
||||
if limitedRecorder.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected limited login to return 429, got %d with body %s", limitedRecorder.Code, limitedRecorder.Body.String())
|
||||
}
|
||||
assertHeaderContains(t, limitedRecorder, "Retry-After", "1")
|
||||
}
|
||||
|
||||
func TestClientAPIRateLimitReturnsTooManyRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
router, _, store := newTestRouterWithConfig(t, func(cfg *config.Config) {
|
||||
cfg.ClientRateLimitPerMinute = 60
|
||||
cfg.ClientRateLimitBurst = 2
|
||||
})
|
||||
|
||||
keyResult, err := apikeys.NewService(store).Create(t.Context(), apikeys.CreateParams{
|
||||
Name: "Clients",
|
||||
ScopeMode: db.ScopeModeAllProjects,
|
||||
CanDownload: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create api key: %v", err)
|
||||
}
|
||||
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
recorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected api attempt %d to return 200, got %d with body %s", attempt+1, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
limitedRecorder := performAPIRequest(t, router, http.MethodGet, "/api/v1/projects", "Bearer "+keyResult.RawKey)
|
||||
if limitedRecorder.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("expected api rate limit to return 429, got %d with body %s", limitedRecorder.Code, limitedRecorder.Body.String())
|
||||
}
|
||||
assertHeaderContains(t, limitedRecorder, "Retry-After", "1")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue