fix: harden login for MLPS requirements
This commit is contained in:
@@ -5,17 +5,5 @@ import (
|
||||
)
|
||||
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Request-ID")
|
||||
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(204)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
return CORSWithConfig(CORSConfig{AllowAll: true})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -27,7 +30,7 @@ func Logger(logger *zap.Logger) gin.HandlerFunc {
|
||||
fields = append(fields, zap.String("route", route))
|
||||
}
|
||||
if rawQuery := c.Request.URL.RawQuery; rawQuery != "" {
|
||||
fields = append(fields, zap.String("query", rawQuery))
|
||||
fields = append(fields, zap.String("query", redactRawQuery(rawQuery)))
|
||||
}
|
||||
if userAgent := c.Request.UserAgent(); userAgent != "" {
|
||||
fields = append(fields, zap.String("user_agent", userAgent))
|
||||
@@ -86,3 +89,29 @@ func Logger(logger *zap.Logger) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var sensitiveQueryKeyPattern = regexp.MustCompile(`(?i)(password|passwd|pwd|token|secret|authorization|credential|session|cookie|key|signature|sig)`)
|
||||
|
||||
func redactRawQuery(raw string) string {
|
||||
values, err := url.ParseQuery(raw)
|
||||
if err != nil {
|
||||
return redactQueryFallback(raw)
|
||||
}
|
||||
for key := range values {
|
||||
if sensitiveQueryKeyPattern.MatchString(key) {
|
||||
values[key] = []string{"[REDACTED]"}
|
||||
}
|
||||
}
|
||||
return values.Encode()
|
||||
}
|
||||
|
||||
func redactQueryFallback(raw string) string {
|
||||
parts := strings.Split(raw, "&")
|
||||
for i, part := range parts {
|
||||
key, _, ok := strings.Cut(part, "=")
|
||||
if ok && sensitiveQueryKeyPattern.MatchString(key) {
|
||||
parts[i] = key + "=[REDACTED]"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
@@ -105,3 +105,36 @@ func TestLoggerUsesForwardedClientIPFromTrustedProxy(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
require.Contains(t, logBuffer.String(), `"client_ip":"106.14.89.27"`)
|
||||
}
|
||||
|
||||
func TestLoggerRedactsSensitiveQueryValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
var logBuffer bytes.Buffer
|
||||
encoderConfig := zap.NewProductionEncoderConfig()
|
||||
encoderConfig.TimeKey = ""
|
||||
logger := zap.New(zapcore.NewCore(
|
||||
zapcore.NewJSONEncoder(encoderConfig),
|
||||
zapcore.AddSync(&logBuffer),
|
||||
zapcore.DebugLevel,
|
||||
))
|
||||
|
||||
router := gin.New()
|
||||
router.Use(RequestID())
|
||||
router.Use(Logger(logger))
|
||||
router.GET("/test", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/test?password=secret&tab=cards&token=abc", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
logLine := logBuffer.String()
|
||||
require.Contains(t, logLine, `"query":"password=%5BREDACTED%5D&tab=cards&token=%5BREDACTED%5D"`)
|
||||
require.NotContains(t, logLine, "secret")
|
||||
require.NotContains(t, logLine, "token=abc")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func SecurityHeaders() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
h := c.Writer.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
h.Set("X-Permitted-Cross-Domain-Policies", "none")
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
if isHTTPSRequest(c) {
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||
}
|
||||
if isSensitivePath(c.Request.URL.Path) {
|
||||
h.Set("Cache-Control", "no-store")
|
||||
h.Set("Pragma", "no-cache")
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func isHTTPSRequest(c *gin.Context) bool {
|
||||
if c == nil || c.Request == nil {
|
||||
return false
|
||||
}
|
||||
if c.Request.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
func isSensitivePath(path string) bool {
|
||||
switch strings.TrimSpace(path) {
|
||||
case "/api/auth/login",
|
||||
"/api/auth/refresh",
|
||||
"/api/auth/me",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/password-key",
|
||||
"/api/ops/auth/login",
|
||||
"/api/ops/auth/me",
|
||||
"/api/ops/auth/password",
|
||||
"/api/ops/auth/password-key":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type CORSConfig struct {
|
||||
AllowedOrigins []string
|
||||
AllowAll bool
|
||||
}
|
||||
|
||||
func CORSWithConfig(cfg CORSConfig) gin.HandlerFunc {
|
||||
allowedOrigins := compactOrigins(cfg.AllowedOrigins)
|
||||
allowedSet := make(map[string]struct{}, len(allowedOrigins))
|
||||
for _, origin := range allowedOrigins {
|
||||
allowedSet[origin] = struct{}{}
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := strings.TrimSpace(c.GetHeader("Origin"))
|
||||
allowedOrigin := ""
|
||||
switch {
|
||||
case cfg.AllowAll:
|
||||
allowedOrigin = "*"
|
||||
case origin != "":
|
||||
if _, ok := allowedSet[origin]; ok {
|
||||
allowedOrigin = origin
|
||||
}
|
||||
}
|
||||
|
||||
if allowedOrigin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", allowedOrigin)
|
||||
if allowedOrigin != "*" {
|
||||
c.Header("Vary", "Origin")
|
||||
}
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Request-ID, X-Internal-Metrics-Token")
|
||||
c.Header("Access-Control-Expose-Headers", "X-Request-ID")
|
||||
c.Header("Access-Control-Max-Age", "86400")
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
if origin != "" && allowedOrigin == "" {
|
||||
c.AbortWithStatus(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func compactOrigins(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSecurityHeadersAddsNoStoreForSensitiveAuthPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(SecurityHeaders())
|
||||
router.POST("/api/auth/login", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/auth/login", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
require.Equal(t, "nosniff", resp.Header().Get("X-Content-Type-Options"))
|
||||
require.Equal(t, "DENY", resp.Header().Get("X-Frame-Options"))
|
||||
require.Equal(t, "no-store", resp.Header().Get("Cache-Control"))
|
||||
require.Equal(t, "no-cache", resp.Header().Get("Pragma"))
|
||||
require.Equal(t, "max-age=31536000; includeSubDomains", resp.Header().Get("Strict-Transport-Security"))
|
||||
}
|
||||
|
||||
func TestCORSWithConfigRejectsUnknownPreflightOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(CORSWithConfig(CORSConfig{AllowedOrigins: []string{"https://admin.example.com"}}))
|
||||
router.POST("/api/auth/login", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||
req.Header.Set("Origin", "https://evil.example.com")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, resp.Code)
|
||||
require.Empty(t, resp.Header().Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
|
||||
func TestCORSWithConfigAllowsConfiguredOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(CORSWithConfig(CORSConfig{AllowedOrigins: []string{"https://admin.example.com"}}))
|
||||
router.POST("/api/auth/login", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodOptions, "/api/auth/login", nil)
|
||||
req.Header.Set("Origin", "https://admin.example.com")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, resp.Code)
|
||||
require.Equal(t, "https://admin.example.com", resp.Header().Get("Access-Control-Allow-Origin"))
|
||||
require.Equal(t, "Origin", resp.Header().Get("Vary"))
|
||||
}
|
||||
Reference in New Issue
Block a user