fix: harden login for MLPS requirements
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const DefaultPasswordCipherKeyID = "login-password-rsa-oaep-v1"
|
||||
|
||||
type PasswordCipher struct {
|
||||
mu sync.RWMutex
|
||||
privateKey *rsa.PrivateKey
|
||||
keyID string
|
||||
publicPEM string
|
||||
}
|
||||
|
||||
type PasswordCipherPublicKey struct {
|
||||
KeyID string `json:"key_id"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
PublicKey string `json:"public_key"`
|
||||
}
|
||||
|
||||
func NewPasswordCipher(privateKeyPEM, keyID string) (*PasswordCipher, error) {
|
||||
privateKey, err := parseRSAPrivateKeyPEM(privateKeyPEM)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newPasswordCipherFromKey(privateKey, keyID)
|
||||
}
|
||||
|
||||
func NewEphemeralPasswordCipher(keyID string) (*PasswordCipher, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate ephemeral rsa private key: %w", err)
|
||||
}
|
||||
return newPasswordCipherFromKey(privateKey, keyID)
|
||||
}
|
||||
|
||||
func newPasswordCipherFromKey(privateKey *rsa.PrivateKey, keyID string) (*PasswordCipher, error) {
|
||||
if strings.TrimSpace(keyID) == "" {
|
||||
keyID = DefaultPasswordCipherKeyID
|
||||
}
|
||||
publicDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal rsa public key: %w", err)
|
||||
}
|
||||
publicPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: publicDER}))
|
||||
return &PasswordCipher{
|
||||
privateKey: privateKey,
|
||||
keyID: keyID,
|
||||
publicPEM: publicPEM,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *PasswordCipher) PublicKey() PasswordCipherPublicKey {
|
||||
if c == nil {
|
||||
return PasswordCipherPublicKey{}
|
||||
}
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return PasswordCipherPublicKey{
|
||||
KeyID: c.keyID,
|
||||
Algorithm: "RSA-OAEP-256",
|
||||
PublicKey: c.publicPEM,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *PasswordCipher) Decrypt(ciphertextB64, keyID string) (string, error) {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("password cipher is not configured")
|
||||
}
|
||||
c.mu.RLock()
|
||||
privateKey := c.privateKey
|
||||
expectedKeyID := c.keyID
|
||||
c.mu.RUnlock()
|
||||
|
||||
if strings.TrimSpace(keyID) != "" && strings.TrimSpace(keyID) != expectedKeyID {
|
||||
return "", fmt.Errorf("password cipher key id mismatch")
|
||||
}
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(ciphertextB64))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode encrypted password: %w", err)
|
||||
}
|
||||
plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt encrypted password: %w", err)
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func parseRSAPrivateKeyPEM(value string) (*rsa.PrivateKey, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return nil, fmt.Errorf("password cipher private key is required")
|
||||
}
|
||||
block, _ := pem.Decode([]byte(trimmed))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("password cipher private key is not PEM encoded")
|
||||
}
|
||||
|
||||
switch block.Type {
|
||||
case "RSA PRIVATE KEY":
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse pkcs1 rsa private key: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
case "PRIVATE KEY":
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse pkcs8 private key: %w", err)
|
||||
}
|
||||
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("private key is not RSA")
|
||||
}
|
||||
return rsaKey, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported private key PEM type %q", block.Type)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPasswordCipherDecryptsRSAOAEP256(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
}))
|
||||
|
||||
cipher, err := NewPasswordCipher(privatePEM, "test-key")
|
||||
require.NoError(t, err)
|
||||
|
||||
encrypted, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, &key.PublicKey, []byte("Admin@123"), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := cipher.Decrypt(base64.StdEncoding.EncodeToString(encrypted), "test-key")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Admin@123", got)
|
||||
}
|
||||
|
||||
func TestPasswordCipherRejectsWrongKeyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
privatePEM := string(pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
}))
|
||||
|
||||
cipher, err := NewPasswordCipher(privatePEM, "expected")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cipher.Decrypt("bad", "other")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "key id mismatch")
|
||||
}
|
||||
|
||||
func TestNewEphemeralPasswordCipherExposesPublicKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cipher, err := NewEphemeralPasswordCipher("ephemeral")
|
||||
require.NoError(t, err)
|
||||
|
||||
publicKey := cipher.PublicKey()
|
||||
require.Equal(t, "ephemeral", publicKey.KeyID)
|
||||
require.Equal(t, "RSA-OAEP-256", publicKey.Algorithm)
|
||||
require.Contains(t, publicKey.PublicKey, "BEGIN PUBLIC KEY")
|
||||
}
|
||||
@@ -44,9 +44,15 @@ type Config struct {
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
TrustedProxies []string `mapstructure:"trusted_proxies"`
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
TrustedProxies []string `mapstructure:"trusted_proxies"`
|
||||
AllowedOrigins []string `mapstructure:"allowed_origins"`
|
||||
SecurityHeaders SecurityHeaders `mapstructure:"security_headers"`
|
||||
}
|
||||
|
||||
type SecurityHeaders struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -296,13 +302,20 @@ type JWTConfig struct {
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
LoginGuard LoginGuardConfig `mapstructure:"login_guard"`
|
||||
LoginGuard LoginGuardConfig `mapstructure:"login_guard"`
|
||||
PasswordCipher PasswordCipherConfig `mapstructure:"password_cipher"`
|
||||
}
|
||||
|
||||
type LoginGuardConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
}
|
||||
|
||||
type PasswordCipherConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
KeyID string `mapstructure:"key_id"`
|
||||
PrivateKeyPEM string `mapstructure:"private_key_pem"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
Format string `mapstructure:"format"`
|
||||
@@ -498,6 +511,11 @@ func decodeResolvedConfig(configFile, localConfigFile string) (*Config, error) {
|
||||
}
|
||||
|
||||
func applyConfigDefaults(settings map[string]any) {
|
||||
serverSettings := ensureMapSetting(settings, "server")
|
||||
securityHeadersSettings := ensureMapSetting(serverSettings, "security_headers")
|
||||
if _, ok := securityHeadersSettings["enabled"]; !ok {
|
||||
securityHeadersSettings["enabled"] = true
|
||||
}
|
||||
cacheSettings := ensureMapSetting(settings, "cache")
|
||||
if _, ok := cacheSettings["metrics_enabled"]; !ok {
|
||||
cacheSettings["metrics_enabled"] = true
|
||||
@@ -540,11 +558,12 @@ func NormalizeServerConfig(cfg *ServerConfig) {
|
||||
return
|
||||
}
|
||||
cfg.Mode = strings.TrimSpace(cfg.Mode)
|
||||
cfg.AllowedOrigins = compactStringSlice(cfg.AllowedOrigins)
|
||||
if cfg.TrustedProxies == nil {
|
||||
cfg.TrustedProxies = DefaultTrustedProxies()
|
||||
return
|
||||
} else {
|
||||
cfg.TrustedProxies = compactStringSlice(cfg.TrustedProxies)
|
||||
}
|
||||
cfg.TrustedProxies = compactStringSlice(cfg.TrustedProxies)
|
||||
}
|
||||
|
||||
func compactStringSlice(values []string) []string {
|
||||
@@ -964,6 +983,21 @@ func applyEnvOverrides(cfg *Config) {
|
||||
if trustedProxies, ok := lookupNonEmptyEnv("SERVER_TRUSTED_PROXIES"); ok {
|
||||
cfg.Server.TrustedProxies = strings.Split(trustedProxies, ",")
|
||||
}
|
||||
if origins, ok := lookupNonEmptyEnv("SERVER_ALLOWED_ORIGINS"); ok {
|
||||
cfg.Server.AllowedOrigins = strings.Split(origins, ",")
|
||||
}
|
||||
if enabled, ok := lookupBoolEnv("SERVER_SECURITY_HEADERS_ENABLED"); ok {
|
||||
cfg.Server.SecurityHeaders.Enabled = enabled
|
||||
}
|
||||
if enabled, ok := lookupBoolEnv("AUTH_PASSWORD_CIPHER_ENABLED"); ok {
|
||||
cfg.Auth.PasswordCipher.Enabled = enabled
|
||||
}
|
||||
if keyID, ok := lookupNonEmptyEnv("AUTH_PASSWORD_CIPHER_KEY_ID"); ok {
|
||||
cfg.Auth.PasswordCipher.KeyID = keyID
|
||||
}
|
||||
if privateKey, ok := lookupNonEmptyEnv("AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM"); ok {
|
||||
cfg.Auth.PasswordCipher.PrivateKeyPEM = privateKey
|
||||
}
|
||||
if path, ok := lookupNonEmptyEnv("IP_REGION_V4_XDB_PATH"); ok {
|
||||
cfg.IPRegion.V4XDBPath = path
|
||||
}
|
||||
|
||||
@@ -168,6 +168,32 @@ server:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesServerSecurityDefaultsAndOriginOverrides(t *testing.T) {
|
||||
t.Setenv("SERVER_ALLOWED_ORIGINS", "https://admin.example.com, https://ops.example.com")
|
||||
|
||||
configPath := writeTestConfig(t, `
|
||||
server: {}
|
||||
`)
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if !cfg.Server.SecurityHeaders.Enabled {
|
||||
t.Fatalf("expected security headers enabled by default")
|
||||
}
|
||||
wantOrigins := []string{"https://admin.example.com", "https://ops.example.com"}
|
||||
if len(cfg.Server.AllowedOrigins) != len(wantOrigins) {
|
||||
t.Fatalf("allowed origins = %#v, want %#v", cfg.Server.AllowedOrigins, wantOrigins)
|
||||
}
|
||||
for i := range wantOrigins {
|
||||
if cfg.Server.AllowedOrigins[i] != wantOrigins[i] {
|
||||
t.Fatalf("allowed origins = %#v, want %#v", cfg.Server.AllowedOrigins, wantOrigins)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesSchedulerHTTPDefaultsAndDisableSentinel(t *testing.T) {
|
||||
defaultConfigPath := writeTestConfig(t, `
|
||||
scheduler: {}
|
||||
@@ -358,6 +384,34 @@ jwt:
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPrefersEnvPasswordCipherSettings(t *testing.T) {
|
||||
t.Setenv("AUTH_PASSWORD_CIPHER_ENABLED", "true")
|
||||
t.Setenv("AUTH_PASSWORD_CIPHER_KEY_ID", "env-key")
|
||||
t.Setenv("AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM", "pem-value")
|
||||
|
||||
configPath := writeTestConfig(t, `
|
||||
auth:
|
||||
password_cipher:
|
||||
enabled: false
|
||||
key_id: config-key
|
||||
private_key_pem: config-pem
|
||||
`)
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if !cfg.Auth.PasswordCipher.Enabled {
|
||||
t.Fatalf("expected env password cipher enabled")
|
||||
}
|
||||
if cfg.Auth.PasswordCipher.KeyID != "env-key" {
|
||||
t.Fatalf("expected env key id, got %q", cfg.Auth.PasswordCipher.KeyID)
|
||||
}
|
||||
if cfg.Auth.PasswordCipher.PrivateKeyPEM != "pem-value" {
|
||||
t.Fatalf("expected env private key pem, got %q", cfg.Auth.PasswordCipher.PrivateKeyPEM)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesMembershipDefaults(t *testing.T) {
|
||||
configPath := writeTestConfig(t, `
|
||||
membership: {}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -19,10 +19,11 @@ var routeDocs = map[string]routeDoc{
|
||||
"GET /api/health/ready": {"就绪探针", "Kubernetes readiness 探针,依赖(数据库等)就绪后返回 200。"},
|
||||
|
||||
// --- Auth ---
|
||||
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
|
||||
"POST /api/auth/refresh": {"刷新访问令牌", "用 refresh token 换取新的 access token。"},
|
||||
"GET /api/auth/me": {"当前登录用户", "返回当前 JWT 对应的用户、租户、Workspace 等会话信息。"},
|
||||
"POST /api/auth/logout": {"退出登录", "吊销当前会话,使其 access/refresh token 立即失效。"},
|
||||
"GET /api/auth/password-key": {"登录密码加密公钥", "返回登录密码前端加密使用的临时公钥与 key_id。"},
|
||||
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
|
||||
"POST /api/auth/refresh": {"刷新访问令牌", "用 refresh token 换取新的 access token。"},
|
||||
"GET /api/auth/me": {"当前登录用户", "返回当前 JWT 对应的用户、租户、Workspace 等会话信息。"},
|
||||
"POST /api/auth/logout": {"退出登录", "吊销当前会话,使其 access/refresh token 立即失效。"},
|
||||
|
||||
// --- Public 资源 ---
|
||||
"GET /api/public/assets/:token": {"对外访问签名资源", "通过签名 token 直接访问对象存储中的图片/附件,无需登录。"},
|
||||
|
||||
@@ -498,7 +498,8 @@ func errorResponse(description string) map[string]any {
|
||||
}
|
||||
|
||||
func securityForPath(path string) []map[string][]string {
|
||||
if path == "/api/auth/login" || path == "/api/auth/refresh" ||
|
||||
if path == "/api/auth/password-key" ||
|
||||
path == "/api/auth/login" || path == "/api/auth/refresh" ||
|
||||
strings.HasPrefix(path, "/api/health/") ||
|
||||
strings.HasPrefix(path, "/api/public/") {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user