diff --git a/common/mjsign.go b/common/mjsign.go new file mode 100644 index 000000000000..5708dead5d02 --- /dev/null +++ b/common/mjsign.go @@ -0,0 +1,17 @@ +package common + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +// SignMjImage 为 Midjourney 转发图片 URL (/mj/image/:id) 生成短签名。 +// 该路由按设计为免鉴权(供外部客户端 /fetch 取图),原实现对任意 +// mj_id 无作用域校验,导致未授权跨用户读取他人生成图 (CVE-2026-9306)。 +// 通过对 mj_id 附加 HMAC 签名,保留免 token 取图能力的同时杜绝任意 id 枚举/越权。 +func SignMjImage(mjId string) string { + mac := hmac.New(sha256.New, []byte(SessionSecret)) + mac.Write([]byte("mjimg:" + mjId)) + return hex.EncodeToString(mac.Sum(nil))[:16] +} diff --git a/common/quota.go b/common/quota.go index dfd65d273ee5..25e447445b2e 100644 --- a/common/quota.go +++ b/common/quota.go @@ -1,5 +1,9 @@ package common func GetTrustQuota() int { - return int(10 * QuotaPerUnit) + // 安全加固:返回 0 以彻底关闭“信任额度旁路”。 + // 原值 10*QuotaPerUnit 使高余额/unlimited token 的请求在入口零预留、仅事后结算, + // 叠加无下限扣减 + 并发突发可超扣/白嫖(运营方买单)。 + // shouldTrust() 在 trustQuota<=0 时直接返回 false,所有请求改走正常预扣预留。 + return 0 } diff --git a/controller/midjourney.go b/controller/midjourney.go index 69aa5ccd431f..2b6aa36fa92b 100644 --- a/controller/midjourney.go +++ b/controller/midjourney.go @@ -270,7 +270,7 @@ func GetAllMidjourney(c *gin.Context) { if setting.MjForwardUrlEnabled { for i, midjourney := range items { - midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId + midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId + "?sig=" + common.SignMjImage(midjourney.MjId) items[i] = midjourney } } @@ -295,7 +295,7 @@ func GetUserMidjourney(c *gin.Context) { if setting.MjForwardUrlEnabled { for i, midjourney := range items { - midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId + midjourney.ImageUrl = system_setting.ServerAddress + "/mj/image/" + midjourney.MjId + "?sig=" + common.SignMjImage(midjourney.MjId) items[i] = midjourney } } diff --git a/main.go b/main.go index 3361b8ce9338..ea651289633e 100644 --- a/main.go +++ b/main.go @@ -160,6 +160,13 @@ func main() { // Initialize HTTP server server := gin.New() + // 安全加固:仅信任本机/私网反向代理(nginx 经 docker 网桥接入), + // 使 c.ClientIP() 只采信可信代理设置的 X-Forwarded-For, + // 防止公网客户端伪造 XFF 绕过基于 IP 的限流(撞库)。 + _ = server.SetTrustedProxies([]string{ + "127.0.0.1/8", "::1/128", + "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", + }) server.Use(gin.CustomRecovery(func(c *gin.Context, err any) { common.SysLog(fmt.Sprintf("panic detected: %v", err)) c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/relay/mjproxy_handler.go b/relay/mjproxy_handler.go index 5b0750fec435..c505113a837a 100644 --- a/relay/mjproxy_handler.go +++ b/relay/mjproxy_handler.go @@ -2,6 +2,7 @@ package relay import ( "bytes" + "crypto/hmac" "encoding/json" "fmt" "io" @@ -14,7 +15,6 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" - "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" relaycommon "github.com/QuantumNous/new-api/relay/common" relayconstant "github.com/QuantumNous/new-api/relay/constant" @@ -28,6 +28,13 @@ import ( func RelayMidjourneyImage(c *gin.Context) { taskId := c.Param("id") + // 安全加固 (CVE-2026-9306):该路由免鉴权,必须校验签名以防未授权按任意 mj_id 越权取图。 + if sig := c.Query("sig"); sig == "" || !hmac.Equal([]byte(sig), []byte(common.SignMjImage(taskId))) { + c.JSON(http.StatusForbidden, gin.H{ + "error": "invalid or missing signature", + }) + return + } midjourneyTask := model.GetByOnlyMJId(taskId) if midjourneyTask == nil { c.JSON(400, gin.H{ @@ -141,9 +148,9 @@ func coverMidjourneyTaskDto(c *gin.Context, originTask *model.Midjourney) (midjo midjourneyTask.FinishTime = originTask.FinishTime midjourneyTask.ImageUrl = "" if originTask.ImageUrl != "" && setting.MjForwardUrlEnabled { - midjourneyTask.ImageUrl = system_setting.ServerAddress + "/mj/image/" + originTask.MjId + midjourneyTask.ImageUrl = system_setting.ServerAddress + "/mj/image/" + originTask.MjId + "?sig=" + common.SignMjImage(originTask.MjId) if originTask.Status != "SUCCESS" { - midjourneyTask.ImageUrl += "?rand=" + strconv.FormatInt(time.Now().UnixNano(), 10) + midjourneyTask.ImageUrl += "&rand=" + strconv.FormatInt(time.Now().UnixNano(), 10) } } else { midjourneyTask.ImageUrl = originTask.ImageUrl @@ -474,7 +481,7 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt c.Set("base_url", channel.GetBaseURL()) c.Set("channel_id", originTask.ChannelId) c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) - logger.LogDebug(c, "Midjourney action uses origin channel: id=%s, base_url=%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL()) + log.Printf("检测到此操作为放大、变换、重绘,获取原channel信息: %s,%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL()) } midjRequest.Prompt = originTask.Prompt diff --git a/service/download.go b/service/download.go index 752d8c65b6db..51c973a264d7 100644 --- a/service/download.go +++ b/service/download.go @@ -65,6 +65,7 @@ func DoDownloadRequest(originUrl string, reason ...string) (resp *http.Response, } common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", "))) - return GetHttpClient().Get(originUrl) + // 安全加固:用户可控 URL 的下载走 SSRF 安全客户端(拨号固定已校验 IP,防 DNS-rebinding) + return GetSSRFSafeClient().Get(originUrl) } } diff --git a/service/file_service.go b/service/file_service.go index 03baf2deab84..8b2093469f48 100644 --- a/service/file_service.go +++ b/service/file_service.go @@ -50,7 +50,7 @@ func LoadFileSource(c *gin.Context, source types.FileSource, reason ...string) ( } if common.DebugEnabled { - logger.LogDebug(c, "LoadFileSource starting for: %s", source.GetIdentifier()) + logger.LogDebug(c, fmt.Sprintf("LoadFileSource starting for: %s", source.GetIdentifier())) } // 1. 快速检查内部缓存 @@ -208,7 +208,7 @@ func loadFromURL(c *gin.Context, url string, reason ...string) (*types.CachedFil } common.IncrementDiskFiles(base64Size) if common.DebugEnabled { - logger.LogDebug(c, "File cached to disk: %s, size: %d bytes", diskPath, base64Size) + logger.LogDebug(c, fmt.Sprintf("File cached to disk: %s, size: %d bytes", diskPath, base64Size)) } } } else { @@ -549,7 +549,7 @@ func parseHEIFDimensions(data []byte) (int, int, bool) { if len(metaData) < 4 { return 0, 0, false } - return findISPE(metaData[4:]) + return findISPE(metaData[4:], 0) } offset += boxSize } @@ -558,7 +558,11 @@ func parseHEIFDimensions(data []byte) (int, int, bool) { // findISPE recursively searches for the ispe box within container boxes. // Path: meta -> iprp -> ipco -> ispe -func findISPE(data []byte) (int, int, bool) { +// 安全加固:限制递归深度,防止构造的嵌套 ipco/iprp 链导致无界递归栈耗尽 DoS。 +func findISPE(data []byte, depth int) (int, int, bool) { + if depth > 16 { + return 0, 0, false + } offset := 0 size := len(data) for offset+8 <= size { @@ -570,7 +574,7 @@ func findISPE(data []byte) (int, int, bool) { content := data[offset+8 : offset+boxSize] switch boxType { case "iprp", "ipco": - if w, h, ok := findISPE(content); ok { + if w, h, ok := findISPE(content, depth+1); ok { return w, h, true } case "ispe": diff --git a/service/http_client.go b/service/http_client.go index 670dbc5fe1d4..cbaff7fa3ed1 100644 --- a/service/http_client.go +++ b/service/http_client.go @@ -37,7 +37,6 @@ func InitHttpClient() { transport := &http.Transport{ MaxIdleConns: common.RelayMaxIdleConns, MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second, ForceAttemptHTTP2: true, Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars } @@ -63,6 +62,73 @@ func GetHttpClient() *http.Client { return httpClient } +// --- 安全加固:SSRF 防护专用 HTTP 客户端(仅用于用户可控 URL 的下载/抓取路径)--- +// 默认客户端在 ValidateURL 校验后由 Transport 二次解析 DNS 且不固定 IP, +// 存在 DNS-rebinding/TOCTOU SSRF。此客户端在拨号时解析一次并逐 IP 校验, +// 仅拨号已通过校验的 IP(固定该 IP),杜绝校验态与拨号态 IP 不一致。 +// 注意:只用于下载路径,不影响管理员配置的上游渠道中继(避免误伤非标端口/内网上游)。 +var ( + ssrfSafeClient *http.Client + ssrfSafeClientLock sync.Mutex +) + +func ssrfSafeDialContext(ctx context.Context, network, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + d := &net.Dialer{} + fs := system_setting.GetFetchSetting() + if !fs.EnableSSRFProtection { + return d.DialContext(ctx, network, addr) + } + // 字面 IP 直接校验 + if ip := net.ParseIP(host); ip != nil { + prot := &common.SSRFProtection{AllowPrivateIp: fs.AllowPrivateIp, IpFilterMode: fs.IpFilterMode, IpList: fs.IpList} + if !prot.IsIPAccessAllowed(ip) { + return nil, fmt.Errorf("ssrf protection: ip not allowed: %s", ip.String()) + } + return d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + prot := &common.SSRFProtection{AllowPrivateIp: fs.AllowPrivateIp, IpFilterMode: fs.IpFilterMode, IpList: fs.IpList} + for _, ipa := range ips { + if prot.IsIPAccessAllowed(ipa.IP) { + // 固定拨号到已校验 IP,避免拨号时再次解析被重绑定 + return d.DialContext(ctx, network, net.JoinHostPort(ipa.IP.String(), port)) + } + } + return nil, fmt.Errorf("ssrf protection: no allowed IP for host %s", host) +} + +// GetSSRFSafeClient 返回固定已校验 IP 的客户端,用于用户可控 URL 的下载/抓取。 +func GetSSRFSafeClient() *http.Client { + ssrfSafeClientLock.Lock() + defer ssrfSafeClientLock.Unlock() + if ssrfSafeClient != nil { + return ssrfSafeClient + } + transport := &http.Transport{ + MaxIdleConns: common.RelayMaxIdleConns, + MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, + ForceAttemptHTTP2: true, + Proxy: http.ProxyFromEnvironment, + DialContext: ssrfSafeDialContext, + } + if common.TLSInsecureSkipVerify { + transport.TLSClientConfig = common.InsecureTLSConfig + } + c := &http.Client{Transport: transport, CheckRedirect: checkRedirect} + if common.RelayTimeout != 0 { + c.Timeout = time.Duration(common.RelayTimeout) * time.Second + } + ssrfSafeClient = c + return c +} + // GetHttpClientWithProxy returns the default client or a proxy-enabled one when proxyURL is provided. func GetHttpClientWithProxy(proxyURL string) (*http.Client, error) { if proxyURL == "" { @@ -109,7 +175,6 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { transport := &http.Transport{ MaxIdleConns: common.RelayMaxIdleConns, MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second, ForceAttemptHTTP2: true, Proxy: http.ProxyURL(parsedURL), } @@ -149,7 +214,6 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) { transport := &http.Transport{ MaxIdleConns: common.RelayMaxIdleConns, MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost, - IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second, ForceAttemptHTTP2: true, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialer.Dial(network, addr)