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
2 changes: 2 additions & 0 deletions internal/bootstrap/data/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,8 @@ func InitialSettings() []model.SettingItem {
{Key: conf.TaskCopyThreadsNum, Value: strconv.Itoa(conf.Conf.Tasks.Copy.Workers), Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
{Key: conf.TaskDecompressDownloadThreadsNum, Value: strconv.Itoa(conf.Conf.Tasks.Decompress.Workers), Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
{Key: conf.TaskDecompressUploadThreadsNum, Value: strconv.Itoa(conf.Conf.Tasks.DecompressUpload.Workers), Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
{Key: conf.ProxyMaxConcurrentRequestsPerIP, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE, Help: "Limit the maximum number of concurrent proxy requests per IP. -1 means unlimited, 0 means disabled. NOTE: This limit relies on the client IP address. To prevent IP spoofing via headers, ensure your reverse proxy correctly overwrites X-Forwarded-For and X-Real-IP headers. Also, these counts are per-process; in multi-instance deployments, the effective limit is limit * N instances."},
{Key: conf.ProxyClientIPHeader, Value: "", Type: conf.TypeString, Group: model.TRAFFIC, Flag: model.PRIVATE, Help: "Custom HTTP header to extract the client IP for proxy limits (e.g., 'X-Forwarded-For', 'CF-Connecting-IP'). Leave empty to use strict strict remote address (c.Request.RemoteAddr) which prevents IP spoofing but breaks behind reverse proxies without transparent IP capabilities."},
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

The help text contains a duplicated word: "strict strict remote address". Please fix to "strict remote address" (or rephrase) to avoid confusing admins reading the setting description.

Suggested change
{Key: conf.ProxyClientIPHeader, Value: "", Type: conf.TypeString, Group: model.TRAFFIC, Flag: model.PRIVATE, Help: "Custom HTTP header to extract the client IP for proxy limits (e.g., 'X-Forwarded-For', 'CF-Connecting-IP'). Leave empty to use strict strict remote address (c.Request.RemoteAddr) which prevents IP spoofing but breaks behind reverse proxies without transparent IP capabilities."},
{Key: conf.ProxyClientIPHeader, Value: "", Type: conf.TypeString, Group: model.TRAFFIC, Flag: model.PRIVATE, Help: "Custom HTTP header to extract the client IP for proxy limits (e.g., 'X-Forwarded-For', 'CF-Connecting-IP'). Leave empty to use strict remote address (c.Request.RemoteAddr) which prevents IP spoofing but breaks behind reverse proxies without transparent IP capabilities."},

Copilot uses AI. Check for mistakes.
{Key: conf.StreamMaxClientDownloadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
{Key: conf.StreamMaxClientUploadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
{Key: conf.StreamMaxServerDownloadSpeed, Value: "-1", Type: conf.TypeNumber, Group: model.TRAFFIC, Flag: model.PRIVATE},
Expand Down
2 changes: 2 additions & 0 deletions internal/conf/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ const (
TaskMoveThreadsNum = "move_task_threads_num"
TaskDecompressDownloadThreadsNum = "decompress_download_task_threads_num"
TaskDecompressUploadThreadsNum = "decompress_upload_task_threads_num"
ProxyMaxConcurrentRequestsPerIP = "proxy_max_concurrent_requests_per_ip"
ProxyClientIPHeader = "proxy_client_ip_header"
StreamMaxClientDownloadSpeed = "max_client_download_speed"
StreamMaxClientUploadSpeed = "max_client_upload_speed"
StreamMaxServerDownloadSpeed = "max_server_download_speed"
Expand Down
80 changes: 80 additions & 0 deletions server/middlewares/ip_limit.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package middlewares

import (
"errors"
"net"
"net/http"
"strings"
"sync"

"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
"github.com/OpenListTeam/OpenList/v4/server/common"
"github.com/gin-gonic/gin"
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The counters are stored in package-level variables, so in a horizontally-scaled deployment each process maintains its own independent counts. A single IP effectively gets configured_limit * N concurrent requests across N instances. This is not necessarily a bug, but it will surprise operators who set a tight limit and still see it exceeded. Add a comment here (or in the setting's description string in bootstrap/data/setting.go) calling this out, e.g. // NOTE: counts are per-process; in multi-instance deployments the effective limit is limit*N.


// NOTE: Counts are per-process; in multi-instance deployments the effective limit is limit * N.
var (
proxyIPCounts = make(map[string]int)
proxyIPCountsMu sync.Mutex
)

// ProxyIPConcurrencyLimit limits the maximum concurrent server proxy requests per IP Address
func ProxyIPConcurrencyLimit() gin.HandlerFunc {
return func(c *gin.Context) {
limit := setting.GetInt(conf.ProxyMaxConcurrentRequestsPerIP, -1)

if limit < 0 {
// -1 or less means unlimited
c.Next()
return
}

if limit == 0 {
// 0 means completely disabled
common.ErrorPage(c, errors.New("Proxy is disabled"), http.StatusForbidden)
return
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

c.ClientIP() resolves the client address from X-Forwarded-For / X-Real-IP headers, which a client can freely forge if Gin's trusted proxy list hasn't been configured. An attacker could rotate spoofed IPs in that header on every request and bypass the concurrency limit entirely. Either add a code comment (and a note to the setting description) that engine.SetTrustedProxies(...) must be configured for this limit to have any teeth, or expose a separate admin option to use c.Request.RemoteAddr for strict enforcement (accepting that it breaks deployments behind CDNs/reverse-proxies).

}

ipHeader := setting.GetStr(conf.ProxyClientIPHeader)
var ip string

// Extract IP based on user configuration to prevent spoofing
if ipHeader != "" {
ip = c.Request.Header.Get(ipHeader)
if idx := strings.Index(ip, ","); idx != -1 {
ip = ip[:idx]
Comment on lines +42 to +46
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

The comment says this header-based extraction is "to prevent spoofing", but the code will trust whatever value is in the configured header. To reduce spoofing/misconfiguration impact, validate/normalize the extracted value (e.g., net.ParseIP after trimming/splitting) and fall back to RemoteAddr when invalid/empty; also update the comment to reflect the actual trust model.

Copilot uses AI. Check for mistakes.
}
ip = strings.TrimSpace(ip)
}

// Fallback to strict remote address if missing or not configured
if ip == "" {
ip = c.Request.RemoteAddr
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
}

proxyIPCountsMu.Lock()
count := proxyIPCounts[ip]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The 429 response body is hand-rolled as gin.H{"code": ..., "message": ..., "data": nil}. Double-check that this matches the project's shared error-response helper (if one exists, e.g. common.ErrorResp or similar). Using a different shape here would make client-side error handling inconsistent with every other endpoint in the API.

if count >= limit {
proxyIPCountsMu.Unlock()
common.ErrorPage(c, errors.New("Too Many Proxy Requests from this IP"), http.StatusTooManyRequests)
return
}
proxyIPCounts[ip] = count + 1
proxyIPCountsMu.Unlock()

defer func() {
proxyIPCountsMu.Lock()
proxyIPCounts[ip]--
if proxyIPCounts[ip] <= 0 {
delete(proxyIPCounts, ip)
}
proxyIPCountsMu.Unlock()
}()

c.Next()
}
}
9 changes: 5 additions & 4 deletions server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,18 @@ func Init(e *gin.Engine) {
S3(g.Group("/s3"))

downloadLimiter := middlewares.DownloadRateLimiter(stream.ClientDownloadLimit)
proxyConcurrencyLimiter := middlewares.ProxyIPConcurrencyLimit()
signCheck := middlewares.Down(sign.Verify)
g.GET("/d/*path", middlewares.PathParse, signCheck, downloadLimiter, handles.Down)
g.GET("/p/*path", middlewares.PathParse, signCheck, downloadLimiter, handles.Proxy)
g.GET("/p/*path", middlewares.PathParse, signCheck, proxyConcurrencyLimiter, downloadLimiter, handles.Proxy)
g.HEAD("/d/*path", middlewares.PathParse, signCheck, handles.Down)
g.HEAD("/p/*path", middlewares.PathParse, signCheck, handles.Proxy)
g.HEAD("/p/*path", middlewares.PathParse, signCheck, proxyConcurrencyLimiter, handles.Proxy)
archiveSignCheck := middlewares.Down(sign.VerifyArchive)
g.GET("/ad/*path", middlewares.PathParse, archiveSignCheck, downloadLimiter, handles.ArchiveDown)
g.GET("/ap/*path", middlewares.PathParse, archiveSignCheck, downloadLimiter, handles.ArchiveProxy)
g.GET("/ap/*path", middlewares.PathParse, archiveSignCheck, proxyConcurrencyLimiter, downloadLimiter, handles.ArchiveProxy)
g.GET("/ae/*path", middlewares.PathParse, archiveSignCheck, downloadLimiter, handles.ArchiveInternalExtract)
g.HEAD("/ad/*path", middlewares.PathParse, archiveSignCheck, handles.ArchiveDown)
g.HEAD("/ap/*path", middlewares.PathParse, archiveSignCheck, handles.ArchiveProxy)
g.HEAD("/ap/*path", middlewares.PathParse, archiveSignCheck, proxyConcurrencyLimiter, handles.ArchiveProxy)
g.HEAD("/ae/*path", middlewares.PathParse, archiveSignCheck, handles.ArchiveInternalExtract)

g.GET("/sd/:sid", middlewares.EmptyPathParse, middlewares.SharingIdParse, downloadLimiter, handles.SharingDown)
Expand Down