fix: 修复 P1-Resource 资源/SSRF 类问题
- 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) 兜底,不丢通知仅排队。
This commit is contained in:
1 parent
4cc6ff307e
commit
35a20cffeb
8 files changed
+274
-62
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(), ...)`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user