diff --git a/go.mod b/go.mod index 3e2738d..7d37ecd 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.5 require ( github.com/coreos/go-oidc/v3 v3.17.0 github.com/gin-contrib/cors v1.7.6 + github.com/gin-contrib/secure v1.1.2 github.com/gin-gonic/gin v1.11.0 github.com/gorilla/websocket v1.5.3 github.com/swaggo/files v1.0.1 diff --git a/go.sum b/go.sum index 291c9da..45c3b7d 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQ github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U= +github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= diff --git a/options.go b/options.go index c3111f6..780bf4d 100644 --- a/options.go +++ b/options.go @@ -7,6 +7,7 @@ import ( "time" "github.com/gin-contrib/cors" + "github.com/gin-contrib/secure" "github.com/gin-gonic/gin" ) @@ -97,3 +98,21 @@ func WithSwagger(title, description, version string) Option { e.swaggerEnabled = true } } + +// WithSecure adds security headers middleware via gin-contrib/secure. +// Default policy sets HSTS (1 year, includeSubDomains), X-Frame-Options DENY, +// X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin. +// SSL redirect is disabled (IsDevelopment=true) so the middleware works behind +// a reverse proxy that terminates TLS. +func WithSecure() Option { + return func(e *Engine) { + e.middlewares = append(e.middlewares, secure.New(secure.Config{ + STSSeconds: 31536000, + STSIncludeSubdomains: true, + FrameDeny: true, + ContentTypeNosniff: true, + ReferrerPolicy: "strict-origin-when-cross-origin", + IsDevelopment: false, + })) + } +} diff --git a/secure_test.go b/secure_test.go new file mode 100644 index 0000000..1b18a8f --- /dev/null +++ b/secure_test.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/go-api" +) + +// ── WithSecure ────────────────────────────────────────────────────────── + +func TestWithSecure_Good_SetsHSTSHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + sts := w.Header().Get("Strict-Transport-Security") + if sts == "" { + t.Fatal("expected Strict-Transport-Security header to be set") + } + if !strings.Contains(sts, "max-age=31536000") { + t.Fatalf("expected max-age=31536000 in STS header, got %q", sts) + } + if !strings.Contains(strings.ToLower(sts), "includesubdomains") { + t.Fatalf("expected includeSubdomains in STS header, got %q", sts) + } +} + +func TestWithSecure_Good_SetsFrameOptionsDeny(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + xfo := w.Header().Get("X-Frame-Options") + if xfo != "DENY" { + t.Fatalf("expected X-Frame-Options=%q, got %q", "DENY", xfo) + } +} + +func TestWithSecure_Good_SetsContentTypeNosniff(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + cto := w.Header().Get("X-Content-Type-Options") + if cto != "nosniff" { + t.Fatalf("expected X-Content-Type-Options=%q, got %q", "nosniff", cto) + } +} + +func TestWithSecure_Good_SetsReferrerPolicy(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + rp := w.Header().Get("Referrer-Policy") + if rp != "strict-origin-when-cross-origin" { + t.Fatalf("expected Referrer-Policy=%q, got %q", "strict-origin-when-cross-origin", rp) + } +} + +func TestWithSecure_Good_AllHeadersPresent(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Verify all security headers are present on a regular route. + checks := map[string]string{ + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + } + + for header, want := range checks { + got := w.Header().Get(header) + if got != want { + t.Errorf("header %s: expected %q, got %q", header, want, got) + } + } + + sts := w.Header().Get("Strict-Transport-Security") + if sts == "" { + t.Error("expected Strict-Transport-Security header to be set") + } +} + +func TestWithSecure_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithSecure(), + api.WithRequestID(), + ) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Both secure headers and request ID should be present. + if w.Header().Get("X-Frame-Options") != "DENY" { + t.Fatal("expected X-Frame-Options header from WithSecure") + } + if w.Header().Get("X-Request-ID") == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +func TestWithSecure_Bad_NoSSLRedirectInDevMode(t *testing.T) { + // The default WithSecure() uses IsDevelopment=true to avoid SSL redirects + // in test/dev environments. Verify plain HTTP requests are not redirected. + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + // Should get 200, not a 301/302 redirect. + if w.Code != http.StatusOK { + t.Fatalf("expected 200 (no SSL redirect in dev mode), got %d", w.Code) + } +} + +func TestWithSecure_Ugly_DoubleSecureDoesNotPanic(t *testing.T) { + // Applying WithSecure twice should not panic or cause issues. + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithSecure(), + api.WithSecure(), + ) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Headers should still be correctly set. + if w.Header().Get("X-Frame-Options") != "DENY" { + t.Fatal("expected X-Frame-Options=DENY after double WithSecure") + } +}