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:
Snider 2026-02-20 23:10:52 +00:00
parent 8f3e496173
commit 6bb7195cca
4 changed files with 207 additions and 0 deletions

1
go.mod
View file

@ -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
View file

@ -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=

View file

@ -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
View 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")
}
}