Files
xlgo-core/utils/http.go
T
杭州明婳科技 6595e94677 fix: 多模型评审终审收口 — P0安全 + 主线A并发治理 + 文档对齐
经 deepseek/GLM/Claude 三轮对抗性评审 + 交叉核验 + 终审(见
last_report.md、cross_review_assessment.md),逐条回读源码核实并修复。
go build / vet / test -race ./... 全绿。

安全(P0):
- JWT alg confusion:WithValidMethods 固定 HMAC + keyfunc 方法断言
- JWT 空密钥 / 不支持算法 fail-closed(ErrEmptySecret / ErrUnsupportedAlgorithm)
- 上传大小实测封顶(enforceUploadSize),不信任客户端 file.Size
- HTTP headers/cookies map 竞态(写锁+快照)+ SSRF 防护(opt-in dialer.Control)

并发/资源(主线A 闭合):
- 包级可变全局一律 atomic.Pointer:DefaultRedis / DefaultStorage /
  DefaultManager / Validator / DefaultCache 等,消除裸指针数据竞争
- trace.Close 去 sync.Once(防 exporter 泄漏);app OnReady 失败走 Shutdown
- ws.Hub.Stop stopOnce 防 double-close;cron 接入 App 生命周期(WithCron)
- ratelimit failClosed atomic.Bool;Recover Written() 守卫
- cron checkAndRun 锁内收集锁外 spawn(wg.Add 在锁内防 Stop 竞态)

终审剩余项收口(H-A~M-H + L 系列):
- 副本连接池 MaxOpenConns/2 截断修复(replicaMaxOpenConns)
- JWT 黑名单 1s 超时 + 显式错误;ratelimit GetRedis 取一次复用
- GetPage 深分页上限;HashFile 流式;ReadFile 去 TOCTOU
- config Clone() 深拷贝;cache 锁操作返 ErrRedisNotReady
- compress 解压残留清理;正则预编译;EqualsIgnoreCase→EqualFold
- 删 redisLimiters 死代码;CLI 输入校验 + 回滚

文档:
- README/GUIDE/CHANGELOG 对齐当前 API(含破坏性变更迁移说明)
- docs/README 索引修正;config 注释修正(RS256 已不支持)

破坏性变更详见 CHANGELOG.md [Unreleased]。项目处于研发初期、无下游用户。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 23:16:03 +08:00

537 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package utils
import (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
)
// HTTPClient HTTP 客户端封装
type HTTPClient struct {
// mu 保护 client/transport/cfg/timeout/skipTLS 的并发读写。SetSkipTLS/SetTimeout
// 在写锁下重建 client/transportdo/DoWithResponse/Close 在读锁下快照后无锁调用
// http.Client.DoDo 自身并发安全),不持有锁发请求以避免序列化。
mu sync.RWMutex
client *http.Client
transport *http.Transport
cfg HTTPClientConfig // 保留配置以便 SetSkipTLS 重建 transport
timeout time.Duration
headers map[string]string
cookies map[string]string
skipTLS bool
// maxRespBodySize 响应体读取上限(字节),0 表示用默认 32MB,-1 表示不限。
// 防止恶意/异常服务端返回超大响应打爆内存(C5/N5)。
maxRespBodySize int64
}
// UploadFile 上传文件信息
type UploadFile struct {
FieldName string // 表单字段名
FilePath string // 文件路径
}
// HTTPClientConfig HTTP 客户端配置
type HTTPClientConfig struct {
Timeout time.Duration // 请求超时时间
MaxIdleConns int // 最大空闲连接数
IdleConnTimeout time.Duration // 空闲连接超时时间
MaxConnsPerHost int // 每个主机最大连接数
MaxIdleConnsPerHost int // 每个主机最大空闲连接数
SkipTLSVerify bool // 是否跳过 TLS 验证(默认 false 校验 TLS;自签证书场景需显式设 true)
// MaxResponseBodySize 响应体读取上限(字节)。0 = 默认 32MB,-1 = 不限制。
// 防止异常服务端返回超大响应打爆内存(C5/N5)。
MaxResponseBodySize int64
// BlockPrivateNetworks 启用 SSRF 防护(P0,默认 false 保持兼容)。启用后,连接建立时
// 校验解析出的目标 IP,拒绝回环/私有(RFC1918+ULA)/链路本地/元数据(169.254.169.254)/
// 未指定/多播等内网地址。校验在 DialContext.Control 中进行,对重定向的每一跳同样生效。
// 当 URL 可能来自用户输入(webhook、头像抓取等)时应开启。
BlockPrivateNetworks bool
}
// ErrSSRFBlocked 目标 IP 落在被拦截网段(SSRF 防护,P0)。
var ErrSSRFBlocked = errors.New("ssrf guard: 目标 IP 属被拦截网段(回环/私有/链路本地/元数据)")
// isBlockedIP 判断 IP 是否应被 SSRF 防护拦截。
// 覆盖:回环、私有(RFC1918 + ULA fc00::/7)、链路本地(含 169.254.169.254 云元数据 / fe80::)、
// 未指定(0.0.0.0/::)、多播。
func isBlockedIP(ip net.IP) bool {
if ip == nil {
return true
}
return ip.IsLoopback() || ip.IsPrivate() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast()
}
// ssrfControl 是 net.Dialer.Control 回调:在 DNS 解析后、真正 dial 前校验目标 IP,
// 命中内网段即拒绝。放在 Control 层可覆盖 DNS 重绑定与重定向的每一跳(P0)。
func ssrfControl(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("%w: 无法解析地址 %q", ErrSSRFBlocked, address)
}
if isBlockedIP(ip) {
return fmt.Errorf("%w: %s", ErrSSRFBlocked, ip)
}
return nil
}
// DefaultHTTPClientConfig 默认配置
var DefaultHTTPClientConfig = HTTPClientConfig{
Timeout: 30 * time.Second,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
MaxConnsPerHost: 10,
MaxIdleConnsPerHost: 10,
SkipTLSVerify: false, // H2 修复:默认校验 TLS,防 MITM;自签证书需显式 SetSkipTLS(true)
MaxResponseBodySize: 32 * 1024 * 1024,
}
// NewHTTPClient 创建 HTTP 客户端
func NewHTTPClient() *HTTPClient {
cfg := DefaultHTTPClientConfig
return NewHTTPClientWithConfig(cfg)
}
// NewSSRFSafeHTTPClient 创建启用 SSRF 防护的 HTTP 客户端(P0):拒绝连接内网目标 IP。
// 适用于目标 URL 可能来自用户输入的场景(webhook 回调、远程图片抓取等)。
func NewSSRFSafeHTTPClient() *HTTPClient {
cfg := DefaultHTTPClientConfig
cfg.BlockPrivateNetworks = true
return NewHTTPClientWithConfig(cfg)
}
// NewHTTPClientWithConfig 使用自定义配置创建 HTTP 客户端
func NewHTTPClientWithConfig(cfg HTTPClientConfig) *HTTPClient {
transport, client := buildHTTPClientPair(cfg)
return &HTTPClient{
client: client,
transport: transport,
cfg: cfg,
timeout: cfg.Timeout,
headers: make(map[string]string),
cookies: make(map[string]string),
skipTLS: cfg.SkipTLSVerify,
maxRespBodySize: cfg.MaxResponseBodySize,
}
}
// buildHTTPClientPair 按 cfg 构造 (transport, client) 对。SetSkipTLS 重建时复用。
func buildHTTPClientPair(cfg HTTPClientConfig) (*http.Transport, *http.Client) {
// Transport 在初始化时创建,连接池可复用
transport := &http.Transport{
// #nosec G402 -- InsecureSkipVerify 仅在调用方显式设 cfg.SkipTLSVerify=true 时启用,
// 默认 false 校验 TLS(H2 修复)。自签证书场景需 opt-in。
TLSClientConfig: &tls.Config{
InsecureSkipVerify: cfg.SkipTLSVerify,
},
MaxIdleConns: cfg.MaxIdleConns,
IdleConnTimeout: cfg.IdleConnTimeout,
MaxConnsPerHost: cfg.MaxConnsPerHost,
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
DisableCompression: false,
}
// SSRF 防护(P0):启用后为拨号器装 Control 回调,连接建立时拦截内网目标 IP。
if cfg.BlockPrivateNetworks {
dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
Control: ssrfControl,
}
transport.DialContext = dialer.DialContext
}
client := &http.Client{
Transport: transport,
Timeout: cfg.Timeout,
}
return transport, client
}
// currentClient 在读锁下快照当前 *http.ClientH-12:与 SetSkipTLS/SetTimeout 重建无竞态)。
func (c *HTTPClient) currentClient() *http.Client {
c.mu.RLock()
defer c.mu.RUnlock()
return c.client
}
// currentTransport 在读锁下快照当前 *http.Transport。
func (c *HTTPClient) currentTransport() *http.Transport {
c.mu.RLock()
defer c.mu.RUnlock()
return c.transport
}
// SetTimeout 设置超时时间。
// H-12 修复:在写锁下重建 *http.Client(复用 transport 保留连接池),避免与并发 Do 对
// client.Timeout 字段的数据竞争。
func (c *HTTPClient) SetTimeout(timeout time.Duration) *HTTPClient {
c.mu.Lock()
defer c.mu.Unlock()
c.timeout = timeout
c.cfg.Timeout = timeout
_, client := buildHTTPClientPair(c.cfg)
// 复用旧 transport 保留连接池:新 client 用旧 transport + 新 timeout。
client.Transport = c.transport
c.client = client
return c
}
// SetHeader 设置请求头(P0:写锁保护,与并发 do/DoWithResponse 读 map 无竞态)。
func (c *HTTPClient) SetHeader(key, value string) *HTTPClient {
c.mu.Lock()
c.headers[key] = value
c.mu.Unlock()
return c
}
// SetHeaders 批量设置请求头(P0:写锁保护)。
func (c *HTTPClient) SetHeaders(headers map[string]string) *HTTPClient {
c.mu.Lock()
for k, v := range headers {
c.headers[k] = v
}
c.mu.Unlock()
return c
}
// SetCookie 设置 CookieP0:写锁保护)。
func (c *HTTPClient) SetCookie(key, value string) *HTTPClient {
c.mu.Lock()
c.cookies[key] = value
c.mu.Unlock()
return c
}
// snapshotHeadersCookies 在读锁下拷贝 headers/cookies,供 do/DoWithResponse 在锁外应用到请求,
// 消除与 SetHeader/SetHeaders/SetCookie 的 map 读写竞态(P0)。
func (c *HTTPClient) snapshotHeadersCookies() (headers, cookies map[string]string) {
c.mu.RLock()
defer c.mu.RUnlock()
headers = make(map[string]string, len(c.headers))
for k, v := range c.headers {
headers[k] = v
}
cookies = make(map[string]string, len(c.cookies))
for k, v := range c.cookies {
cookies[k] = v
}
return headers, cookies
}
// SetSkipTLS 设置是否跳过 TLS 验证。
// 跳过 TLS 校验会暴露于 MITM 攻击,仅在受控环境(如自签证书的内网服务)且明确风险时启用,
// 生产环境应保持 false。
//
// H-12 修复:原实现直接覆盖 c.transport.TLSClientConfig 指针,与并发 Do 读取该字段
// 存在数据竞争(-race 必采),且注释自承"需重建 Transport"却未重建。改为在写锁下用
// 新配置重建 transport+client 并原子替换,旧 transport 释放空闲连接(保留旧 TLS 配置的
// idle 连接不再被复用)。支持运行期并发调用与并发请求无竞态。
func (c *HTTPClient) SetSkipTLS(skip bool) *HTTPClient {
c.mu.Lock()
c.skipTLS = skip
c.cfg.SkipTLSVerify = skip
transport, client := buildHTTPClientPair(c.cfg)
oldTransport := c.transport
c.transport = transport
c.client = client
c.mu.Unlock()
// 锁外释放旧 transport 的空闲连接(CloseIdleConnections 仅关 idle 连接,不影响在途请求)。
if oldTransport != nil {
oldTransport.CloseIdleConnections()
}
return c
}
// SetBlockPrivateNetworks 运行期开关 SSRF 防护(P0)。开启后连接内网 IP(回环/私有/链路本地/
// 元数据等)会被拒绝并返回 ErrSSRFBlocked。写锁下重建 transport+client 并原子替换,
// 与并发请求无竞态;旧 transport 的空闲连接在锁外释放。
func (c *HTTPClient) SetBlockPrivateNetworks(block bool) *HTTPClient {
c.mu.Lock()
c.cfg.BlockPrivateNetworks = block
transport, client := buildHTTPClientPair(c.cfg)
oldTransport := c.transport
c.transport = transport
c.client = client
c.mu.Unlock()
if oldTransport != nil {
oldTransport.CloseIdleConnections()
}
return c
}
// Get 发送 GET 请求
func (c *HTTPClient) Get(urlStr string, params map[string]string) ([]byte, error) {
if len(params) > 0 {
u, err := url.Parse(urlStr)
if err != nil {
return nil, err
}
q := u.Query()
for k, v := range params {
q.Set(k, v)
}
u.RawQuery = q.Encode()
urlStr = u.String()
}
req, err := http.NewRequest("GET", urlStr, nil)
if err != nil {
return nil, err
}
return c.do(req)
}
// Post 发送 POST 请求(form 表单格式)
func (c *HTTPClient) Post(urlStr string, params map[string]string) ([]byte, error) {
data := url.Values{}
for k, v := range params {
data.Set(k, v)
}
req, err := http.NewRequest("POST", urlStr, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return c.do(req)
}
// PostJSON 发送 POST 请求(JSON 格式)
func (c *HTTPClient) PostJSON(urlStr string, data any) ([]byte, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", urlStr, bytes.NewReader(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return c.do(req)
}
// Put 发送 PUT 请求(JSON 格式)
func (c *HTTPClient) Put(urlStr string, data any) ([]byte, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return nil, err
}
req, err := http.NewRequest("PUT", urlStr, bytes.NewReader(jsonData))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
return c.do(req)
}
// Delete 发送 DELETE 请求
func (c *HTTPClient) Delete(urlStr string) ([]byte, error) {
req, err := http.NewRequest("DELETE", urlStr, nil)
if err != nil {
return nil, err
}
return c.do(req)
}
// Upload 上传文件
func (c *HTTPClient) Upload(urlStr string, files []UploadFile, params map[string]string) ([]byte, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
for _, f := range files {
file, err := os.Open(f.FilePath)
if err != nil {
return nil, err
}
part, err := writer.CreateFormFile(f.FieldName, filepath.Base(f.FilePath))
if err != nil {
file.Close()
return nil, err
}
if _, err = io.Copy(part, file); err != nil {
file.Close()
return nil, err
}
// 显式关闭,避免循环内 defer 累积 FD(N5/C5)。
file.Close()
}
for k, v := range params {
if err := writer.WriteField(k, v); err != nil {
return nil, err
}
}
if err := writer.Close(); err != nil {
return nil, err
}
req, err := http.NewRequest("POST", urlStr, &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
return c.do(req)
}
// UploadFromBytes 从字节数据上传文件
func (c *HTTPClient) UploadFromBytes(urlStr string, fieldName string, filename string, data []byte, params map[string]string) ([]byte, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, err := writer.CreateFormFile(fieldName, filename)
if err != nil {
return nil, err
}
if _, err = io.Copy(part, bytes.NewReader(data)); err != nil {
return nil, err
}
for k, v := range params {
if err := writer.WriteField(k, v); err != nil {
return nil, err
}
}
if err := writer.Close(); err != nil {
return nil, err
}
req, err := http.NewRequest("POST", urlStr, &buf)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", writer.FormDataContentType())
return c.do(req)
}
// Request 发送自定义请求
func (c *HTTPClient) Request(method, urlStr string, body []byte) ([]byte, error) {
req, err := http.NewRequest(method, urlStr, bytes.NewReader(body))
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
return c.do(req)
}
// do 执行请求(使用共享的 client 和 transport
func (c *HTTPClient) do(req *http.Request) ([]byte, error) {
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
headers, cookies := c.snapshotHeadersCookies()
for k, v := range headers {
req.Header.Set(k, v)
}
for k, v := range cookies {
req.AddCookie(&http.Cookie{Name: k, Value: v})
}
// 发送请求(H-12:快照 client,与 SetSkipTLS/SetTimeout 重建无竞态)
resp, err := c.currentClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 检查状态码
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("http error: %d %s", resp.StatusCode, resp.Status)
}
// 响应体读取封顶,防异常服务端返回超大响应打爆内存(C5/N5)。
// maxRespBodySize: 0=默认 32MB-1=不限。
limit := c.maxRespBodySize
if limit == 0 {
limit = 32 * 1024 * 1024
}
var reader io.Reader = resp.Body
if limit > 0 {
// 多读 1 字节用于判断是否超限。
reader = io.LimitReader(resp.Body, limit+1)
}
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if limit > 0 && int64(len(data)) > limit {
return nil, fmt.Errorf("response body exceeds limit %d bytes", limit)
}
return data, nil
}
// DoWithResponse 执行请求并返回完整响应。
//
// 注意:调用方负责关闭返回的 resp.Body;且此方法不套用 maxRespBodySize 读封顶(由调用方掌控)。
func (c *HTTPClient) DoWithResponse(req *http.Request) (*http.Response, error) {
// P0:读锁快照 headers/cookies 后在锁外应用,与并发 Set* 无竞态。
headers, cookies := c.snapshotHeadersCookies()
for k, v := range headers {
req.Header.Set(k, v)
}
for k, v := range cookies {
req.AddCookie(&http.Cookie{Name: k, Value: v})
}
return c.currentClient().Do(req)
}
// Close 关闭客户端(释放连接池资源)
func (c *HTTPClient) Close() {
if t := c.currentTransport(); t != nil {
t.CloseIdleConnections()
}
}
// JSONMarshal 内部 JSON 序列化函数
func JSONMarshal(v any) ([]byte, error) {
return json.Marshal(v)
}
// 全局默认 HTTP 客户端
var defaultClient *HTTPClient
var defaultClientOnce sync.Once
// DefaultHTTPClient 获取全局默认 HTTP 客户端
func DefaultHTTPClient() *HTTPClient {
defaultClientOnce.Do(func() {
defaultClient = NewHTTPClient()
})
return defaultClient
}
// HTTPGet 使用默认客户端发送 GET 请求
func HTTPGet(url string, params map[string]string) ([]byte, error) {
return DefaultHTTPClient().Get(url, params)
}
// HTTPPost 使用默认客户端发送 POST 请求
func HTTPPost(url string, params map[string]string) ([]byte, error) {
return DefaultHTTPClient().Post(url, params)
}
// HTTPPostJSON 使用默认客户端发送 JSON POST 请求
func HTTPPostJSON(url string, data any) ([]byte, error) {
return DefaultHTTPClient().PostJSON(url, data)
}