feat: add WithSecure security headers middleware
Wraps gin-contrib/secure to set HSTS (1 year, includeSubdomains), X-Frame-Options DENY, X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin on all responses. Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8f3e496173
commit
6bb7195cca
4 changed files with 207 additions and 0 deletions
1
go.mod
1
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
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
19
options.go
19
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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
185
secure_test.go
Normal file
185
secure_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue