diff --git a/handler/middleware/cross_site_request_forgery.go b/handler/middleware/cross_site_request_forgery.go index c8ed1a4..9fbca5f 100644 --- a/handler/middleware/cross_site_request_forgery.go +++ b/handler/middleware/cross_site_request_forgery.go @@ -2,11 +2,10 @@ package middleware import ( "fmt" + "net/http" "strings" "me-fit/service" - - "net/http" ) type csrfResponseWriter struct { diff --git a/handler/middleware/logger.go b/handler/middleware/logger.go index 1514114..63d6eb4 100644 --- a/handler/middleware/logger.go +++ b/handler/middleware/logger.go @@ -1,12 +1,12 @@ package middleware import ( - "me-fit/log" - "net/http" "strconv" "time" + "me-fit/log" + "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" ) diff --git a/handler/middleware/security_headers.go b/handler/middleware/security_headers.go index e1b2e01..2dce7f3 100644 --- a/handler/middleware/security_headers.go +++ b/handler/middleware/security_headers.go @@ -7,33 +7,26 @@ import ( ) func SecurityHeaders(serverSettings *types.Settings) func(http.Handler) http.Handler { - cspValues := map[string]string{ - "default-src": "'none'", - "script-src": "'self' https://umami.me-fit.eu", - "connect-src": "'self' https://umami.me-fit.eu", - "img-src": "'self'", - "style-src": "'self'", - "form-action": "'self'", - "frame-ancestors": "'none'", - } - - var csp string - for key, value := range cspValues { - csp += key + " " + value + "; " - } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Access-Control-Allow-Origin", serverSettings.BaseUrl) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE") - w.Header().Set("Content-Security-Policy", csp) + w.Header().Set("Content-Security-Policy", + "default-src 'none';"+ + "script-src 'self' https://umami.me-fit.eu"+ + "connect-src 'self' https://umami.me-fit.eu"+ + "img-src 'self'"+ + "style-src 'self'"+ + "form-action 'self'"+ + "frame-ancestors 'none'", + ) w.Header().Set("Cross-Origin-Resource-Policy", "same-origin") w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") w.Header().Set("Cross-Origin-Embedder-Policy", "require-corp") - w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=()") + w.Header().Set("Permissions-Policy", "geolocation=(), camera=(), microphone=(), interest-cohort=()") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - w.Header().Set("Permissions-Policy", "interest-cohort=()") w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload") if r.Method == "OPTIONS" { diff --git a/main_test.go b/main_test.go index 1268cfc..f99df06 100644 --- a/main_test.go +++ b/main_test.go @@ -30,14 +30,89 @@ var ( port atomic.Int32 ) -func TestSecurity(t *testing.T) { +func TestIntegrationSecurityHeader(t *testing.T) { t.Parallel() + t.Run("should keep caching for static content", func(t *testing.T) { + t.Parallel() + + _, basePath, ctx := setupIntegrationTest(t) + + req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/static/favicon.svg", nil) + assert.Nil(t, err) + + resp, err := httpClient.Do(req) + assert.Nil(t, err) + + cacheControl := resp.Header.Get("Cache-Control") + assert.Equal(t, "", cacheControl) + }) + t.Run("should disable caching for dynamic content", func(t *testing.T) { + t.Parallel() + + _, basePath, ctx := setupIntegrationTest(t) + + req, err := http.NewRequestWithContext(ctx, "GET", basePath, nil) + assert.Nil(t, err) + + resp, err := httpClient.Do(req) + assert.Nil(t, err) + + cacheControl := resp.Header.Get("Cache-Control") + assert.Equal(t, "no-cache, no-store, must-revalidate", cacheControl) + }) + t.Run("should include security headers", func(t *testing.T) { + t.Parallel() + + _, basePath, ctx := setupIntegrationTest(t) + + req, err := http.NewRequestWithContext(ctx, "GET", basePath, nil) + assert.Nil(t, err) + + resp, err := httpClient.Do(req) + assert.Nil(t, err) + + value := resp.Header.Get("X-Content-Type-Options") + assert.Equal(t, "nosniff", value) + + value = resp.Header.Get("Access-Control-Allow-Origin") + assert.Equal(t, basePath, value) + + value = resp.Header.Get("Access-Control-Allow-Methods") + assert.Equal(t, "GET, POST, DELETE", value) + + value = resp.Header.Get("Content-Security-Policy") + assert.Equal(t, "default-src 'none';"+ + "script-src 'self' https://umami.me-fit.eu"+ + "connect-src 'self' https://umami.me-fit.eu"+ + "img-src 'self'"+ + "style-src 'self'"+ + "form-action 'self'"+ + "frame-ancestors 'none'", value) + + value = resp.Header.Get("Cross-Origin-Resource-Policy") + assert.Equal(t, "same-origin", value) + + value = resp.Header.Get("Cross-Origin-Opener-Policy") + assert.Equal(t, "same-origin", value) + + value = resp.Header.Get("Cross-Origin-Embedder-Policy") + assert.Equal(t, "require-corp", value) + + value = resp.Header.Get("Permissions-Policy") + assert.Equal(t, "geolocation=(), camera=(), microphone=(), interest-cohort=()", value) + + value = resp.Header.Get("Referrer-Policy") + assert.Equal(t, "strict-origin-when-cross-origin", value) + + value = resp.Header.Get("Strict-Transport-Security") + assert.Equal(t, "max-age=63072000; includeSubDomains; preload", value) + }) } -func TestAuth(t *testing.T) { +func TestIntegrationAuth(t *testing.T) { t.Parallel() - t.Run("should signin and return session cookie", func(t *testing.T) { + t.Run("should return secure cookie on signin with generated csrf-token and session-id", func(t *testing.T) { t.Parallel() db, basePath, ctx := setupIntegrationTest(t) @@ -46,9 +121,7 @@ func TestAuth(t *testing.T) { _, err := db.Exec(` INSERT INTO user (user_id, email, email_verified, is_admin, password, salt, created_at) VALUES (?, "mail@mail.de", FALSE, FALSE, ?, ?, datetime())`, uuid.New(), pass, []byte("salt")) - if err != nil { - t.Fatalf("Error inserting user: %v", err) - } + assert.Nil(t, err) req, err := http.NewRequestWithContext(ctx, "GET", basePath+"/auth/signin", nil) assert.Nil(t, err) @@ -73,7 +146,7 @@ func TestAuth(t *testing.T) { req, err = http.NewRequestWithContext(ctx, "POST", basePath+"/api/auth/signin", strings.NewReader(formData.Encode())) assert.Nil(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Cookie", anonymousSession.Name+"="+anonymousSession.Value) + req.Header.Set("Cookie", "id="+anonymousSession.Value) resp, err = httpClient.Do(req) assert.Nil(t, err) @@ -81,11 +154,10 @@ func TestAuth(t *testing.T) { assert.Equal(t, http.StatusSeeOther, resp.StatusCode) cookie := findCookie(resp, "id") - if cookie == nil { - t.Fatalf("No session cookie found") - } else if cookie.SameSite != http.SameSiteStrictMode || cookie.HttpOnly != true || cookie.Secure != true { - t.Fatalf("Cookie is not secure") - } + assert.NotNil(t, cookie) + assert.Equal(t, http.SameSiteStrictMode, cookie.SameSite, "Cookie is not secure") + assert.True(t, cookie.HttpOnly, "Cookie is not secure") + assert.True(t, cookie.Secure, "Cookie is not secure") }) }