fix: harden login for MLPS requirements

This commit is contained in:
2026-05-14 11:05:20 +08:00
parent ecf3ee1ef0
commit 879677516f
39 changed files with 1230 additions and 71 deletions
+1 -13
View File
@@ -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})
}
+30 -1
View File
@@ -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"))
}