fix: harden jwt locks sse and storage safety

This commit is contained in:
杭州明婳科技
2026-07-07 00:13:56 +08:00
parent 86e126c42b
commit 2d3dde99c2
9 changed files with 414 additions and 63 deletions
+32 -7
View File
@@ -15,6 +15,12 @@ var (
ErrLockNotHeld = errors.New("锁未被当前客户端持有")
ErrLockExpired = errors.New("锁已过期")
ErrRedisNotReady = errors.New("Redis 未初始化")
// ErrLockNotAcquired 表示锁被其它客户端持有,业务函数未执行。
ErrLockNotAcquired = errors.New("未获取到锁")
// ErrInvalidLockTTL 表示锁 TTL 小于 Redis PX 支持的 1ms 粒度或非正。
ErrInvalidLockTTL = errors.New("锁 TTL 必须大于等于 1ms")
// ErrInvalidLockRetryInterval 表示重试或续期间隔非法。
ErrInvalidLockRetryInterval = errors.New("锁重试/续期间隔必须大于 0")
// ErrLockUnexpectedResult Lua 脚本返回了非预期的结果类型(C1b:裸类型断言防护)。
ErrLockUnexpectedResult = errors.New("锁脚本返回非预期结果")
)
@@ -29,6 +35,13 @@ func toInt64(v any) (int64, error) {
return n, nil
}
func ttlMillis(ttl time.Duration) (int64, error) {
if ttl < time.Millisecond {
return 0, ErrInvalidLockTTL
}
return int64(ttl / time.Millisecond), nil
}
// LockToken 锁令牌(用于安全释放锁)。
//
// 安全说明(C1d 设计局限):Token 是随机 UUID(非单调递增的 fencing token)。
@@ -88,7 +101,10 @@ func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, er
}
token := utils.UUID()
ttlMs := int64(ttl / time.Millisecond)
ttlMs, err := ttlMillis(ttl)
if err != nil {
return nil, err
}
result, err := rdb.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
if err != nil {
@@ -148,7 +164,7 @@ func Unlock(ctx context.Context, token *LockToken) error {
func UnlockByKey(ctx context.Context, key string) error {
rdb := database.GetRedis()
if rdb == nil {
return nil
return ErrRedisNotReady
}
return rdb.Del(ctx, key).Err()
}
@@ -165,7 +181,10 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error
return ErrLockNotHeld
}
ttlMs := int64(ttl / time.Millisecond)
ttlMs, err := ttlMillis(ttl)
if err != nil {
return err
}
result, err := rdb.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
if err != nil {
@@ -185,6 +204,9 @@ func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error
// TryLock 尝试获取锁,失败时等待重试。重试等待响应 ctx 取消(C1c 修复)。
func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval time.Duration, maxRetry int) (*LockToken, error) {
if retryInterval <= 0 {
return nil, ErrInvalidLockRetryInterval
}
for i := 0; i < maxRetry; i++ {
token, err := NewLock(ctx, key, ttl)
if err != nil {
@@ -200,7 +222,7 @@ func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval t
case <-time.After(retryInterval):
}
}
return nil, nil
return nil, ErrLockNotAcquired
}
// WithLock 使用分布式锁执行函数(自动管理锁)。
@@ -215,7 +237,7 @@ func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() erro
return err
}
if token == nil {
return nil // 未获取到锁,跳过执行
return ErrLockNotAcquired
}
defer func() {
unlockCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@@ -235,12 +257,15 @@ func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() erro
// ctx 取消或 ExtendLock 失败时 done 已 closed,父再 send 即 panicUnlock 不执行、锁泄漏到 TTL)。
// Unlock 用 context.Background() 派生超时,避免原 ctx 已取消致 Unlock 失败再泄漏。
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func() error) error {
if extendInterval <= 0 {
return ErrInvalidLockRetryInterval
}
token, err := NewLock(ctx, key, initialTTL)
if err != nil {
return err
}
if token == nil {
return nil // 未获取到锁,跳过执行
return ErrLockNotAcquired
}
// 父关停信号(仅父 close)与子 ack 信号(仅子 close)。
@@ -392,4 +417,4 @@ func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) er
return nil
}
return rdb.Set(ctx, key, value, ttl).Err()
}
}
+79
View File
@@ -299,3 +299,82 @@ func TestTryLockRespectsCtxCancel(t *testing.T) {
t.Errorf("TryLock took %v, should return shortly after ctx cancel (C1c: time.Sleep ignored ctx)", elapsed)
}
}
func TestLockRejectsSubMillisecondTTL(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
if token, err := cache.NewLock(ctx, "m10:ttl", time.Nanosecond); !errors.Is(err, cache.ErrInvalidLockTTL) || token != nil {
t.Fatalf("NewLock sub-ms ttl token=%v err=%v, want ErrInvalidLockTTL", token, err)
}
token, err := cache.NewLock(ctx, "m10:ttl-valid", time.Second)
if err != nil || token == nil {
t.Fatalf("NewLock valid setup token=%v err=%v", token, err)
}
defer cache.Unlock(ctx, token)
if err := cache.ExtendLock(ctx, token, 0); !errors.Is(err, cache.ErrInvalidLockTTL) {
t.Errorf("ExtendLock zero ttl err = %v, want ErrInvalidLockTTL", err)
}
}
func TestWithLockAutoExtendRejectsNonPositiveInterval(t *testing.T) {
setupMiniRedis(t)
called := false
err := cache.WithLockAutoExtend(context.Background(), "m10:interval", time.Second, 0, func() error {
called = true
return nil
})
if !errors.Is(err, cache.ErrInvalidLockRetryInterval) {
t.Fatalf("WithLockAutoExtend zero interval err = %v, want ErrInvalidLockRetryInterval", err)
}
if called {
t.Fatal("WithLockAutoExtend should not run fn with invalid interval")
}
}
func TestWithLockHeldReturnsErrLockNotAcquired(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "m10:not-acquired"
holder, err := cache.NewLock(ctx, key, time.Second)
if err != nil || holder == nil {
t.Fatalf("setup holder token=%v err=%v", holder, err)
}
defer cache.Unlock(ctx, holder)
called := false
err = cache.WithLock(ctx, key, time.Second, func() error {
called = true
return nil
})
if !errors.Is(err, cache.ErrLockNotAcquired) {
t.Fatalf("WithLock held err = %v, want ErrLockNotAcquired", err)
}
if called {
t.Fatal("WithLock should not run fn when lock is held")
}
}
func TestTryLockExhaustedReturnsErrLockNotAcquired(t *testing.T) {
setupMiniRedis(t)
ctx := context.Background()
key := "m10:try-exhausted"
holder, err := cache.NewLock(ctx, key, time.Second)
if err != nil || holder == nil {
t.Fatalf("setup holder token=%v err=%v", holder, err)
}
defer cache.Unlock(ctx, holder)
token, err := cache.TryLock(ctx, key, time.Second, time.Millisecond, 2)
if !errors.Is(err, cache.ErrLockNotAcquired) {
t.Fatalf("TryLock exhausted err = %v, want ErrLockNotAcquired", err)
}
if token != nil {
t.Fatalf("TryLock exhausted token = %v, want nil", token)
}
}
+5 -1
View File
@@ -51,6 +51,10 @@ func TestLockErrors(t *testing.T) {
if ttl != 0 {
t.Error("GetLockTTL should return 0 without Redis")
}
if err := cache.UnlockByKey(ctx, "test_key"); !errors.Is(err, cache.ErrRedisNotReady) {
t.Errorf("UnlockByKey without Redis should return ErrRedisNotReady, got %v", err)
}
}
func TestIncrDecr(t *testing.T) {
@@ -146,4 +150,4 @@ func TestKFunctions(t *testing.T) {
if sessionKey == "" {
t.Error("KSession should not return empty string")
}
}
}
+1 -1
View File
@@ -298,7 +298,7 @@ func UnzipWithOptions(zipPath, dstDir string, opts DecompressOptions) error {
// 用绝对路径作目标锚定根,避免相对路径 + `..` 组合绕过前缀校验(C5a)。
absDst, err := filepath.Abs(dstDir)
if err != nil {
absDst = dstDir
return fmt.Errorf("resolve unzip destination failed: %w", err)
}
absDst = filepath.Clean(absDst)
+77 -42
View File
@@ -53,6 +53,10 @@ var (
// 本实现仅支持 HMAC 族(HS256/HS384/HS512);RS256 等非对称算法暂不支持,
// 不再静默回退 HS256——避免用户误以为在用非对称算法、实则 HMAC,并助长算法混淆攻击。
ErrUnsupportedAlgorithm = errors.New("jwt: 不支持的签名算法(仅支持 HS256/HS384/HS512")
// ErrInvalidExpiry 过期时间非法。签发非正过期时间的 token 会立即失效或产生不可预期会话语义。
ErrInvalidExpiry = errors.New("jwt: 过期时间必须大于 0")
// ErrEmptyJTI 空 JTI 无法匹配任何正常 token,却会写入 jwt_bl: 这种永不命中的黑名单键。
ErrEmptyJTI = errors.New("jwt: jti 不能为空")
)
// validMethods 允许的签名算法名(HMAC 族)。ParseWithClaims 传 jwt.WithValidMethods 固定算法,
@@ -125,6 +129,10 @@ func blacklistCtx() (context.Context, context.CancelFunc) {
// 无 Redis 时返回 ErrBlacklistUnavailableC9a 修复):让调用方(RefreshToken/InvalidateToken
// 感知黑名单不可用并 fail-closed,避免无 Redis 时静默成功致撤销失效。
func (tb *TokenBlacklist) Add(jti string, expiry time.Time) error {
if strings.TrimSpace(jti) == "" {
return ErrEmptyJTI
}
client := tb.redisClient()
if client == nil {
// Redis 未启用,黑名单不可用——fail-closed 让调用方决策。
@@ -230,7 +238,17 @@ func (m *Manager) Blacklist() *TokenBlacklist {
// GenerateToken 生成 JWT Token
func GenerateToken(userID uint, username, role, userType string) (string, error) {
cfg := config.Get()
var expiry time.Duration
if cfg != nil {
expiry = cfg.JWT.Expire
}
return generateTokenWithExpiry(cfg, userID, username, role, userType, expiry)
}
func generateTokenWithExpiry(cfg *config.Config, userID uint, username, role, userType string, expiry time.Duration) (string, error) {
if expiry <= 0 {
return "", ErrInvalidExpiry
}
// P0:先校验密钥非空与算法受支持(fail-closed)。secretKey 亦守卫 cfg==nil
// 通过后 cfg 保证非空,后续访问 cfg.JWT.* 安全。
key, err := secretKey(cfg)
@@ -255,7 +273,7 @@ func GenerateToken(userID uint, username, role, userType string) (string, error)
UserType: userType,
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.JWT.Expire)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiry)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: issuerOrDefault(cfg.JWT.Issuer),
@@ -270,39 +288,10 @@ func GenerateToken(userID uint, username, role, userType string) (string, error)
// GenerateTokenWithCustomExpiry 生成带自定义过期时间的 Token
func GenerateTokenWithCustomExpiry(userID uint, username, role, userType string, expireSeconds int) (string, error) {
cfg := config.Get()
// P0:先校验密钥非空与算法受支持(fail-closed,见 GenerateToken)。
key, err := secretKey(cfg)
if err != nil {
return "", err
if expireSeconds <= 0 {
return "", ErrInvalidExpiry
}
method, err := signingMethod(cfg.JWT.Algorithm)
if err != nil {
return "", err
}
jti, err := generateJTI()
if err != nil {
return "", err
}
claims := Claims{
UserID: userID,
Username: username,
Role: role,
UserType: userType,
JTI: jti,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireSeconds) * time.Second)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: issuerOrDefault(cfg.JWT.Issuer),
ID: jti,
},
}
token := jwt.NewWithClaims(method, claims)
return token.SignedString(key)
return generateTokenWithExpiry(cfg, userID, username, role, userType, time.Duration(expireSeconds)*time.Second)
}
// issuerOrDefault 返回配置的 issuer,未配置时回退 "xlgo"。
@@ -329,11 +318,33 @@ func signingMethod(algorithm string) (jwt.SigningMethod, error) {
}
}
func parseOptions(cfg *config.Config) []jwt.ParserOption {
issuer := "xlgo"
if cfg != nil {
issuer = issuerOrDefault(cfg.JWT.Issuer)
}
return []jwt.ParserOption{
jwt.WithValidMethods(validMethods),
jwt.WithIssuer(issuer),
}
}
func validateIssuer(cfg *config.Config, claims *Claims) error {
want := "xlgo"
if cfg != nil {
want = issuerOrDefault(cfg.JWT.Issuer)
}
if claims == nil || claims.Issuer != want {
return ErrTokenInvalid
}
return nil
}
// ParseToken 解析 JWT Token
func ParseToken(tokenString string) (*Claims, error) {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), jwt.WithValidMethods(validMethods))
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), parseOptions(cfg)...)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
@@ -363,15 +374,22 @@ func ParseToken(tokenString string) (*Claims, error) {
func InvalidateToken(tokenString string) error {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), jwt.WithValidMethods(validMethods))
opts := append(parseOptions(cfg), jwt.WithoutClaimsValidation())
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), opts...)
if err != nil {
// Token 无效或已过期,无需加入黑名单
// Token 签名/格式/issuer 无效,无需加入黑名单
return nil
}
if claims, ok := token.Claims.(*Claims); ok {
if claims.JTI != "" && claims.ExpiresAt != nil {
if err := validateIssuer(cfg, claims); err != nil {
return nil
}
if strings.TrimSpace(claims.JTI) == "" {
return ErrEmptyJTI
}
if claims.ExpiresAt != nil {
return currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time)
}
}
@@ -382,6 +400,9 @@ func InvalidateToken(tokenString string) error {
// InvalidateTokenByID 直接通过 JTI 使 Token 失效
// 参数: jti JWT IDexpiry 过期时间
func InvalidateTokenByID(jti string, expiry time.Time) error {
if strings.TrimSpace(jti) == "" {
return ErrEmptyJTI
}
return currentBlacklist().Add(jti, expiry)
}
@@ -397,13 +418,24 @@ func RefreshToken(tokenString string) (string, error) {
}
// 将旧 Token 加入黑名单;失败则不签发新 token(C9b:禁止吞 Add 错误)。
if claims.JTI != "" && claims.ExpiresAt != nil {
if strings.TrimSpace(claims.JTI) == "" {
return "", ErrEmptyJTI
}
if claims.ExpiresAt != nil {
if err := currentBlacklist().Add(claims.JTI, claims.ExpiresAt.Time); err != nil {
return "", fmt.Errorf("刷新令牌失败:旧令牌撤销失败: %w", err)
}
}
return GenerateToken(claims.UserID, claims.Username, claims.Role, claims.UserType)
cfg := config.Get()
var expiry time.Duration
if cfg != nil {
expiry = cfg.JWT.RefreshExpire
}
if expiry <= 0 && cfg != nil {
expiry = cfg.JWT.Expire
}
return generateTokenWithExpiry(cfg, claims.UserID, claims.Username, claims.Role, claims.UserType, expiry)
}
// GetJTI 从 Token 中提取 JTI(不验证签名)
@@ -431,16 +463,19 @@ func IsTokenRevoked(jti string) bool {
func GetClaimsFromToken(tokenString string) (*Claims, error) {
cfg := config.Get()
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg),
jwt.WithValidMethods(validMethods), jwt.WithoutClaimsValidation())
opts := append(parseOptions(cfg), jwt.WithoutClaimsValidation())
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, hmacKeyfunc(cfg), opts...)
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok {
if err := validateIssuer(cfg, claims); err != nil {
return nil, err
}
return claims, nil
}
return nil, ErrTokenInvalid
}
}
+128 -1
View File
@@ -17,7 +17,7 @@ func setupTestConfig() {
cfg := &config.Config{
JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012", // ≥32 字节
Expire: time.Hour, // 1小时
Expire: time.Hour, // 1小时
},
}
config.Set(cfg)
@@ -384,3 +384,130 @@ func TestSupportedAlgorithmHS384(t *testing.T) {
}
}
func TestParseTokenRejectsWrongIssuer(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Expire: time.Hour,
Issuer: "trusted-issuer",
}})
t.Cleanup(setupTestConfig)
claims := jwt.Claims{
UserID: 1,
Username: "attacker",
Role: "admin",
UserType: "super_admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "evil-issuer",
},
}
token := signTokenForTest(t, claims)
if _, err := jwt.ParseToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
t.Errorf("ParseToken wrong issuer err = %v, want ErrTokenInvalid", err)
}
if _, err := jwt.GetClaimsFromToken(token); !errors.Is(err, jwt.ErrTokenInvalid) {
t.Errorf("GetClaimsFromToken wrong issuer err = %v, want ErrTokenInvalid", err)
}
}
func TestRefreshTokenUsesRefreshExpire(t *testing.T) {
config.Set(&config.Config{JWT: config.JWTConfig{
Secret: "test-secret-key-1234567890123456789012",
Expire: time.Hour,
RefreshExpire: 4 * time.Hour,
}})
t.Cleanup(setupTestConfig)
setupMiniRedis(t)
token, err := jwt.GenerateToken(1, "testuser", "admin", "super_admin")
if err != nil {
t.Fatalf("GenerateToken: %v", err)
}
newToken, err := jwt.RefreshToken(token)
if err != nil {
t.Fatalf("RefreshToken: %v", err)
}
claims, err := jwt.ParseToken(newToken)
if err != nil {
t.Fatalf("ParseToken refreshed token: %v", err)
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl < 3*time.Hour || ttl > 4*time.Hour+time.Minute {
t.Errorf("refreshed token ttl = %v, want about 4h", ttl)
}
}
func TestGenerateTokenWithCustomExpiryRejectsNonPositive(t *testing.T) {
setupTestConfig()
for _, seconds := range []int{0, -1} {
if _, err := jwt.GenerateTokenWithCustomExpiry(1, "u", "admin", "admin", seconds); !errors.Is(err, jwt.ErrInvalidExpiry) {
t.Errorf("GenerateTokenWithCustomExpiry(%d) err = %v, want ErrInvalidExpiry", seconds, err)
}
}
}
func TestInvalidateTokenByIDRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
if err := jwt.InvalidateTokenByID("", time.Now().Add(time.Hour)); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("InvalidateTokenByID empty err = %v, want ErrEmptyJTI", err)
}
}
func TestRefreshTokenRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token := signTokenForTest(t, jwt.Claims{
UserID: 1,
Username: "legacy",
Role: "admin",
UserType: "admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "xlgo",
},
})
if _, err := jwt.RefreshToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("RefreshToken empty JTI err = %v, want ErrEmptyJTI", err)
}
}
func TestInvalidateTokenRejectsEmptyJTI(t *testing.T) {
setupTestConfig()
setupMiniRedis(t)
token := signTokenForTest(t, jwt.Claims{
UserID: 1,
Username: "legacy",
Role: "admin",
UserType: "admin",
RegisteredClaims: gojwt.RegisteredClaims{
ExpiresAt: gojwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: gojwt.NewNumericDate(time.Now()),
NotBefore: gojwt.NewNumericDate(time.Now()),
Issuer: "xlgo",
},
})
if err := jwt.InvalidateToken(token); !errors.Is(err, jwt.ErrEmptyJTI) {
t.Errorf("InvalidateToken empty JTI err = %v, want ErrEmptyJTI", err)
}
}
func signTokenForTest(t *testing.T, claims jwt.Claims) string {
t.Helper()
token, err := gojwt.NewWithClaims(gojwt.SigningMethodHS256, claims).SignedString([]byte("test-secret-key-1234567890123456789012"))
if err != nil {
t.Fatalf("sign token: %v", err)
}
return token
}
+21 -2
View File
@@ -14,12 +14,16 @@ package sse
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
var ErrInvalidEventName = errors.New("sse: event name must not contain CR or LF")
// SSEWriter SSE 写入器
type SSEWriter struct {
writer gin.ResponseWriter
@@ -54,10 +58,13 @@ func NewSSEWriter(c *gin.Context) (*SSEWriter, error) {
// 写错误向上传播(C3b 修复):旧实现丢弃 fmt.Fprintf 错误且恒 return nil
// 导致客户端断连后 Stream 守卫永不触发、消费循环不退出、上游 LLM 流持续运行。
func (w *SSEWriter) WriteEvent(event, data string) error {
if strings.ContainsAny(event, "\r\n") {
return ErrInvalidEventName
}
if _, err := fmt.Fprintf(w.writer, "event: %s\n", event); err != nil {
return err
}
if _, err := fmt.Fprintf(w.writer, "data: %s\n\n", data); err != nil {
if err := w.writeDataLines(data); err != nil {
return err
}
w.flusher.Flush()
@@ -67,13 +74,25 @@ func (w *SSEWriter) WriteEvent(event, data string) error {
// WriteMessage 写入消息(无事件类型)
// 格式: data: <data>\n\n
func (w *SSEWriter) WriteMessage(data string) error {
if _, err := fmt.Fprintf(w.writer, "data: %s\n\n", data); err != nil {
if err := w.writeDataLines(data); err != nil {
return err
}
w.flusher.Flush()
return nil
}
func (w *SSEWriter) writeDataLines(data string) error {
data = strings.ReplaceAll(data, "\r\n", "\n")
data = strings.ReplaceAll(data, "\r", "\n")
for _, line := range strings.Split(data, "\n") {
if _, err := fmt.Fprintf(w.writer, "data: %s\n", line); err != nil {
return err
}
}
_, err := fmt.Fprint(w.writer, "\n")
return err
}
// WriteJSON 写入 JSON 数据
func (w *SSEWriter) WriteJSON(event string, data any) error {
jsonData, err := json.Marshal(data)
+46 -1
View File
@@ -1,6 +1,7 @@
package sse_test
import (
"errors"
"net/http/httptest"
"strings"
"testing"
@@ -139,4 +140,48 @@ func TestSSEHeaders(t *testing.T) {
if w.Header().Get("Connection") != "keep-alive" {
t.Error("Connection should be keep-alive")
}
}
}
func TestSSEWriterRejectsEventNewlineInjection(t *testing.T) {
r := setupTestRouter()
var writeErr error
r.GET("/sse", func(c *gin.Context) {
writer, _ := sse.NewSSEWriter(c)
writeErr = writer.WriteEvent("message\nevent: hacked", "safe")
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/sse", nil)
r.ServeHTTP(w, req)
if !errors.Is(writeErr, sse.ErrInvalidEventName) {
t.Fatalf("WriteEvent newline err = %v, want ErrInvalidEventName", writeErr)
}
if strings.Contains(w.Body.String(), "event: hacked") {
t.Fatalf("response contains injected event: %q", w.Body.String())
}
}
func TestSSEWriterSplitsMultilineData(t *testing.T) {
r := setupTestRouter()
r.GET("/sse", func(c *gin.Context) {
writer, _ := sse.NewSSEWriter(c)
if err := writer.WriteMessage("first\nevent: injected\r\nsecond"); err != nil {
t.Errorf("WriteMessage: %v", err)
}
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/sse", nil)
r.ServeHTTP(w, req)
body := w.Body.String()
if strings.Contains(body, "\nevent: injected") {
t.Fatalf("data newline escaped into event field: %q", body)
}
for _, want := range []string{"data: first", "data: event: injected", "data: second"} {
if !strings.Contains(body, want) {
t.Fatalf("body = %q, want %q", body, want)
}
}
}
+25 -8
View File
@@ -244,7 +244,7 @@ func (s *LocalStorage) safeJoin(parts ...string) (string, error) {
}
// Upload 上传文件
func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string, error) {
func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (ret string, err error) {
// 上传安全策略校验(大小 / 扩展名);file.Size 由 multipart 解析时填,无需打开文件(C4b)。
if err := validateUploadSize(s.policy, file.Size); err != nil {
return "", err
@@ -294,13 +294,18 @@ func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string
if err != nil {
return "", fmt.Errorf("创建文件失败: %w", err)
}
defer dstFile.Close()
defer func() {
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
ret = ""
_ = os.Remove(dst)
}
}()
// 复制文件内容。按策略实测封顶(P0):超限即报错并清理已落盘的部分文件。
if _, err := io.Copy(dstFile, enforceUploadSize(s.policy, srcReader)); err != nil {
// 先关闭再删除(Windows 下删除打开中的文件会失败);defer 的 Close 随后成为无害 no-op。
_ = dstFile.Close()
_ = os.Remove(dst)
return "", fmt.Errorf("保存文件失败: %w", err)
}
@@ -314,7 +319,7 @@ func (s *LocalStorage) Upload(file *multipart.FileHeader, subdir string) (string
}
// UploadFromBytes 从字节数组上传文件
func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (string, error) {
func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (ret string, err error) {
// 上传安全策略校验(C4b
if err := validateUploadSize(s.policy, int64(len(data))); err != nil {
return "", err
@@ -360,7 +365,15 @@ func (s *LocalStorage) UploadFromBytes(data []byte, filename, subdir string) (st
if err != nil {
return "", fmt.Errorf("创建文件失败: %w", err)
}
defer dstFile.Close()
defer func() {
if cerr := dstFile.Close(); cerr != nil {
err = errors.Join(err, cerr)
}
if err != nil {
ret = ""
_ = os.Remove(dst)
}
}()
// 写入文件内容
if _, err := io.Copy(dstFile, srcReader); err != nil {
@@ -571,7 +584,11 @@ func (s *OSSStorage) GetURL(path string) string {
// GetSignedURL 获取带签名的临时访问 URL(用于私有文件)
func (s *OSSStorage) GetSignedURL(path string, expire time.Duration) (string, error) {
return s.bucket.SignURL(path, oss.HTTPGet, int64(expire.Seconds()))
key, err := sanitizeObjectKey(path)
if err != nil {
return "", err
}
return s.bucket.SignURL(key, oss.HTTPGet, int64(expire.Seconds()))
}
// Delete 删除 OSS 文件