From 35a20cffeb0cd38399212719fbdbf9ed09d66225 Mon Sep 17 00:00:00 2001 From: martin <406615373@qq.com> Date: Sun, 26 Jul 2026 12:54:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20P1-Resource=20?= =?UTF-8?q?=E8=B5=84=E6=BA=90/SSRF=20=E7=B1=BB=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1-R1 rehostFromURL SSRF:syncImageDownloadClient 改用 SSRF 加固的 DialContext,dial 阶段拒绝 loopback/link-local/私网/multicast/unspecified, 用已校验 IP 直拨号杜绝 DNS rebinding;入口加 validateSSRFURL 预检(仅 http/https、字面量内网 IP 直接拒)。新增 isForbiddenIP/validateSSRFURL 单测。 - P1-R2 payment LockOrder 竞态:sync.Map + 手工引用计数改为 256 分片 sharded mutex(FNV-1a),同订单恒命中同分片,无引用计数/Delete,内存恒定。 公共 API 不变;同步更新 callback_test.go,-race 通过。 - P1-R4 OAuth 无超时:新增 oauthHTTPClient(10s 超时),替换全部 5 处 http.DefaultClient.Do / http.Post 出站请求。 - P1-R5 告警派发 goroutine 无上限:新增 goDispatchAlertNotifications, 阻塞式信号量(容量 16)封顶并发 + WithTimeout(30s) 兜底,不丢通知仅排队。 --- internal/logic/monitor/alert_engine.go | 5 +- internal/logic/monitor/alert_notify.go | 33 +++++++ internal/logic/payment/callback.go | 63 ++++++------ internal/logic/payment/callback_test.go | 39 +++++--- internal/logic/task/sync_image_worker.go | 96 ++++++++++++++++++- internal/logic/task/sync_image_worker_test.go | 71 ++++++++++++++ internal/logic/tenant/oauth.go | 16 +++- 代码分析结果.md | 13 ++- 8 files changed, 274 insertions(+), 62 deletions(-) diff --git a/internal/logic/monitor/alert_engine.go b/internal/logic/monitor/alert_engine.go index d2003f9..495764d 100644 --- a/internal/logic/monitor/alert_engine.go +++ b/internal/logic/monitor/alert_engine.go @@ -11,7 +11,6 @@ import ( "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/os/gtime" "github.com/gogf/gf/v2/util/gconv" @@ -111,8 +110,8 @@ func RunAlertDetection(ctx context.Context) error { }). Update() - // Dispatch notifications - go dispatchAlertNotifications(gctx.New(), rule, eventID, currentValue, threshold) + // Dispatch notifications(带并发上限与超时兜底,见 goDispatchAlertNotifications) + goDispatchAlertNotifications(rule, eventID, currentValue, threshold) } } } else { diff --git a/internal/logic/monitor/alert_notify.go b/internal/logic/monitor/alert_notify.go index 32fa172..ed384c4 100644 --- a/internal/logic/monitor/alert_notify.go +++ b/internal/logic/monitor/alert_notify.go @@ -7,6 +7,7 @@ import ( "time" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" "github.com/gogf/gf/v2/util/gconv" "github.com/qianfree/team-api/internal/dao" @@ -14,6 +15,38 @@ import ( do "github.com/qianfree/team-api/internal/model/do" ) +const ( + // alertNotifyMaxConcurrent 限制 dispatchAlertNotifications 的并发派发数。 + // 告警风暴时一个检测周期可能触发多条规则,每条 spawn 一个 goroutine 发邮件/打 + // webhook;慢响应(SMTP 卡顿、webhook 不可达)会让挂起 goroutine 累积。容量 16 的 + // 带缓冲 channel 作为信号量,把并发派发封顶在 16,避免拖垮 DB/SMTP/出站连接。 + alertNotifyMaxConcurrent = 16 + // alertNotifyTimeout 单次通知派发(email + in_app + webhook 全流程)的最长时长。 + // 给原本无超时的 gctx.New() 兜底,确保即使目标不可达,goroutine 也会在 30s 内退出, + // 不会因上游挂起而永久泄漏。 + alertNotifyTimeout = 30 * time.Second +) + +// alertNotifySem 是 dispatchAlertNotifications 的并发信号量。 +// 采用「阻塞获取」语义:拿不到槽位的派发任务会排队等待而非丢弃——告警通知不应丢失, +// 只需限制并发。等待中的 goroutine 栈很小(~2-4KB),配合 alertNotifyTimeout, +// 风暴能在有限时间内自然排空。 +var alertNotifySem = make(chan struct{}, alertNotifyMaxConcurrent) + +// goDispatchAlertNotifications 异步派发告警通知,带并发上限与超时兜底。 +// 由告警引擎在事件创建后调用;阻塞式信号量保证并发不超 alertNotifyMaxConcurrent, +// WithTimeout 保证单个派发不会因上游挂起而无限挂起 goroutine。 +func goDispatchAlertNotifications(rule map[string]any, eventID int64, triggerValue, threshold float64) { + go func() { + alertNotifySem <- struct{}{} + defer func() { <-alertNotifySem }() + // 用全新的带超时 context,不沿用检测循环的 ctx(检测周期结束后原 ctx 可能被取消)。 + ctx, cancel := context.WithTimeout(gctx.New(), alertNotifyTimeout) + defer cancel() + dispatchAlertNotifications(ctx, rule, eventID, triggerValue, threshold) + }() +} + // dispatchAlertNotifications sends notifications for a triggered alert. func dispatchAlertNotifications(ctx context.Context, rule map[string]any, eventID int64, triggerValue, threshold float64) { methods := gconv.Strings(rule["notification_methods"]) diff --git a/internal/logic/payment/callback.go b/internal/logic/payment/callback.go index 9cbd19d..1507e80 100644 --- a/internal/logic/payment/callback.go +++ b/internal/logic/payment/callback.go @@ -17,50 +17,43 @@ import ( ) // 订单级互斥锁,防止回调重复处理。 -var ( - orderLocks sync.Map - createLock sync.Mutex -) +// +// 采用 sharded mutex(按 orderNo 哈希到固定数量分片)替代原先 sync.Map + 手工引用计数 +// 的实现:原 UnlockOrder 在 rcm.mu.Unlock() 之后才 refCount--,存在「归零后误 Delete」的 +// 竞态窗口——并发 LockOrder 在 Delete 前后可能拿到不同的 rcm 对象,导致两路回调各自持有 +// 「订单锁」却互不互斥。sharded 方案无动态分配、无引用计数、无 Delete,内存恒定为分片数, +// 且同一 orderNo 必然命中同一分片 → 互斥语义对同一订单始终正确。跨实例并发由 DB 条件更新 +// (WHERE status='pending')兜底,进程内锁仅用于降低同实例并发回调时的 DB 竞争。 +const orderLockShardCount = 256 + +var orderLockShards [orderLockShardCount]sync.Mutex + +// orderLockShardIndex 按 orderNo 选定分片。FNV-1a 简单快速、分布均匀,避免不同订单扎堆。 +func orderLockShardIndex(orderNo string) int { + const ( + offset32 = uint32(2166136261) + prime32 = uint32(16777619) + ) + h := offset32 + for i := 0; i < len(orderNo); i++ { + h ^= uint32(orderNo[i]) + h *= prime32 + } + return int(h % orderLockShardCount) +} // paymentAmountTolerance 回调金额与订单金额容差(0.01 CNY)。 // 用 decimal 表达,避免 float64 在 0.01 这种边界值上的二进制表示误差导致误判。 var paymentAmountTolerance = decimal.NewFromFloat(0.01) -type refCountedMutex struct { - mu sync.Mutex - refCount int -} - -// LockOrder 对订单号加锁。 +// LockOrder 对订单号加锁(按 orderNo 哈希到固定分片,同订单始终命中同一分片)。 func LockOrder(orderNo string) { - createLock.Lock() - var rcm *refCountedMutex - if v, ok := orderLocks.Load(orderNo); ok { - rcm = v.(*refCountedMutex) - } else { - rcm = &refCountedMutex{} - orderLocks.Store(orderNo, rcm) - } - rcm.refCount++ - createLock.Unlock() - rcm.mu.Lock() + orderLockShards[orderLockShardIndex(orderNo)].Lock() } // UnlockOrder 释放订单锁。 func UnlockOrder(orderNo string) { - v, ok := orderLocks.Load(orderNo) - if !ok { - return - } - rcm := v.(*refCountedMutex) - rcm.mu.Unlock() - - createLock.Lock() - rcm.refCount-- - if rcm.refCount == 0 { - orderLocks.Delete(orderNo) - } - createLock.Unlock() + orderLockShards[orderLockShardIndex(orderNo)].Unlock() } // ProcessCallback 统一回调处理流程。 @@ -128,7 +121,7 @@ func ProcessCallback(ctx context.Context, r *http.Request, channelType string) e // 该条件更新的 RowsAffected 是【跨实例】幂等闸门:多实例并发回调时,两个回调都会 // 通过上面第 5 步的 pending 检查,但 "WHERE status='pending'" 的原子更新只有一个能命中 // (另一个在行锁释放后重读到 status 已是 paid → 0 行)。据此仅让真正把订单从 pending - // 翻成 paid 的那次回调去履约,杜绝重复入账/重复发套餐。进程内 orderLocks 仅单实例有效, + // 翻成 paid 的那次回调去履约,杜绝重复入账/重复发套餐。进程内 orderLockShards 仅单实例有效, // 不能作为并发保护依赖。 res, err := dao.OrdOrders.Ctx(ctx). Where("id", order.ID). diff --git a/internal/logic/payment/callback_test.go b/internal/logic/payment/callback_test.go index 1962592..00ff371 100644 --- a/internal/logic/payment/callback_test.go +++ b/internal/logic/payment/callback_test.go @@ -32,9 +32,9 @@ func TestLockUnlock_DifferentOrders(t *testing.T) { } } -func TestUnlockOrder_NonExistent(t *testing.T) { - UnlockOrder("never-locked-order") -} +// 注:sharded mutex 使用裸 sync.Mutex,UnlockOrder 必须与 LockOrder 严格配对 +// (生产代码均通过 defer 保证)。Unlock 一个未加锁的 mutex 会 panic,这是更严格的 +// 契约,能暴露调用错误,故不再保留「UnlockOrder 不存在订单」的容错测试。 // ─── Mutual exclusion ─────────────────────────────────────────────── @@ -74,9 +74,12 @@ func TestLockOrder_MutualExclusion(t *testing.T) { } } -// ─── Ref-counting cleanup ─────────────────────────────────────────── +// ─── Multiple waiters serialize through the same shard ───────────── -func TestLockOrder_RefCounting(t *testing.T) { +// TestLockOrder_SerializesMultipleWaiters 验证同一订单的多个等待者在持有者释放后 +// 能依次拿到锁。sharded mutex 不再有引用计数/清理语义(分片静态存在),故不再断言 +// map 清理,仅断言所有等待者最终都完成。 +func TestLockOrder_SerializesMultipleWaiters(t *testing.T) { orderNo := "test-order-refcount" var sequence []int var mu sync.Mutex @@ -104,10 +107,23 @@ func TestLockOrder_RefCounting(t *testing.T) { if len(sequence) != 3 { t.Fatalf("expected 3 goroutines to complete, got %d", len(sequence)) } +} - _, exists := orderLocks.Load(orderNo) - if exists { - t.Fatal("lock entry should be cleaned up after all refs are released") +// ─── Shard determinism ───────────────────────────────────────────── + +// TestOrderLockShardIndex_Deterministic 验证同一 orderNo 始终映射到同一分片 +// (这是 sharded mutex 对同一订单互斥正确性的前提)。 +func TestOrderLockShardIndex_Deterministic(t *testing.T) { + for _, orderNo := range []string{"ORD-2024-0001", "abc", "x", "169.254.169.254"} { + first := orderLockShardIndex(orderNo) + for i := 0; i < 10; i++ { + if got := orderLockShardIndex(orderNo); got != first { + t.Fatalf("orderLockShardIndex(%q) not deterministic: %d vs %d", orderNo, got, first) + } + if first < 0 || first >= orderLockShardCount { + t.Fatalf("orderLockShardIndex(%q) = %d out of range [0,%d)", orderNo, first, orderLockShardCount) + } + } } } @@ -139,17 +155,14 @@ func TestLockOrder_ConcurrentStress(t *testing.T) { // ─── Re-lock after full release ───────────────────────────────────── +// TestLockOrder_ReuseAfterRelease 验证释放后可再次加锁。sharded mutex 分片静态存在, +// 无需(也不再有)清理动作,只需确保 unlock 后同订单可重新 lock 而不死锁。 func TestLockOrder_ReuseAfterRelease(t *testing.T) { orderNo := "test-order-reuse" LockOrder(orderNo) UnlockOrder(orderNo) - _, exists := orderLocks.Load(orderNo) - if exists { - t.Fatal("lock should be cleaned up after release") - } - LockOrder(orderNo) UnlockOrder(orderNo) } diff --git a/internal/logic/task/sync_image_worker.go b/internal/logic/task/sync_image_worker.go index ba49da0..c50f786 100644 --- a/internal/logic/task/sync_image_worker.go +++ b/internal/logic/task/sync_image_worker.go @@ -9,7 +9,10 @@ import ( "fmt" "io" "math/rand" + "net" "net/http" + "net/url" + "strings" "sync" "sync/atomic" "time" @@ -788,13 +791,104 @@ func rehostImage(ctx context.Context, job *SyncImageJob, data []byte, contentTyp // 成品图片文件。显式设置超时,避免 http.DefaultClient 无超时时慢速/挂起的图片服务器长期 // 占用 worker(io.LimitReader 只限体积不限时间)。120s 对 ≤20MB 的成品图很充裕,且远低于 // 生成超时——放宽是为了避免「生成已成功却因下载慢被判失败+退款」的资损。 -var syncImageDownloadClient = &http.Client{Timeout: 120 * time.Second} +// +// SSRF 加固:Transport 使用 ssrfSafeDialContext,在连接建立阶段拒绝 loopback / link-local +// (含 169.254.169.254 云元数据)/ RFC1918 私网 / multicast / unspecified 地址。在 dial 时 +// 校验而非仅在请求前校验,可关闭 DNS rebinding 的 TOCTOU 窗口(攻击者 DNS 先返回公网 IP +// 绕过预检、实际连接时再返回内网 IP)。 +var syncImageDownloadClient = &http.Client{ + Timeout: 120 * time.Second, + Transport: &http.Transport{ + DialContext: ssrfSafeDialContext, + Proxy: nil, // 显式禁用代理,避免通过代理绕过 IP 校验访问内网 + IdleConnTimeout: 30 * time.Second, + }, +} + +// errSSRFBlocked 表示 URL 解析出的目标地址落在禁止访问的网段。 +var errSSRFBlocked = errors.New("url host resolves to a forbidden (private/link-local/loopback) address") + +// isForbiddenIP 判断 ip 是否落在服务端抓取永远不应触达的地址段(SSRF 黑名单): +// loopback、link-local(含 169.254.169.254 云元数据)、RFC1918 私网、multicast、unspecified。 +func isForbiddenIP(ip net.IP) bool { + if ip == nil { + return true + } + return ip.IsLoopback() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsPrivate() || + ip.IsMulticast() || + ip.IsUnspecified() +} + +// validateSSRFURL 对即将下载的 URL 做请求前的快速校验:仅允许 http/https scheme, +// 并对字面量 IP 主机直接判定网段。域名主机由 ssrfSafeDialContext 在 dial 时再校验。 +func validateSSRFURL(rawURL string) error { + u, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("parse url: %w", err) + } + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + return fmt.Errorf("unsupported scheme %q (only http/https allowed)", u.Scheme) + } + host := u.Hostname() + if host == "" { + return errors.New("url host is empty") + } + // 字面量 IP 在请求前即可判定;域名留待 dial 时按解析结果判定(防 DNS rebinding)。 + if ip := net.ParseIP(host); ip != nil && isForbiddenIP(ip) { + return errSSRFBlocked + } + return nil +} + +// ssrfSafeDialContext 在连接阶段解析主机名并拒绝任何指向 loopback / link-local / 私网 / +// multicast / unspecified 的地址,随后用解析到的 IP 直接建立连接(不再二次解析,杜绝 +// rebinding)。检查放在 dial 而非仅请求前,可关闭「预检解析为公网、连接时解析为内网」的 +// TOCTOU 窗口。 +func ssrfSafeDialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + for _, ip := range ips { + if isForbiddenIP(ip.IP) { + return nil, errSSRFBlocked + } + } + // 直接用已校验的 IP 拨号,避免 Transport 内部再次走 DNS(可能被 rebind 到内网)。 + dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} + var lastErr error + for _, ip := range ips { + conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port)) + if err != nil { + lastErr = err + continue + } + return conn, nil + } + if lastErr == nil { + lastErr = errors.New("no usable address after SSRF filter") + } + return nil, lastErr +} func rehostFromURL(ctx context.Context, job *SyncImageJob, url string, index int) (string, error) { // 先确认存储可用,避免存储未配置时白白下载一遍成品图。 if _, err := acquireSyncImageFileSvc(ctx); err != nil { return "", fmt.Errorf("object storage not configured, cannot re-host image: %w", err) } + // SSRF 预检:拒绝 file://、gopher:// 等非 http(s) scheme,以及字面量内网 IP。 + // 域名主机的网段校验由 syncImageDownloadClient 的 DialContext 在 dial 时兜底。 + if err := validateSSRFURL(url); err != nil { + return "", fmt.Errorf("reject image url: %w", err) + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", err diff --git a/internal/logic/task/sync_image_worker_test.go b/internal/logic/task/sync_image_worker_test.go index e1dc6eb..82b86cf 100644 --- a/internal/logic/task/sync_image_worker_test.go +++ b/internal/logic/task/sync_image_worker_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "strings" "testing" @@ -293,3 +294,73 @@ func TestTruncateStr(t *testing.T) { } } } + +func TestIsForbiddenIP(t *testing.T) { + cases := []struct { + ip string + want bool + }{ + // 禁止:云元数据 / 私网 / loopback / link-local / multicast / unspecified + {"169.254.169.254", true}, // AWS/云元数据 + {"169.254.0.1", true}, // link-local + {"10.0.0.1", true}, // 私网 10/8 + {"172.16.0.1", true}, // 私网 172.16/12 + {"172.31.255.255", true}, // 私网 172.16/12 上界 + {"172.32.0.1", false}, // 公网(紧邻私网上界) + {"192.168.1.1", true}, // 私网 192.168/16 + {"127.0.0.1", true}, // loopback + {"0.0.0.0", true}, // unspecified + {"224.0.0.1", true}, // multicast + {"::1", true}, // IPv6 loopback + {"fe80::1", true}, // IPv6 link-local + {"fc00::1", true}, // IPv6 ULA(私网) + // 允许:公网 + {"8.8.8.8", false}, + {"1.1.1.1", false}, + {"104.16.0.1", false}, + } + for _, c := range cases { + ip := net.ParseIP(c.ip) + if ip == nil { + t.Fatalf("ParseIP(%q) failed", c.ip) + } + if got := isForbiddenIP(ip); got != c.want { + t.Errorf("isForbiddenIP(%q) = %v, want %v", c.ip, got, c.want) + } + } +} + +func TestValidateSSRFURL(t *testing.T) { + blocked := []string{ + // 字面量内网 IP(预检阶段直接拒绝) + "http://169.254.169.254/latest/meta-data/", + "http://10.0.0.1/x.png", + "http://127.0.0.1/x.png", + "http://192.168.1.1/x.png", + "http://172.16.0.1/x.png", + "https://[::1]/x.png", + // 非 http(s) scheme + "file:///etc/passwd", + "gopher://127.0.0.1:6379/_FLUSHALL", + "ftp://example.com/x.png", + // 空 host + "http:///x.png", + } + for _, u := range blocked { + if err := validateSSRFURL(u); err == nil { + t.Errorf("validateSSRFURL(%q) = nil, want error", u) + } + } + + allowed := []string{ + // 公网字面量 IP 与域名均应通过预检(域名的网段校验由 dial 时兜底) + "https://8.8.8.8/x.png", + "http://example.com/image.png", + "https://cdn.example.com/a/b/c.jpeg", + } + for _, u := range allowed { + if err := validateSSRFURL(u); err != nil { + t.Errorf("validateSSRFURL(%q) = %v, want nil", u, err) + } + } +} diff --git a/internal/logic/tenant/oauth.go b/internal/logic/tenant/oauth.go index aef5404..f3a204c 100644 --- a/internal/logic/tenant/oauth.go +++ b/internal/logic/tenant/oauth.go @@ -29,6 +29,12 @@ const ( oauthStateTTL = 5 * time.Minute ) +// oauthHTTPClient 是 OAuth 出站请求专用 HTTP 客户端。 +// 显式设置 10s 超时,避免 http.DefaultClient(无超时)在 GitHub/Google 上游慢响应或 +// 挂起时长期占用 goroutine——登录回调路径上每个未完成的 OAuth 请求都会泄漏一个 goroutine。 +// OAuth token 交换与 userinfo 都是短小 JSON 请求,10s 足够宽裕。 +var oauthHTTPClient = &http.Client{Timeout: 10 * time.Second} + // OAuthProvider defines the interface for OAuth providers. type OAuthProvider interface { GetName() string @@ -491,7 +497,7 @@ func (p *GitHubProvider) ExchangeToken(ctx context.Context, code string) (*OAuth return nil, err } - resp, err := http.Post("https://github.com/login/oauth/access_token", "application/json", strings.NewReader(string(bodyBytes))) + resp, err := oauthHTTPClient.Post("https://github.com/login/oauth/access_token", "application/json", strings.NewReader(string(bodyBytes))) if err != nil { return nil, err } @@ -513,7 +519,7 @@ func (p *GitHubProvider) GetUserInfo(ctx context.Context, token string) (*OAuthU req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := oauthHTTPClient.Do(req) if err != nil { return nil, err } @@ -553,7 +559,7 @@ func githubFetchPrimaryEmail(token string) string { req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := oauthHTTPClient.Do(req) if err != nil { return "" } @@ -600,7 +606,7 @@ func (p *GoogleProvider) ExchangeToken(ctx context.Context, code string) (*OAuth v.Set("redirect_uri", siteURL+"/api/tenant/oauth/google/callback") v.Set("grant_type", "authorization_code") - resp, err := http.Post("https://oauth2.googleapis.com/token", "application/x-www-form-urlencoded", strings.NewReader(v.Encode())) + resp, err := oauthHTTPClient.Post("https://oauth2.googleapis.com/token", "application/x-www-form-urlencoded", strings.NewReader(v.Encode())) if err != nil { return nil, err } @@ -621,7 +627,7 @@ func (p *GoogleProvider) GetUserInfo(ctx context.Context, token string) (*OAuthU req, _ := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil) req.Header.Set("Authorization", "Bearer "+token) - resp, err := http.DefaultClient.Do(req) + resp, err := oauthHTTPClient.Do(req) if err != nil { return nil, err } diff --git a/代码分析结果.md b/代码分析结果.md index b26c327..caf2259 100644 --- a/代码分析结果.md +++ b/代码分析结果.md @@ -175,31 +175,34 @@ ### 🌐 P1-Resource 资源/SSRF 类 -#### - [ ] P1-R1 rehostFromURL SSRF 风险 +#### - [x] P1-R1 rehostFromURL SSRF 风险 - **位置**:`internal/logic/task/sync_image_worker.go:756-782` - **问题描述**:直接 GET 上游返回的 URL 下载图片,无内网地址过滤。 - **影响**:恶意/被劫持渠道返回 `http://169.254.169.254/latest/meta-data/` 即可让 worker 拉取内网元数据。 - **修复建议**:过滤 link-local(169.254.0.0/16)、私有 IP 段(10/172.16/192.168)、loopback;解析 DNS 后再校验。 +- **修复**:`syncImageDownloadClient` 改用 SSRF 加固的 `Transport.DialContext`(`ssrfSafeDialContext`),在 dial 阶段解析主机名并拒绝 loopback/link-local/RFC1918 私网/multicast/unspecified 地址,用已校验 IP 直接拨号杜绝 DNS rebinding;`rehostFromURL` 入口加 `validateSSRFURL` 预检(仅 http(s)、字面量内网 IP 直接拒)。新增 `TestIsForbiddenIP`/`TestValidateSSRFURL` 覆盖。 -#### - [ ] P1-R2 payment LockOrder 引用计数锁竞态 +#### - [x] P1-R2 payment LockOrder 引用计数锁竞态 - **位置**:`internal/logic/payment/callback.go:30-59` - **问题描述**:`sync.Map` + 手工引用计数,`UnlockOrder` 中 `rcm.mu.Unlock()` 之后才 `refCount--`,存在归零后误 Delete 的窗口。 - **修复建议**:用 sharded mutex 或 singleflight 替代手写引用计数;或直接依赖 DB 条件更新删掉进程内锁。 +- **修复**:改为 256 分片 sharded mutex(`orderLockShards` + FNV-1a 哈希),同一 orderNo 恒映射到同一分片 → 互斥语义正确;无动态分配/引用计数/Delete,内存恒定。公共 API `LockOrder`/`UnlockOrder` 不变,`admin/order.go` 无需改动。同步更新 `callback_test.go`(移除已不适用的引用计数清理/不存在订单容错断言,新增分片确定性测试),`-race` 通过。 - -#### - [ ] P1-R4 OAuth HTTP 客户端无超时 +#### - [x] P1-R4 OAuth HTTP 客户端无超时 - **位置**:`internal/logic/tenant/oauth.go:516,556,624` - **问题描述**:用 `http.DefaultClient`(无超时),上游慢响应会挂死 goroutine。 - **修复建议**:`&http.Client{Timeout: 10 * time.Second}`。 +- **修复**:新增包级 `oauthHTTPClient = &http.Client{Timeout: 10 * time.Second}`,替换全部 5 处出站请求(GitHub/Google 的 token 交换 `http.Post` × 2 与 userinfo `http.DefaultClient.Do` × 3)。 -#### - [ ] P1-R5 dispatchAlertNotifications goroutine 无上限 +#### - [x] P1-R5 dispatchAlertNotifications goroutine 无上限 - **位置**:`internal/logic/monitor/alert_engine.go:114` + `alert_notify.go:142` - **问题描述**:ctx 是 `gctx.New()`(无超时),告警风暴时可能产生数百个挂起 goroutine。 - **修复建议**:给 ctx 加 timeout 或限制并发数。 +- **修复**:双管齐下——新增 `goDispatchAlertNotifications`,阻塞式信号量 `alertNotifySem`(容量 16)封顶并发派发(不丢通知,仅排队),并对派发 ctx 加 `context.WithTimeout(gctx.New(), 30s)` 兜底;告警引擎改为调用该 helper,移除直连 `go dispatchAlertNotifications(gctx.New(), ...)`。 ---