fix(security): 修复多个MED安全问题
MED-03: 数据库密码明文配置 - 在 gateway/internal/config/config.go 中添加 AES-GCM 加密支持 - 添加 EncryptedPassword 字段和 GetPassword() 方法 - 支持密码加密存储和解密获取 MED-04: 审计日志Route字段未验证 - 在 supply-api/internal/middleware/auth.go 中添加 sanitizeRoute() 函数 - 防止路径遍历攻击(.., ./, \ 等) - 防止 null 字节和换行符注入 MED-05: 请求体大小无限制 - 在 gateway/internal/handler/handler.go 中添加 MaxRequestBytes 限制(1MB) - 添加 maxBytesReader 包装器 - 添加 COMMON_REQUEST_TOO_LARGE 错误码 MED-08: 缺少CORS配置 - 创建 gateway/internal/middleware/cors.go CORS 中间件 - 支持来源域名白名单、通配符子域名 - 支持预检请求处理和凭证配置 MED-09: 错误信息泄露内部细节 - 添加测试验证 JWT 错误消息不包含敏感信息 - 当前实现已正确返回安全错误消息 MED-10: 数据库凭证日志泄露风险 - 在 gateway/cmd/gateway/main.go 中使用 GetPassword() 代替 Password - 避免 DSN 中明文密码被记录 MED-11: 缺少Token刷新机制 - 当前 verifyToken() 已正确验证 token 过期时间 - Token 刷新需要额外的 refresh token 基础设施 MED-12: 缺少暴力破解保护 - 添加 BruteForceProtection 结构体 - 支持最大尝试次数和锁定时长配置 - 在 TokenVerifyMiddleware 中集成暴力破解保护
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -38,6 +39,7 @@ type AuthMiddleware struct {
|
||||
tokenCache *TokenCache
|
||||
tokenBackend TokenStatusBackend
|
||||
auditEmitter AuditEmitter
|
||||
bruteForce *BruteForceProtection // 暴力破解保护
|
||||
}
|
||||
|
||||
// TokenStatusBackend Token状态后端查询接口
|
||||
@@ -75,6 +77,79 @@ func NewAuthMiddleware(config AuthConfig, tokenCache *TokenCache, tokenBackend T
|
||||
}
|
||||
}
|
||||
|
||||
// BruteForceProtection 暴力破解保护
|
||||
// MED-12: 防止暴力破解攻击,限制登录尝试次数
|
||||
type BruteForceProtection struct {
|
||||
maxAttempts int
|
||||
lockoutDuration time.Duration
|
||||
attempts map[string]*attemptRecord
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type attemptRecord struct {
|
||||
count int
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
// NewBruteForceProtection 创建暴力破解保护
|
||||
// maxAttempts: 最大失败尝试次数
|
||||
// lockoutDuration: 锁定时长
|
||||
func NewBruteForceProtection(maxAttempts int, lockoutDuration time.Duration) *BruteForceProtection {
|
||||
return &BruteForceProtection{
|
||||
maxAttempts: maxAttempts,
|
||||
lockoutDuration: lockoutDuration,
|
||||
attempts: make(map[string]*attemptRecord),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordFailedAttempt 记录失败尝试
|
||||
func (b *BruteForceProtection) RecordFailedAttempt(ip string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
record, exists := b.attempts[ip]
|
||||
if !exists {
|
||||
record = &attemptRecord{}
|
||||
b.attempts[ip] = record
|
||||
}
|
||||
|
||||
record.count++
|
||||
if record.count >= b.maxAttempts {
|
||||
record.lockedUntil = time.Now().Add(b.lockoutDuration)
|
||||
}
|
||||
}
|
||||
|
||||
// IsLocked 检查IP是否被锁定
|
||||
func (b *BruteForceProtection) IsLocked(ip string) (bool, time.Duration) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
record, exists := b.attempts[ip]
|
||||
if !exists {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
if record.count >= b.maxAttempts && record.lockedUntil.After(time.Now()) {
|
||||
remaining := time.Until(record.lockedUntil)
|
||||
return true, remaining
|
||||
}
|
||||
|
||||
// 如果锁定已过期,重置计数
|
||||
if record.lockedUntil.Before(time.Now()) {
|
||||
record.count = 0
|
||||
record.lockedUntil = time.Time{}
|
||||
}
|
||||
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// Reset 重置IP的尝试记录
|
||||
func (b *BruteForceProtection) Reset(ip string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
delete(b.attempts, ip)
|
||||
}
|
||||
|
||||
// QueryKeyRejectMiddleware 拒绝外部query key入站
|
||||
// 对应M-016指标
|
||||
func (m *AuthMiddleware) QueryKeyRejectMiddleware(next http.Handler) http.Handler {
|
||||
@@ -92,7 +167,7 @@ func (m *AuthMiddleware) QueryKeyRejectMiddleware(next http.Handler) http.Handle
|
||||
m.auditEmitter.Emit(r.Context(), AuditEvent{
|
||||
EventName: "token.query_key.rejected",
|
||||
RequestID: getRequestID(r),
|
||||
Route: r.URL.Path,
|
||||
Route: sanitizeRoute(r.URL.Path),
|
||||
ResultCode: "QUERY_KEY_NOT_ALLOWED",
|
||||
ClientIP: getClientIP(r),
|
||||
CreatedAt: time.Now(),
|
||||
@@ -115,7 +190,7 @@ func (m *AuthMiddleware) QueryKeyRejectMiddleware(next http.Handler) http.Handle
|
||||
m.auditEmitter.Emit(r.Context(), AuditEvent{
|
||||
EventName: "token.query_key.rejected",
|
||||
RequestID: getRequestID(r),
|
||||
Route: r.URL.Path,
|
||||
Route: sanitizeRoute(r.URL.Path),
|
||||
ResultCode: "QUERY_KEY_NOT_ALLOWED",
|
||||
ClientIP: getClientIP(r),
|
||||
CreatedAt: time.Now(),
|
||||
@@ -143,7 +218,7 @@ func (m *AuthMiddleware) BearerExtractMiddleware(next http.Handler) http.Handler
|
||||
m.auditEmitter.Emit(r.Context(), AuditEvent{
|
||||
EventName: "token.authn.fail",
|
||||
RequestID: getRequestID(r),
|
||||
Route: r.URL.Path,
|
||||
Route: sanitizeRoute(r.URL.Path),
|
||||
ResultCode: "AUTH_MISSING_BEARER",
|
||||
ClientIP: getClientIP(r),
|
||||
CreatedAt: time.Now(),
|
||||
@@ -175,17 +250,33 @@ func (m *AuthMiddleware) BearerExtractMiddleware(next http.Handler) http.Handler
|
||||
}
|
||||
|
||||
// TokenVerifyMiddleware 校验JWT Token
|
||||
// MED-12: 添加暴力破解保护
|
||||
func (m *AuthMiddleware) TokenVerifyMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// MED-12: 检查暴力破解保护
|
||||
if m.bruteForce != nil {
|
||||
clientIP := getClientIP(r)
|
||||
if locked, remaining := m.bruteForce.IsLocked(clientIP); locked {
|
||||
writeAuthError(w, http.StatusTooManyRequests, "AUTH_ACCOUNT_LOCKED",
|
||||
fmt.Sprintf("too many failed attempts, try again in %v", remaining))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tokenString := r.Context().Value(bearerTokenKey).(string)
|
||||
|
||||
claims, err := m.verifyToken(tokenString)
|
||||
if err != nil {
|
||||
// MED-12: 记录失败尝试
|
||||
if m.bruteForce != nil {
|
||||
m.bruteForce.RecordFailedAttempt(getClientIP(r))
|
||||
}
|
||||
|
||||
if m.auditEmitter != nil {
|
||||
m.auditEmitter.Emit(r.Context(), AuditEvent{
|
||||
EventName: "token.authn.fail",
|
||||
RequestID: getRequestID(r),
|
||||
Route: r.URL.Path,
|
||||
Route: sanitizeRoute(r.URL.Path),
|
||||
ResultCode: "AUTH_INVALID_TOKEN",
|
||||
ClientIP: getClientIP(r),
|
||||
CreatedAt: time.Now(),
|
||||
@@ -206,7 +297,7 @@ func (m *AuthMiddleware) TokenVerifyMiddleware(next http.Handler) http.Handler {
|
||||
RequestID: getRequestID(r),
|
||||
TokenID: claims.ID,
|
||||
SubjectID: claims.SubjectID,
|
||||
Route: r.URL.Path,
|
||||
Route: sanitizeRoute(r.URL.Path),
|
||||
ResultCode: "AUTH_TOKEN_INACTIVE",
|
||||
ClientIP: getClientIP(r),
|
||||
CreatedAt: time.Now(),
|
||||
@@ -229,7 +320,7 @@ func (m *AuthMiddleware) TokenVerifyMiddleware(next http.Handler) http.Handler {
|
||||
RequestID: getRequestID(r),
|
||||
TokenID: claims.ID,
|
||||
SubjectID: claims.SubjectID,
|
||||
Route: r.URL.Path,
|
||||
Route: sanitizeRoute(r.URL.Path),
|
||||
ResultCode: "OK",
|
||||
ClientIP: getClientIP(r),
|
||||
CreatedAt: time.Now(),
|
||||
@@ -259,7 +350,7 @@ func (m *AuthMiddleware) ScopeRoleAuthzMiddleware(requiredScope string) func(htt
|
||||
RequestID: getRequestID(r),
|
||||
TokenID: claims.ID,
|
||||
SubjectID: claims.SubjectID,
|
||||
Route: r.URL.Path,
|
||||
Route: sanitizeRoute(r.URL.Path),
|
||||
ResultCode: "AUTH_SCOPE_DENIED",
|
||||
ClientIP: getClientIP(r),
|
||||
CreatedAt: time.Now(),
|
||||
@@ -413,6 +504,42 @@ func getClientIP(r *http.Request) string {
|
||||
return addr
|
||||
}
|
||||
|
||||
// sanitizeRoute 清理路由字符串,防止路径遍历和其他安全问题
|
||||
// MED-04: 审计日志Route字段需要验证以防止路径遍历攻击
|
||||
func sanitizeRoute(route string) string {
|
||||
if route == "" {
|
||||
return route
|
||||
}
|
||||
|
||||
// 检查是否包含路径遍历模式
|
||||
// 路径遍历通常包含 .. 或 . 后面跟着 / 或 \
|
||||
for i := 0; i < len(route)-1; i++ {
|
||||
if route[i] == '.' {
|
||||
next := route[i+1]
|
||||
if next == '.' || next == '/' || next == '\\' {
|
||||
// 检测到路径遍历模式,返回安全的替代值
|
||||
return "/sanitized"
|
||||
}
|
||||
}
|
||||
// 检查反斜杠(Windows路径遍历)
|
||||
if route[i] == '\\' {
|
||||
return "/sanitized"
|
||||
}
|
||||
}
|
||||
|
||||
// 检查null字节
|
||||
if strings.Contains(route, "\x00") {
|
||||
return "/sanitized"
|
||||
}
|
||||
|
||||
// 检查换行符
|
||||
if strings.Contains(route, "\n") || strings.Contains(route, "\r") {
|
||||
return "/sanitized"
|
||||
}
|
||||
|
||||
return route
|
||||
}
|
||||
|
||||
// containsScope 检查scope列表是否包含目标scope
|
||||
func containsScope(scopes []string, target string) bool {
|
||||
for _, scope := range scopes {
|
||||
|
||||
32
supply-api/internal/middleware/auth_route_test.go
Normal file
32
supply-api/internal/middleware/auth_route_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeRoute(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"/api/v1/test", "/api/v1/test"},
|
||||
{"/", "/"},
|
||||
{"", ""},
|
||||
{"/api/../../../etc/passwd", "/sanitized"},
|
||||
{"../../etc/passwd", "/sanitized"},
|
||||
{"/api/v1/../admin", "/sanitized"},
|
||||
{"/api\\v1\\admin", "/sanitized"},
|
||||
{"/api/v1" + string(rune(0)) + "/admin", "/sanitized"},
|
||||
{"/api/v1\n/admin", "/sanitized"},
|
||||
{"/api/v1\r/admin", "/sanitized"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := sanitizeRoute(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("sanitizeRoute(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
221
supply-api/internal/middleware/auth_security_test.go
Normal file
221
supply-api/internal/middleware/auth_security_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// TestMED09_ErrorMessageShouldNotLeakInternalDetails verifies that internal error details
|
||||
// are not exposed to clients
|
||||
func TestMED09_ErrorMessageShouldNotLeakInternalDetails(t *testing.T) {
|
||||
secretKey := "test-secret-key-12345678901234567890"
|
||||
issuer := "test-issuer"
|
||||
|
||||
// Create middleware with a token that will cause an error
|
||||
middleware := &AuthMiddleware{
|
||||
config: AuthConfig{
|
||||
SecretKey: secretKey,
|
||||
Issuer: issuer,
|
||||
},
|
||||
tokenCache: NewTokenCache(),
|
||||
// Intentionally no tokenBackend - to simulate error scenario
|
||||
}
|
||||
|
||||
nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Next handler should not be called for auth failures
|
||||
})
|
||||
|
||||
handler := middleware.TokenVerifyMiddleware(nextHandler)
|
||||
|
||||
// Create a token that will fail verification
|
||||
// Using wrong signing key to simulate internal error
|
||||
claims := TokenClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
Subject: "subject:1",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
SubjectID: "subject:1",
|
||||
Role: "owner",
|
||||
Scope: []string{"read", "write"},
|
||||
TenantID: 1,
|
||||
}
|
||||
|
||||
// Sign with wrong key to cause error
|
||||
wrongKeyToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
wrongKeyTokenString, _ := wrongKeyToken.SignedString([]byte("wrong-secret-key-that-will-cause-error"))
|
||||
|
||||
// Create request with Bearer token
|
||||
req := httptest.NewRequest("POST", "/api/v1/test", nil)
|
||||
ctx := context.WithValue(req.Context(), bearerTokenKey, wrongKeyTokenString)
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
// Should return 401
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
// Check error map
|
||||
errorMap, ok := resp["error"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("response should contain error object")
|
||||
}
|
||||
|
||||
message, ok := errorMap["message"].(string)
|
||||
if !ok {
|
||||
t.Fatal("error should contain message")
|
||||
}
|
||||
|
||||
// The error message should NOT contain internal details like:
|
||||
// - "crypto" or "cipher" related terms (implementation details)
|
||||
// - "secret", "key", "password" (credential info)
|
||||
// - "SQL", "database", "connection" (database details)
|
||||
// - File paths or line numbers
|
||||
|
||||
internalKeywords := []string{
|
||||
"crypto/",
|
||||
"/go/src/",
|
||||
".go:",
|
||||
"sql",
|
||||
"database",
|
||||
"connection",
|
||||
"pq",
|
||||
"pgx",
|
||||
}
|
||||
|
||||
for _, keyword := range internalKeywords {
|
||||
if strings.Contains(strings.ToLower(message), keyword) {
|
||||
t.Errorf("MED-09: error message should NOT contain internal details like '%s'. Got: %s", keyword, message)
|
||||
}
|
||||
}
|
||||
|
||||
// The message should be a generic user-safe message
|
||||
if message == "" {
|
||||
t.Error("error message should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMED09_TokenVerifyErrorShouldBeSanitized tests that token verification errors
|
||||
// don't leak sensitive information
|
||||
func TestMED09_TokenVerifyErrorShouldBeSanitized(t *testing.T) {
|
||||
secretKey := "test-secret-key-12345678901234567890"
|
||||
issuer := "test-issuer"
|
||||
|
||||
// Create middleware
|
||||
m := &AuthMiddleware{
|
||||
config: AuthConfig{
|
||||
SecretKey: secretKey,
|
||||
Issuer: issuer,
|
||||
},
|
||||
}
|
||||
|
||||
// Test with various invalid tokens
|
||||
invalidTokens := []struct {
|
||||
name string
|
||||
token string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "completely invalid token",
|
||||
token: "not.a.valid.token.at.all",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "expired token",
|
||||
token: createExpiredTestToken(secretKey, issuer),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "wrong issuer token",
|
||||
token: createWrongIssuerTestToken(secretKey, issuer),
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range invalidTokens {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := m.verifyToken(tt.token)
|
||||
|
||||
if tt.expectError && err == nil {
|
||||
t.Error("expected error but got nil")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
|
||||
// Internal error messages should be sanitized
|
||||
// They should NOT contain sensitive keywords
|
||||
sensitiveKeywords := []string{
|
||||
"secret",
|
||||
"password",
|
||||
"credential",
|
||||
"/",
|
||||
".go:",
|
||||
}
|
||||
|
||||
for _, keyword := range sensitiveKeywords {
|
||||
if strings.Contains(strings.ToLower(errMsg), keyword) {
|
||||
t.Errorf("MED-09: internal error should NOT contain '%s'. Got: %s", keyword, errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create expired token
|
||||
func createExpiredTestToken(secretKey, issuer string) string {
|
||||
claims := TokenClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
Subject: "subject:1",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // Expired
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
|
||||
},
|
||||
SubjectID: "subject:1",
|
||||
Role: "owner",
|
||||
Scope: []string{"read", "write"},
|
||||
TenantID: 1,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, _ := token.SignedString([]byte(secretKey))
|
||||
return tokenString
|
||||
}
|
||||
|
||||
// Helper function to create wrong issuer token
|
||||
func createWrongIssuerTestToken(secretKey, issuer string) string {
|
||||
claims := TokenClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "wrong-issuer",
|
||||
Subject: "subject:1",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
SubjectID: "subject:1",
|
||||
Role: "owner",
|
||||
Scope: []string{"read", "write"},
|
||||
TenantID: 1,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, _ := token.SignedString([]byte(secretKey))
|
||||
return tokenString
|
||||
}
|
||||
Reference in New Issue
Block a user