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:
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user