Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions common/mjsign.go
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]
}
6 changes: 5 additions & 1 deletion common/quota.go
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
Comment on lines +4 to +8

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

return 0 does not fully disable the trust-bypass path.

service/pre_consume_quota.go:31-64 still treats GetTrustQuota() as a numeric threshold. With 0 here, userQuota > trustQuota and tokenQuota > trustQuota become true for almost every positive balance, so that path can still zero out preConsumedQuota. This leaves the overspend bypass reachable and can widen it beyond the old 10*QuotaPerUnit cutoff.

Suggested follow-up
// service/pre_consume_quota.go
trustQuota := common.GetTrustQuota()

relayInfo.UserQuota = userQuota
-if userQuota > trustQuota {
+if trustQuota > 0 && userQuota > trustQuota {
    if !relayInfo.TokenUnlimited {
        tokenQuota := c.GetInt("token_quota")
        if tokenQuota > trustQuota {
            preConsumedQuota = 0
        }
    } else {
        preConsumedQuota = 0
    }
}

Or better, funnel this path through the same centralized shouldTrust() gate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@common/quota.go` around lines 4 - 8, GetTrustQuota() returning 0 doesn't
actually disable the bypass because service/pre_consume_quota.go still compares
balances to that numeric threshold; change the logic so the bypass is gated by
the centralized shouldTrust() check instead of raw numeric comparisons: in
service/pre_consume_quota.go replace the userQuota > trustQuota / tokenQuota >
trustQuota conditions with a call to shouldTrust(...) (or a boolean helper that
uses shouldTrust), and optionally make GetTrustQuota() return a clear sentinel
(e.g., -1) if you want a value-based disable; ensure the code paths that zero
out preConsumedQuota only run when shouldTrust() explicitly allows it.

}
4 changes: 2 additions & 2 deletions controller/midjourney.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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
}
}
Expand Down
7 changes: 7 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
15 changes: 11 additions & 4 deletions relay/mjproxy_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package relay

import (
"bytes"
"crypto/hmac"
"encoding/json"
"fmt"
"io"
Expand All @@ -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"
Expand All @@ -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{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep this diagnostic on the project logger.

log.Printf makes this message unconditional in production and bypasses the repo's log-level/redaction handling, so channel IDs and base URLs will now be emitted on every upscale/variation request.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@relay/mjproxy_handler.go` around lines 483 - 485, Replace the unconditional
log.Printf call in mjproxy_handler.go that prints originTask.ChannelId and
channel.GetBaseURL() with the project's logger API (use the same logger used
elsewhere in this package) at an appropriate debug/trace level, and ensure
sensitive fields are redacted or omitted; specifically update the
log.Printf("检测到此操作为放大、变换、重绘,获取原channel信息: %s,%s",
strconv.Itoa(originTask.ChannelId), channel.GetBaseURL()) usage to call the
shared logger (e.g., logger.Debugf or logger.Tracef) or to log a non-sensitive
message referencing the code locations around originTask.ChannelId and
channel.GetBaseURL() so it honors repo log-level and redaction settings.

midjRequest.Prompt = originTask.Prompt

Expand Down
3 changes: 2 additions & 1 deletion service/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
14 changes: 9 additions & 5 deletions service/file_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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. 快速检查内部缓存
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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 {
Expand All @@ -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":
Expand Down
70 changes: 67 additions & 3 deletions service/http_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In Go's net/http package, when Transport.Proxy is set (for example via http.ProxyFromEnvironment), does Transport.DialContext connect to the proxy address instead of the final request host, so the proxy performs destination DNS resolution?

💡 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:


ProxyFromEnvironment makes ssrfSafeDialContext validate the proxy “first hop”, not the final destination.

With http.Transport.Proxy set (e.g., http.ProxyFromEnvironment), Go’s Transport establishes the TCP connection via DialContext to the proxy host:port rather than the ultimate request host; the proxy then performs the connection/DNS to the destination later. This means the SSRF “pinned destination” protection provided by ssrfSafeDialContext may not apply to the final resolved target when an environment proxy is in play.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@service/http_client.go` around lines 114 - 120, The Transport currently uses
http.ProxyFromEnvironment which causes DialContext (ssrfSafeDialContext) to
validate the proxy host instead of the final destination; replace Proxy:
http.ProxyFromEnvironment with a custom Proxy function that first inspects and
validates the request's final target (e.g., req.URL.Host or host from CONNECT
for HTTPS) against your SSRF allowlist using the same validation logic as
ssrfSafeDialContext and only returns the environment proxy when the final
destination is permitted, otherwise return nil (or block) so DialContext still
applies to the ultimate target; update the Transport creation (the struct using
ssrfSafeDialContext and Proxy) to use that wrapper Proxy function.

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 == "" {
Expand Down Expand Up @@ -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),
}
Expand Down Expand Up @@ -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)
Expand Down
Loading