-
Notifications
You must be signed in to change notification settings - Fork 11.3k
fix(security): MJ image IDOR (CVE-2026-9306) + HEIF DoS + quota trust-bypass + SSRF rebinding #5406
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| package common | ||
|
|
||
| import ( | ||
| "crypto/hmac" | ||
| "crypto/sha256" | ||
| "encoding/hex" | ||
| ) | ||
|
|
||
| // SignMjImage 为 Midjourney 转发图片 URL (/mj/image/:id) 生成短签名。 | ||
| // 该路由按设计为免鉴权(供外部客户端 <img>/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] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,9 @@ | ||
| package common | ||
|
|
||
| func GetTrustQuota() int { | ||
| return int(10 * QuotaPerUnit) | ||
| // 安全加固:返回 0 以彻底关闭“信任额度旁路”。 | ||
| // 原值 10*QuotaPerUnit 使高余额/unlimited token 的请求在入口零预留、仅事后结算, | ||
| // 叠加无下限扣减 + 并发突发可超扣/白嫖(运营方买单)。 | ||
| // shouldTrust() 在 trustQuota<=0 时直接返回 false,所有请求改走正常预扣预留。 | ||
| return 0 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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()) | ||
| } | ||
|
Comment on lines
483
to
485
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep this diagnostic on the project logger.
🤖 Prompt for AI Agents |
||
| midjRequest.Prompt = originTask.Prompt | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| } | ||
|
Comment on lines
+114
to
+120
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, when a Transport is configured with a proxy, the DialContext method is called to establish a connection to the proxy address, not the final destination host [1]. In Go's net/http package, the Transport determines the "first hop" address using the connectMethod structure [1]. When a proxy is in use, this address is the proxy's network address (host:port) [1]. Consequently, the Transport initiates the TCP connection—via your provided DialContext or the default dialer—directly to the proxy [1]. Because the TCP connection is established with the proxy, the proxy is responsible for handling the subsequent request [1]. Whether the proxy performs the destination DNS resolution depends on the type of proxy and the protocol used: 1. HTTP Proxy: For standard HTTP requests through a proxy, the Transport sends a request to the proxy that typically includes the full URL or the host header, and the proxy performs the DNS resolution to reach the final destination. 2. HTTPS Proxy (CONNECT): When using a proxy for HTTPS (via a CONNECT request), the Transport sends a CONNECT method to the proxy, specifying the destination host and port. The proxy then performs the DNS resolution and establishes the connection to that destination on your behalf. 3. SOCKS Proxy: If using a SOCKS5 proxy, the protocol explicitly allows for the client to send the hostname to the proxy, which then performs the DNS resolution at the proxy side. In all these cases, the DialContext hook is interacting only with the proxy's address [1]. The final destination hostname is not passed to the DialContext function when a proxy is configured, as the dialer's target is the proxy itself [1]. Citations:
With 🤖 Prompt for AI Agents |
||
| 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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return 0does not fully disable the trust-bypass path.service/pre_consume_quota.go:31-64still treatsGetTrustQuota()as a numeric threshold. With0here,userQuota > trustQuotaandtokenQuota > trustQuotabecome true for almost every positive balance, so that path can still zero outpreConsumedQuota. This leaves the overspend bypass reachable and can widen it beyond the old10*QuotaPerUnitcutoff.Suggested follow-up
Or better, funnel this path through the same centralized
shouldTrust()gate.🤖 Prompt for AI Agents