Add QA test outputs, build scripts, and Dockerfile validation

- Created `qa-test-output-after-fix.txt` and `qa-test-output.txt` to log results of certificate page authentication tests.
- Added `build.sh` for deterministic backend builds in CI, utilizing `go list` for efficiency.
- Introduced `codeql_scan.sh` for CodeQL database creation and analysis for Go and JavaScript/TypeScript.
- Implemented `dockerfile_check.sh` to validate Dockerfiles for base image and package manager mismatches.
- Added `sourcery_precommit_wrapper.sh` to facilitate Sourcery CLI usage in pre-commit hooks.
This commit is contained in:
GitHub Actions
2025-12-11 18:26:24 +00:00
parent 65d837a13f
commit 8294d6ee49
609 changed files with 111623 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
package middleware
import (
"net/http"
"strings"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
)
func AuthMiddleware(authService *services.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
// Try cookie
cookie, err := c.Cookie("auth_token")
if err == nil {
authHeader = "Bearer " + cookie
}
}
if authHeader == "" {
// Try query param
token := c.Query("token")
if token != "" {
authHeader = "Bearer " + token
}
}
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, err := authService.ValidateToken(tokenString)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
return
}
c.Set("userID", claims.UserID)
c.Set("role", claims.Role)
c.Next()
}
}
func RequireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole, exists := c.Get("role")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
return
}
if userRole.(string) != role && userRole.(string) != "admin" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "Forbidden"})
return
}
c.Next()
}
}
@@ -0,0 +1,163 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Wikid82/charon/backend/internal/config"
"github.com/Wikid82/charon/backend/internal/models"
"github.com/Wikid82/charon/backend/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func setupAuthService(t *testing.T) *services.AuthService {
dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
require.NoError(t, err)
db.AutoMigrate(&models.User{})
cfg := config.Config{JWTSecret: "test-secret"}
return services.NewAuthService(db, cfg)
}
func TestAuthMiddleware_MissingHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
// We pass nil for authService because we expect it to fail before using it
r.Use(AuthMiddleware(nil))
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "Authorization header required")
}
func TestRequireRole_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "admin")
c.Next()
})
r.Use(RequireRole("admin"))
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestRequireRole_Forbidden(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(func(c *gin.Context) {
c.Set("role", "user")
c.Next()
})
r.Use(RequireRole("admin"))
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
}
func TestAuthMiddleware_Cookie(t *testing.T) {
authService := setupAuthService(t)
user, err := authService.Register("test@example.com", "password", "Test User")
require.NoError(t, err)
token, err := authService.GenerateToken(user)
require.NoError(t, err)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(AuthMiddleware(authService))
r.GET("/test", func(c *gin.Context) {
userID, _ := c.Get("userID")
assert.Equal(t, user.ID, userID)
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", http.NoBody)
req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestAuthMiddleware_ValidToken(t *testing.T) {
authService := setupAuthService(t)
user, err := authService.Register("test@example.com", "password", "Test User")
require.NoError(t, err)
token, err := authService.GenerateToken(user)
require.NoError(t, err)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(AuthMiddleware(authService))
r.GET("/test", func(c *gin.Context) {
userID, _ := c.Get("userID")
assert.Equal(t, user.ID, userID)
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", http.NoBody)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
}
func TestAuthMiddleware_InvalidToken(t *testing.T) {
authService := setupAuthService(t)
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(AuthMiddleware(authService))
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", http.NoBody)
req.Header.Set("Authorization", "Bearer invalid-token")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "Invalid token")
}
func TestRequireRole_MissingRoleInContext(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
// No role set in context
r.Use(RequireRole("admin"))
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req, _ := http.NewRequest("GET", "/test", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
+5
View File
@@ -0,0 +1,5 @@
// Package middleware provides Gin middleware for the Charon backend API.
//
// It includes middleware for authentication, request logging, panic recovery,
// security headers, and request ID generation.
package middleware
@@ -0,0 +1,32 @@
package middleware
import (
"net/http"
"runtime/debug"
"github.com/gin-gonic/gin"
)
// Recovery logs panic information. When verbose is true it logs stacktraces
// and basic request metadata for debugging.
func Recovery(verbose bool) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
// Try to get a request-scoped logger; fall back to global logger
entry := GetRequestLogger(c)
if verbose {
entry.WithFields(map[string]interface{}{
"method": c.Request.Method,
"path": SanitizePath(c.Request.URL.Path),
"headers": SanitizeHeaders(c.Request.Header),
}).Errorf("PANIC: %v\nStacktrace:\n%s", r, debug.Stack())
} else {
entry.Errorf("PANIC: %v", r)
}
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
}
}()
c.Next()
}
}
@@ -0,0 +1,115 @@
package middleware
import (
"bytes"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/gin-gonic/gin"
)
func TestRecoveryLogsStacktraceVerbose(t *testing.T) {
old := log.Writer()
buf := &bytes.Buffer{}
log.SetOutput(buf)
defer log.SetOutput(old)
// Ensure structured logger writes to the same buffer and enable debug
logger.Init(true, buf)
router := gin.New()
router.Use(RequestID())
router.Use(Recovery(true))
router.GET("/panic", func(c *gin.Context) {
panic("test panic")
})
req := httptest.NewRequest(http.MethodGet, "/panic", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", w.Code)
}
out := buf.String()
if !strings.Contains(out, "PANIC: test panic") {
t.Fatalf("log did not include panic message: %s", out)
}
if !strings.Contains(out, "Stacktrace:") {
t.Fatalf("verbose log did not include stack trace: %s", out)
}
if !strings.Contains(out, "request_id") {
t.Fatalf("verbose log did not include request_id: %s", out)
}
}
func TestRecoveryLogsBriefWhenNotVerbose(t *testing.T) {
old := log.Writer()
buf := &bytes.Buffer{}
log.SetOutput(buf)
defer log.SetOutput(old)
// Ensure structured logger writes to the same buffer and keep debug off
logger.Init(false, buf)
router := gin.New()
router.Use(RequestID())
router.Use(Recovery(false))
router.GET("/panic", func(c *gin.Context) {
panic("brief panic")
})
req := httptest.NewRequest(http.MethodGet, "/panic", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", w.Code)
}
out := buf.String()
if !strings.Contains(out, "PANIC: brief panic") {
t.Fatalf("log did not include panic message: %s", out)
}
if strings.Contains(out, "Stacktrace:") {
t.Fatalf("non-verbose log unexpectedly included stacktrace: %s", out)
}
}
func TestRecoverySanitizesHeadersAndPath(t *testing.T) {
old := log.Writer()
buf := &bytes.Buffer{}
log.SetOutput(buf)
defer log.SetOutput(old)
// Ensure structured logger writes to the same buffer and enable debug
logger.Init(true, buf)
router := gin.New()
router.Use(RequestID())
router.Use(Recovery(true))
router.GET("/panic", func(c *gin.Context) {
panic("sensitive panic")
})
req := httptest.NewRequest(http.MethodGet, "/panic", http.NoBody)
// Add sensitive header that should be redacted
req.Header.Set("Authorization", "Bearer secret-token")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected status 500, got %d", w.Code)
}
out := buf.String()
if strings.Contains(out, "secret-token") {
t.Fatalf("log contained sensitive token: %s", out)
}
if !strings.Contains(out, "<redacted>") {
t.Fatalf("log did not include redaction marker: %s", out)
}
}
@@ -0,0 +1,39 @@
package middleware
import (
"context"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/Wikid82/charon/backend/internal/trace"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
)
const RequestIDHeader = "X-Request-ID"
// RequestID generates a uuid per request and places it in context and header.
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
rid := uuid.New().String()
c.Set(string(trace.RequestIDKey), rid)
c.Writer.Header().Set(RequestIDHeader, rid)
// Add to logger fields for this request
entry := logger.WithFields(map[string]interface{}{"request_id": rid})
c.Set("logger", entry)
// Propagate into the request context so it can be used by services
ctx := context.WithValue(c.Request.Context(), trace.RequestIDKey, rid)
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
// GetRequestLogger retrieves the request-scoped logger from context or the global logger
func GetRequestLogger(c *gin.Context) *logrus.Entry {
if v, ok := c.Get("logger"); ok {
if entry, ok := v.(*logrus.Entry); ok {
return entry
}
}
// fallback
return logger.Log()
}
@@ -0,0 +1,37 @@
package middleware
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/gin-gonic/gin"
)
func TestRequestIDAddsHeaderAndLogger(t *testing.T) {
buf := &bytes.Buffer{}
logger.Init(true, buf)
router := gin.New()
router.Use(RequestID())
router.GET("/test", func(c *gin.Context) {
// Ensure logger exists in context and header is present
if _, ok := c.Get("logger"); !ok {
t.Fatalf("expected request-scoped logger in context")
}
c.String(200, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", w.Code)
}
if w.Header().Get(RequestIDHeader) == "" {
t.Fatalf("expected response to include X-Request-ID header")
}
}
@@ -0,0 +1,25 @@
package middleware
import (
"github.com/Wikid82/charon/backend/internal/util"
"time"
"github.com/gin-gonic/gin"
)
// RequestLogger logs basic request information along with the request_id.
func RequestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
latency := time.Since(start)
entry := GetRequestLogger(c)
entry.WithFields(map[string]interface{}{
"status": c.Writer.Status(),
"method": c.Request.Method,
"path": SanitizePath(c.Request.URL.Path),
"latency": latency.String(),
"client": util.SanitizeForLog(c.ClientIP()),
}).Info("handled request")
}
}
@@ -0,0 +1,72 @@
package middleware
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wikid82/charon/backend/internal/logger"
"github.com/gin-gonic/gin"
)
func TestRequestLoggerSanitizesPath(t *testing.T) {
old := logger.Log()
buf := &bytes.Buffer{}
logger.Init(true, buf)
longPath := "/" + strings.Repeat("a", 300)
router := gin.New()
router.Use(RequestID())
router.Use(RequestLogger())
router.GET(longPath, func(c *gin.Context) { c.Status(http.StatusOK) })
req := httptest.NewRequest(http.MethodGet, longPath, http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
out := buf.String()
if strings.Contains(out, strings.Repeat("a", 300)) {
t.Fatalf("logged unsanitized long path")
}
i := strings.Index(out, "path=")
if i == -1 {
t.Fatalf("could not find path in logs: %s", out)
}
sub := out[i:]
j := strings.Index(sub, " request_id=")
if j == -1 {
t.Fatalf("could not isolate path field from logs: %s", out)
}
pathField := sub[len("path="):j]
if strings.Contains(pathField, "\n") || strings.Contains(pathField, "\r") {
t.Fatalf("path field contains control characters after sanitization: %s", pathField)
}
_ = old // silence unused var
}
func TestRequestLoggerIncludesRequestID(t *testing.T) {
buf := &bytes.Buffer{}
logger.Init(true, buf)
router := gin.New()
router.Use(RequestID())
router.Use(RequestLogger())
router.GET("/ok", func(c *gin.Context) { c.String(200, "ok") })
req := httptest.NewRequest(http.MethodGet, "/ok", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status code: %d", w.Code)
}
out := buf.String()
if !strings.Contains(out, "request_id") {
t.Fatalf("expected log output to include request_id: %s", out)
}
if !strings.Contains(out, "handled request") {
t.Fatalf("expected log output to indicate handled request: %s", out)
}
}
@@ -0,0 +1,62 @@
package middleware
import (
"net/http"
"strings"
"github.com/Wikid82/charon/backend/internal/util"
)
// SanitizeHeaders returns a map of header keys to redacted/sanitized values
// for safe logging. Sensitive headers are redacted; other values are
// sanitized using util.SanitizeForLog and truncated.
func SanitizeHeaders(h http.Header) map[string][]string {
if h == nil {
return nil
}
sensitive := map[string]struct{}{
"authorization": {},
"cookie": {},
"set-cookie": {},
"proxy-authorization": {},
"x-api-key": {},
"x-api-token": {},
"x-access-token": {},
"x-auth-token": {},
"x-api-secret": {},
"x-forwarded-for": {},
}
out := make(map[string][]string, len(h))
for k, vals := range h {
keyLower := strings.ToLower(k)
if _, ok := sensitive[keyLower]; ok {
out[k] = []string{"<redacted>"}
continue
}
sanitizedVals := make([]string, 0, len(vals))
for _, v := range vals {
v2 := util.SanitizeForLog(v)
if len(v2) > 200 {
v2 = v2[:200]
}
sanitizedVals = append(sanitizedVals, v2)
}
out[k] = sanitizedVals
}
return out
}
// SanitizePath prepares a request path for safe logging by removing
// control characters and truncating long values. It does not include
// query parameters.
func SanitizePath(p string) string {
// remove query string
if i := strings.Index(p, "?"); i != -1 {
p = p[:i]
}
p = util.SanitizeForLog(p)
if len(p) > 200 {
p = p[:200]
}
return p
}
@@ -0,0 +1,55 @@
package middleware
import (
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestSanitizeHeaders(t *testing.T) {
t.Run("nil headers", func(t *testing.T) {
require.Nil(t, SanitizeHeaders(nil))
})
t.Run("redacts sensitive headers", func(t *testing.T) {
headers := http.Header{}
headers.Set("Authorization", "secret")
headers.Set("X-Api-Key", "token")
headers.Set("Cookie", "sessionid=abc")
sanitized := SanitizeHeaders(headers)
require.Equal(t, []string{"<redacted>"}, sanitized["Authorization"])
require.Equal(t, []string{"<redacted>"}, sanitized["X-Api-Key"])
require.Equal(t, []string{"<redacted>"}, sanitized["Cookie"])
})
t.Run("sanitizes and truncates values", func(t *testing.T) {
headers := http.Header{}
headers.Add("X-Trace", "line1\nline2\r\t")
headers.Add("X-Custom", strings.Repeat("a", 210))
sanitized := SanitizeHeaders(headers)
traceValue := sanitized["X-Trace"][0]
require.NotContains(t, traceValue, "\n")
require.NotContains(t, traceValue, "\r")
require.NotContains(t, traceValue, "\t")
customValue := sanitized["X-Custom"][0]
require.Equal(t, 200, len(customValue))
require.True(t, strings.HasPrefix(customValue, strings.Repeat("a", 200)))
})
}
func TestSanitizePath(t *testing.T) {
paddedPath := "/api/v1/resource/" + strings.Repeat("x", 210) + "?token=secret"
sanitized := SanitizePath(paddedPath)
require.NotContains(t, sanitized, "?")
require.False(t, strings.ContainsAny(sanitized, "\n\r\t"))
require.Equal(t, 200, len(sanitized))
}
+126
View File
@@ -0,0 +1,126 @@
package middleware
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
// SecurityHeadersConfig holds configuration for the security headers middleware.
type SecurityHeadersConfig struct {
// IsDevelopment enables less strict settings for local development
IsDevelopment bool
// CustomCSPDirectives allows adding extra CSP directives
CustomCSPDirectives map[string]string
}
// DefaultSecurityHeadersConfig returns a secure default configuration.
func DefaultSecurityHeadersConfig() SecurityHeadersConfig {
return SecurityHeadersConfig{
IsDevelopment: false,
CustomCSPDirectives: nil,
}
}
// SecurityHeaders returns middleware that sets security-related HTTP headers.
// This implements Phase 1 of the security hardening plan.
func SecurityHeaders(cfg SecurityHeadersConfig) gin.HandlerFunc {
return func(c *gin.Context) {
// Build Content-Security-Policy
csp := buildCSP(cfg)
c.Header("Content-Security-Policy", csp)
// Strict-Transport-Security (HSTS)
// max-age=31536000 = 1 year
// includeSubDomains ensures all subdomains also use HTTPS
// preload allows browser preload lists (requires submission to hstspreload.org)
if !cfg.IsDevelopment {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
}
// X-Frame-Options: Prevent clickjacking
// DENY prevents any framing; SAMEORIGIN would allow same-origin framing
c.Header("X-Frame-Options", "DENY")
// X-Content-Type-Options: Prevent MIME sniffing
c.Header("X-Content-Type-Options", "nosniff")
// X-XSS-Protection: Enable browser XSS filtering (legacy but still useful)
// mode=block tells browser to block the response if XSS is detected
c.Header("X-XSS-Protection", "1; mode=block")
// Referrer-Policy: Control referrer information sent with requests
// strict-origin-when-cross-origin sends full URL for same-origin, origin only for cross-origin
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// Permissions-Policy: Restrict browser features
// Disable features that aren't needed for security
c.Header("Permissions-Policy", buildPermissionsPolicy())
// Cross-Origin-Opener-Policy: Isolate browsing context
c.Header("Cross-Origin-Opener-Policy", "same-origin")
// Cross-Origin-Resource-Policy: Prevent cross-origin reads
c.Header("Cross-Origin-Resource-Policy", "same-origin")
// Cross-Origin-Embedder-Policy: Require CORP for cross-origin resources
// Note: This can break some external resources, use with caution
// c.Header("Cross-Origin-Embedder-Policy", "require-corp")
c.Next()
}
}
// buildCSP constructs the Content-Security-Policy header value.
func buildCSP(cfg SecurityHeadersConfig) string {
// Base CSP directives for a secure single-page application
directives := map[string]string{
"default-src": "'self'",
"script-src": "'self'",
"style-src": "'self' 'unsafe-inline'", // unsafe-inline needed for many CSS-in-JS solutions
"img-src": "'self' data: https:", // Allow HTTPS images and data URIs
"font-src": "'self' data:", // Allow self-hosted fonts and data URIs
"connect-src": "'self'", // API connections
"frame-src": "'none'", // No iframes
"object-src": "'none'", // No plugins (Flash, etc.)
"base-uri": "'self'", // Restrict base tag
"form-action": "'self'", // Restrict form submissions
}
// In development, allow more sources for hot reloading, etc.
if cfg.IsDevelopment {
directives["script-src"] = "'self' 'unsafe-inline' 'unsafe-eval'"
directives["connect-src"] = "'self' ws: wss:" // WebSocket for HMR
}
// Apply custom directives
for key, value := range cfg.CustomCSPDirectives {
directives[key] = value
}
// Build the CSP string
var parts []string
for directive, value := range directives {
parts = append(parts, fmt.Sprintf("%s %s", directive, value))
}
return strings.Join(parts, "; ")
}
// buildPermissionsPolicy constructs the Permissions-Policy header value.
func buildPermissionsPolicy() string {
// Disable features we don't need
policies := []string{
"accelerometer=()",
"camera=()",
"geolocation=()",
"gyroscope=()",
"magnetometer=()",
"microphone=()",
"payment=()",
"usb=()",
}
return strings.Join(policies, ", ")
}
@@ -0,0 +1,182 @@
package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestSecurityHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
isDevelopment bool
checkHeaders func(t *testing.T, resp *httptest.ResponseRecorder)
}{
{
name: "production mode sets HSTS",
isDevelopment: false,
checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
hsts := resp.Header().Get("Strict-Transport-Security")
assert.Contains(t, hsts, "max-age=31536000")
assert.Contains(t, hsts, "includeSubDomains")
assert.Contains(t, hsts, "preload")
},
},
{
name: "development mode skips HSTS",
isDevelopment: true,
checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
hsts := resp.Header().Get("Strict-Transport-Security")
assert.Empty(t, hsts)
},
},
{
name: "sets X-Frame-Options",
isDevelopment: false,
checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
assert.Equal(t, "DENY", resp.Header().Get("X-Frame-Options"))
},
},
{
name: "sets X-Content-Type-Options",
isDevelopment: false,
checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
assert.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options"))
},
},
{
name: "sets X-XSS-Protection",
isDevelopment: false,
checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
assert.Equal(t, "1; mode=block", resp.Header().Get("X-XSS-Protection"))
},
},
{
name: "sets Referrer-Policy",
isDevelopment: false,
checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
assert.Equal(t, "strict-origin-when-cross-origin", resp.Header().Get("Referrer-Policy"))
},
},
{
name: "sets Content-Security-Policy",
isDevelopment: false,
checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
csp := resp.Header().Get("Content-Security-Policy")
assert.NotEmpty(t, csp)
assert.Contains(t, csp, "default-src")
},
},
{
name: "development mode CSP allows unsafe-eval",
isDevelopment: true,
checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
csp := resp.Header().Get("Content-Security-Policy")
assert.Contains(t, csp, "unsafe-eval")
},
},
{
name: "sets Permissions-Policy",
isDevelopment: false,
checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
pp := resp.Header().Get("Permissions-Policy")
assert.NotEmpty(t, pp)
assert.Contains(t, pp, "camera=()")
assert.Contains(t, pp, "microphone=()")
},
},
{
name: "sets Cross-Origin-Opener-Policy",
isDevelopment: false,
checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
assert.Equal(t, "same-origin", resp.Header().Get("Cross-Origin-Opener-Policy"))
},
},
{
name: "sets Cross-Origin-Resource-Policy",
isDevelopment: false,
checkHeaders: func(t *testing.T, resp *httptest.ResponseRecorder) {
assert.Equal(t, "same-origin", resp.Header().Get("Cross-Origin-Resource-Policy"))
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := gin.New()
router.Use(SecurityHeaders(SecurityHeadersConfig{
IsDevelopment: tt.isDevelopment,
}))
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "OK")
})
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusOK, resp.Code)
tt.checkHeaders(t, resp)
})
}
}
func TestSecurityHeadersCustomCSP(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(SecurityHeaders(SecurityHeadersConfig{
IsDevelopment: false,
CustomCSPDirectives: map[string]string{
"frame-src": "'self' https://trusted.com",
},
}))
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "OK")
})
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
csp := resp.Header().Get("Content-Security-Policy")
assert.Contains(t, csp, "frame-src 'self' https://trusted.com")
}
func TestDefaultSecurityHeadersConfig(t *testing.T) {
cfg := DefaultSecurityHeadersConfig()
assert.False(t, cfg.IsDevelopment)
assert.Nil(t, cfg.CustomCSPDirectives)
}
func TestBuildCSP(t *testing.T) {
t.Run("production CSP", func(t *testing.T) {
csp := buildCSP(SecurityHeadersConfig{IsDevelopment: false})
assert.Contains(t, csp, "default-src 'self'")
assert.Contains(t, csp, "script-src 'self'")
assert.NotContains(t, csp, "unsafe-eval")
})
t.Run("development CSP", func(t *testing.T) {
csp := buildCSP(SecurityHeadersConfig{IsDevelopment: true})
assert.Contains(t, csp, "unsafe-eval")
assert.Contains(t, csp, "ws:")
})
}
func TestBuildPermissionsPolicy(t *testing.T) {
pp := buildPermissionsPolicy()
// Check that dangerous features are disabled
disabledFeatures := []string{"camera", "microphone", "geolocation", "payment"}
for _, feature := range disabledFeatures {
assert.True(t, strings.Contains(pp, feature+"=()"),
"Expected %s to be disabled in permissions policy", feature)
}
}