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
1 change: 1 addition & 0 deletions common/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ func initConstantEnv() {
constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300)
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 64)
constant.MaxRelayResponseMB = GetEnvOrDefault("MAX_RELAY_RESPONSE_MB", 64)
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 128)
// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128)
Expand Down
29 changes: 27 additions & 2 deletions common/page_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ import (
"github.com/gin-gonic/gin"
)

// MaxPageSize caps the page_size accepted from query parameters. List
// endpoints use PageSize directly as SQL LIMIT; an unbounded value would let a
// single request pull every matching row into memory (F-37). GetPageQuery
// clamps every page size to this value.
const MaxPageSize = 100

// MaxPage caps the page number so (page-1)*pageSize cannot overflow into a
// negative SQL OFFSET (F-37 hardening).
const MaxPage = 100000

type PageInfo struct {
Page int `json:"page"` // page num 页码
PageSize int `json:"page_size"` // page size 页大小
Expand Down Expand Up @@ -73,9 +83,24 @@ func GetPageQuery(c *gin.Context) *PageInfo {
pageInfo.PageSize = ItemsPerPage
}
}
// F-37: cap page size. List endpoints use PageSize directly as SQL LIMIT;
// an unbounded value (e.g. page_size=2000000000) makes a single request
// pull every matching row into memory (memory/DB exhaustion DoS).
if pageInfo.PageSize > MaxPageSize {
pageInfo.PageSize = MaxPageSize
}
if pageInfo.PageSize < 1 {
pageInfo.PageSize = 1
}
if pageInfo.Page > MaxPage {
pageInfo.Page = MaxPage
}
if pageInfo.Page < 1 {
pageInfo.Page = 1
}

if pageInfo.PageSize > 100 {
pageInfo.PageSize = 100
if pageInfo.PageSize > MaxPageSize {
pageInfo.PageSize = MaxPageSize
}

return pageInfo
Expand Down
6 changes: 6 additions & 0 deletions constant/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ package constant
var StreamingTimeout int
var DifyDebug bool
var MaxFileDownloadMB int

// MaxRelayResponseMB bounds the size of a non-stream upstream response body
// read fully into memory by relay handlers. A malicious or misbehaving
// upstream (e.g. free proxies) must not be able to OOM the gateway with an
// unbounded JSON body (F-36).
var MaxRelayResponseMB int
var StreamScannerMaxBufferMB int
var ForceStreamOption bool
var CountToken bool
Expand Down
15 changes: 12 additions & 3 deletions controller/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {

if newAPIError == nil {
relayInfo.LastError = nil
if channel != nil {
service.ClearChannelErrorStreak(channel.Id)
}
return
}

Expand Down Expand Up @@ -365,9 +368,12 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
if service.ShouldDisableChannel(err) && channelError.AutoBan {
gopool.Go(func() {
service.DisableChannel(channelError, err.ErrorWithStatusCode())
})
userID := c.GetInt("id")
if service.RecordChannelErrorAndShouldDisable(channelError.ChannelId, userID) {
gopool.Go(func() {
service.DisableChannel(channelError, err.ErrorWithStatusCode())
})
}
}

if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) {
Expand Down Expand Up @@ -556,6 +562,9 @@ func RelayTask(c *gin.Context) {

result, taskErr = relay.RelayTaskSubmit(c, relayInfo)
if taskErr == nil {
if channel != nil {
service.ClearChannelErrorStreak(channel.Id)
}
break
}

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ require (
require (
github.com/DmitriyVTitov/size v1.5.0 // indirect
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/beorn7/perks v1.0.1 // indirect
Expand Down
41 changes: 41 additions & 0 deletions relay/channel/api_request.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

common2 "github.com/QuantumNous/new-api/common"
rootconstant "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant"
Expand All @@ -25,6 +26,32 @@ import (
"github.com/gorilla/websocket"
)

// limitReadCloser caps how many bytes can be read from an upstream response
// body and fails closed: reads past the limit return an error instead of a
// silent truncation, and Close is delegated to the wrapped body so transport
// resources are released (F-36).
type limitReadCloser struct {
rc io.ReadCloser
n int64
max int64
}

func (l *limitReadCloser) Read(p []byte) (int, error) {
if l.n >= l.max {
return 0, fmt.Errorf("upstream response body exceeds %d bytes", l.max)
}
if int64(len(p)) > l.max-l.n {
p = p[:l.max-l.n]
}
n, err := l.rc.Read(p)
l.n += int64(n)
return n, err
}

func (l *limitReadCloser) Close() error {
return l.rc.Close()
}

// ApplyUpstreamBodyMetadata restores metadata that net/http cannot infer from
// a ReplayableBody. Callers must pass the original body because NewRequest
// hides its dynamic type behind req.Body's io.ReadCloser wrapper.
Expand Down Expand Up @@ -537,6 +564,20 @@ func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
if resp == nil {
return nil, errors.New("resp is nil")
}
// F-36: cap non-stream upstream response bodies. Handlers read them fully
// with io.ReadAll; an unbounded body from a misbehaving/malicious upstream
// (e.g. free proxies) would OOM the gateway. Streams are read
// incrementally and are not capped here.
if !info.IsStream && resp.Body != nil {
maxBytes := int64(rootconstant.MaxRelayResponseMB) << 20
if maxBytes <= 0 {
maxBytes = 64 << 20
}
// Fail closed on oversized bodies (ReadAll past the limit returns a
// distinct error instead of a silent truncation) and delegate Close to
// the original body so transport resources are released.
resp.Body = &limitReadCloser{rc: resp.Body, max: maxBytes}
}
if common2.DebugEnabled {
policy := service.NormalizeHTTPTransportPolicy(info.ChannelSetting)
logger.LogDebug(c, fmt.Sprintf(
Expand Down
22 changes: 22 additions & 0 deletions relay/channel/openai/relay_realtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package openai

import (
"fmt"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
Expand All @@ -25,6 +26,20 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
clientConn := info.ClientWs
targetConn := info.TargetWs

// F-39: an idle WebSocket (no client messages, no pings) must not hold the
// connection forever (slowloris resource exhaustion, uncharged idle
// sessions). Enforce a message-size cap and a read deadline refreshed on
// activity; gorilla auto-replies to client pings, and the pong handler
// refreshes the deadline so well-behaved keep-alive clients stay alive.
const realtimeReadLimit = 8 << 20
const realtimeReadTimeout = 90 * time.Second
clientConn.SetReadLimit(realtimeReadLimit)
clientConn.SetPongHandler(func(string) error {
return clientConn.SetReadDeadline(time.Now().Add(realtimeReadTimeout))
})
targetConn.SetReadLimit(realtimeReadLimit)
targetConn.SetReadDeadline(time.Now().Add(realtimeReadTimeout))

clientClosed := make(chan struct{})
targetClosed := make(chan struct{})
sendChan := make(chan []byte, 100)
Expand All @@ -46,6 +61,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
case <-c.Done():
return
default:
_ = clientConn.SetReadDeadline(time.Now().Add(realtimeReadTimeout))
_, message, err := clientConn.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
Expand Down Expand Up @@ -106,6 +122,7 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
case <-c.Done():
return
default:
_ = targetConn.SetReadDeadline(time.Now().Add(realtimeReadTimeout))
_, message, err := targetConn.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
Expand Down Expand Up @@ -210,6 +227,11 @@ func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.
case <-c.Done():
}

// Close both connections so whichever reader is still blocked in
// ReadMessage unblocks promptly instead of waiting out the read deadline.
_ = clientConn.Close()
_ = targetConn.Close()

if usage.TotalTokens != 0 {
_ = preConsumeUsage(c, info, usage, sumUsage)
}
Expand Down
63 changes: 63 additions & 0 deletions service/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package service
import (
"fmt"
"strings"
"sync"
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
Expand All @@ -11,6 +13,67 @@ import (
"github.com/QuantumNous/new-api/setting/operation_setting"
)

// channelDisableWindow / channelDisableThreshold / channelDisableMinUsers
// implement a windowed, multi-user gate before an upstream error may
// auto-disable a channel. F-24: without a gate, a single request that hits an
// upstream quota/rate-limit message (e.g. "You exceeded your current quota")
// disables the whole channel for every user. Requiring several error events
// from at least two distinct users inside a short window makes accidental or
// single-account triggered disables materially harder while still catching
// genuinely broken channels.
const (
channelDisableErrorWindow = 60 * time.Second
channelDisableErrorThreshold = 3
channelDisableMinDistinctUsers = 2
)

type channelErrorStreak struct {
mu sync.Mutex
windowStart time.Time
users map[int]struct{}
count int
}

var channelErrorStreaks sync.Map // channelID int -> *channelErrorStreak

// RecordChannelErrorAndShouldDisable records an auto-ban-worthy error for a
// channel and reports whether the disable threshold has been crossed.
func RecordChannelErrorAndShouldDisable(channelID int, userID int) bool {
if userID <= 0 {
// Unauthenticated/background errors: treat as one distinct pseudo-user
// so a genuinely broken channel still converges with enough events.
userID = -1
}
now := time.Now()
raw, _ := channelErrorStreaks.LoadOrStore(channelID, &channelErrorStreak{
windowStart: now,
users: make(map[int]struct{}),
})
s := raw.(*channelErrorStreak)
s.mu.Lock()
defer s.mu.Unlock()
if now.Sub(s.windowStart) > channelDisableErrorWindow {
s.windowStart = now
s.users = make(map[int]struct{})
s.count = 0
}
s.users[userID] = struct{}{}
s.count++
if s.count >= channelDisableErrorThreshold && len(s.users) >= channelDisableMinDistinctUsers {
// Only delete the streak if it is still the live entry: a concurrent
// ClearChannelErrorStreak plus a new error may have replaced it, and
// deleting the new streak would discard fresh error events.
return channelErrorStreaks.CompareAndDelete(channelID, s)
}
return false
}

// ClearChannelErrorStreak resets the disable gate for a channel after a
// successful request.
func ClearChannelErrorStreak(channelID int) {
channelErrorStreaks.Delete(channelID)
}

func formatNotifyType(channelId int, status int) string {
return fmt.Sprintf("%s_%d_%d", dto.NotifyTypeChannelUpdate, channelId, status)
}
Expand Down
10 changes: 7 additions & 3 deletions service/file_service.go
Original file line number Diff line number Diff line change
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) {
// 安全加固:限制递归深度,防止构造的嵌套 iprp/ipco 链导致无界递归栈耗尽 DoS (F-19)。
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