Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@
**Vulnerability:** The password reset endpoint explicitly indicated whether an email was registered and performed network-bound email sending synchronously.
**Learning:** Returning specific error messages for non-existent accounts allows attackers to enumerate users. Synchronous email sending allows timing attacks to distinguish between existing and non-existing accounts.
**Prevention:** Always return a uniform success response for public endpoints that accept identifiers (e.g., password reset, registration). Perform latency-heavy operations like email sending in a background goroutine to ensure consistent response times.

## 2025-05-22 - [SSRF Protection for User-Supplied Content]
**Vulnerability:** The application fetched user-supplied resources (like images) using a standard HTTP client without restricting access to internal network addresses.
**Learning:** Even with a proxy, direct outbound requests from the server to user-controlled URLs pose an SSRF risk. Differentiating between "relay" requests (trusted/admin-configured) and "user-content" requests (untrusted) allows applying stricter security controls where needed without breaking core functionality.
**Prevention:** Implement a custom `net.Dialer.Control` function to block connections to internal/private IP ranges (RFC 1918, etc.) for clients that handle untrusted URLs.
36 changes: 30 additions & 6 deletions common/client/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ package client
import (
"crypto/tls"
"fmt"
"net"
"net/http"
"net/url"
"syscall"
"time"

"github.com/Laisky/zap"

"github.com/songquanpeng/one-api/common/config"
"github.com/songquanpeng/one-api/common/logger"
"github.com/songquanpeng/one-api/common/network"
)

// HTTPClient is the default outbound client used for relay requests.
Expand All @@ -24,9 +27,30 @@ var UserContentRequestHTTPClient *http.Client

// Init builds the shared HTTP clients with proxy and timeout settings derived from configuration.
func Init() {
// Create a transport with HTTP/2 disabled to avoid stream errors in CI environments
createTransport := func(proxyURL *url.URL) *http.Transport {
// Create a transport with HTTP/2 disabled to avoid stream errors in CI environments.
// Optionally blocks internal IP addresses to mitigate SSRF risks.
createTransport := func(proxyURL *url.URL, blockInternal bool) *http.Transport {
dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}

if blockInternal {
dialer.Control = func(networkName, address string, c syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip != nil && network.IsInternalIP(ip) {
return fmt.Errorf("SSRF protection: internal IP %s is blocked", ip)
}
Comment on lines 51 to 71
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

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

The SSRF guard only blocks when the dial target host is a literal IP (net.ParseIP(host) != nil). Requests to hostnames like localhost, *.internal, or DNS rebinding domains will bypass this check because the dialer will resolve them later. Consider resolving host via net.Resolver.LookupIPAddr (and blocking if any resolved IP is internal), or performing the check earlier at the request layer using req.URL.Hostname() (including during redirects).

Copilot uses AI. Check for mistakes.
return nil
}
}

transport := &http.Transport{
DialContext: dialer.DialContext,
TLSNextProto: make(map[string]func(authority string, c *tls.Conn) http.RoundTripper), // Disable HTTP/2
}
if proxyURL != nil {
Expand All @@ -42,12 +66,12 @@ func Init() {
logger.Logger.Fatal(fmt.Sprintf("USER_CONTENT_REQUEST_PROXY set but invalid: %s", config.UserContentRequestProxy))
}
UserContentRequestHTTPClient = &http.Client{
Transport: createTransport(proxyURL),
Transport: createTransport(proxyURL, config.BlockInternalUserContentRequests),
Timeout: time.Second * time.Duration(config.UserContentRequestTimeout),
}
} else {
UserContentRequestHTTPClient = &http.Client{
Transport: createTransport(nil),
Transport: createTransport(nil, config.BlockInternalUserContentRequests),
Timeout: 30 * time.Second, // Set a reasonable default timeout
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

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

When USER_CONTENT_REQUEST_PROXY is set, the transport dials only the proxy address, so this internal-IP block will (a) not prevent SSRF to internal targets via the proxy, and (b) potentially break legitimate setups where the proxy itself is on localhost/private IP (common for egress proxies). Recommend skipping the block for proxy connections and instead validating the request destination hostname/IPs before issuing the request (and on redirects), or explicitly exempting the configured proxy host from the internal-IP check.

Copilot uses AI. Check for mistakes.
}
}
Expand All @@ -58,9 +82,9 @@ func Init() {
if err != nil {
logger.Logger.Fatal(fmt.Sprintf("RELAY_PROXY set but invalid: %s", config.RelayProxy))
}
transport = createTransport(proxyURL)
transport = createTransport(proxyURL, false)
} else {
transport = createTransport(nil)
transport = createTransport(nil, false)
}

if config.RelayTimeout == 0 {
Expand Down
13 changes: 13 additions & 0 deletions common/client/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,16 @@ func TestInit(t *testing.T) {
require.NotNil(t, HTTPClient)
require.NotNil(t, ImpatientHTTPClient)
}

func TestUserContentRequestHTTPClient_SSRF(t *testing.T) {
// Test that UserContentRequestHTTPClient blocks internal IPs
Init()

// Try to fetch from localhost (which is an internal IP)
// We use a random port that is likely not listening to avoid connection refused
// but the DialControl should block it before it even tries to connect.
_, err := UserContentRequestHTTPClient.Get("http://127.0.0.1:12345")
require.Error(t, err)
require.Contains(t, err.Error(), "SSRF protection")
require.Contains(t, err.Error(), "blocked")
}
Comment on lines 33 to 80
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

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

This test relies on the process environment defaulting BLOCK_INTERNAL_USER_CONTENT_REQUESTS=true; if CI or another test sets it false, this will fail. It also only tests a literal IP, not the hostname path (http://localhost:...) that currently bypasses the new dialer check. Suggest explicitly setting/restoring config.BlockInternalUserContentRequests within the test and adding a localhost case (or other hostname resolving to loopback) so the test matches the intended protection.

Copilot uses AI. Check for mistakes.
8 changes: 8 additions & 0 deletions common/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -659,6 +659,14 @@ var (
// Unit: seconds
UserContentRequestTimeout = env.Int("USER_CONTENT_REQUEST_TIMEOUT", 30)

// BlockInternalUserContentRequests determines whether to block requests to
// internal network addresses (localhost, private IPs) when fetching
// user-supplied content. This is a critical security feature to prevent SSRF.
//
// Environment variable: BLOCK_INTERNAL_USER_CONTENT_REQUESTS
// Default: true
BlockInternalUserContentRequests = env.Bool("BLOCK_INTERNAL_USER_CONTENT_REQUESTS", true)

// MaxInlineImageSizeMB limits the size of images that can be inlined as base64
// to prevent oversized payloads from overwhelming upstream providers.
//
Expand Down
5 changes: 5 additions & 0 deletions common/network/ip.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,8 @@ func IsIpInSubnets(ctx context.Context, ip string, subnets string) bool {
}
return false
}

// IsInternalIP returns true if the IP is a loopback, link-local, private, or unspecified address.
func IsInternalIP(ip net.IP) bool {
return ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsPrivate() || ip.IsUnspecified()
}
Loading