Skip to content

Conversation

@Laisky
Copy link
Owner

@Laisky Laisky commented Feb 7, 2026

This change implements SSRF protection for the UserContentRequestHTTPClient by blocking requests to internal network addresses (localhost, private IPs).

  • Added IsInternalIP to common/network/ip.go.
  • Modified common/client/init.go to use a custom DialContext with IP blocking.
  • Added BlockInternalUserContentRequests configuration to common/config/config.go.
  • Added a test case to verify the protection.

This prevents attackers from using One API to scan or access internal services via user-supplied URLs (e.g. in multimodal image messages).


PR created automatically by Jules for task 4650579899972121216 started by @Laisky

Summary by CodeRabbit

  • New Features

    • SSRF protection blocks requests to internal IPs for user-supplied content; proxy-exemption handling included.
    • New configuration option to toggle internal-IP blocking (enabled by default).
  • Documentation

    • Added SSRF-specific guidance covering prevention, fail-closed error handling, proxy considerations, and DNS rebinding risks.
  • Tests

    • Added tests covering SSRF blocking and proxy exemption behavior.

Co-authored-by: Laisky <4532436+Laisky@users.noreply.github.com>
@google-labs-jules
Copy link

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

Copilot AI review requested due to automatic review settings February 7, 2026 15:00
@coderabbitai
Copy link

coderabbitai bot commented Feb 7, 2026

📝 Walkthrough

Walkthrough

Adds SSRF protection for user-supplied content by adding a configurable BlockInternalUserContentRequests flag, an IsInternalIP utility, and a dialer Control on HTTP transports to block connections to internal/private IPs (with proxy exemptions).

Changes

Cohort / File(s) Summary
Configuration & Utilities
common/config/config.go, common/network/ip.go
Added BlockInternalUserContentRequests config variable (env flag, default true) and new IsInternalIP(net.IP) bool to classify loopback, link-local, private, and unspecified addresses.
HTTP Client Implementation
common/client/init.go
Extended transport factory to accept blockInternal; when enabled installs a net.Dialer with Control that looks up destination IP(s) and denies connections to internal IPs unless the destination matches configured proxy IP(s). Wired config flag into UserContentRequestHTTPClient and related transports.
Testing
common/client/init_test.go
Added tests TestUserContentRequestHTTPClient_SSRF and TestUserContentRequestHTTPClient_ProxyExemption verifying internal-IP blocking behavior and proxy-exemption handling; tests manipulate config flags and restore state.
Documentation / Guidance
.jules/sentinel.md
Appended three dated SSRF subsections covering vulnerability overview, fail-closed/error handling guidance, and proxy/DNS rebinding considerations.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant HTTPClient
    participant Transport
    participant DNS
    participant Network

    Client->>HTTPClient: make request to user URL
    HTTPClient->>Transport: prepare connection (DialContext)
    Transport->>DNS: resolve host (unless proxy configured)
    DNS-->>Transport: return IP(s)
    Transport->>Transport: Control: IsInternalIP? / check proxy exemption
    alt IP is internal and not exempt
        Transport-->>HTTPClient: return error (deny connection)
        HTTPClient-->>Client: surface SSRF-block error
    else allowed
        Transport->>Network: establish TCP connection (or connect to proxy)
        Network-->>Transport: connection established
        Transport-->>HTTPClient: connection ready
        HTTPClient-->>Client: return response
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 I sniff the nets both near and far,
I skip the loops and hidden spar,
For user links I keep a gate—
Proxy friends get a trusted plate.
Hop safe, little bytes, onward we dart!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Sentinel: Mitigate SSRF in user-supplied content fetching' clearly and specifically summarizes the main change - adding SSRF protection for user-supplied content. It is concise, avoids filler, and directly relates to the primary objective of the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch sentinel/fix-ssrf-user-content-4650579899972121216

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@common/client/init.go`:
- Around line 72-76: The fallback branch that constructs
UserContentRequestHTTPClient currently hardcodes Timeout to 30 * time.Second
instead of using the configured value; update the else branch that creates
UserContentRequestHTTPClient (which calls createTransport) to set Timeout to
config.UserContentRequestTimeout (the same config used in the proxy branch) so
the configured timeout is honored when UserContentRequestProxy is empty.
- Around line 38-50: The Control dialer closure currently returns a bare error
from net.SplitHostPort and allows connections when net.ParseIP(host) yields nil;
change it so net.SplitHostPort errors are wrapped using
github.com/Laisky/errors/v2 (e.g., errors.Wrapf(err, "split host: %s", address))
and if net.ParseIP(host) == nil return a wrapped error (e.g.,
errors.Errorf("SSRF protection: could not parse IP from host %q", host)) to
fail-closed, and replace the fmt.Errorf call with errors.Errorf when blocking
internal IPs (referencing dialer.Control, net.SplitHostPort, net.ParseIP, and
network.IsInternalIP).
🧹 Nitpick comments (1)
common/client/init_test.go (1)

32-43: Test relies on implicit default config; consider making it explicit and adding more cases.

The test depends on config.BlockInternalUserContentRequests defaulting to true. Explicitly setting it before Init() makes the test self-documenting and resilient to execution order. Also consider adding cases for:

  • BlockInternalUserContentRequests = false (should succeed/not block)
  • Private IPs like 10.0.0.1, 192.168.1.1
  • DNS names that resolve to internal IPs (the real SSRF vector via DNS rebinding)
♻️ Proposed improvement
 func TestUserContentRequestHTTPClient_SSRF(t *testing.T) {
-	// Test that UserContentRequestHTTPClient blocks internal IPs
-	Init()
+	t.Run("blocks loopback when enabled", func(t *testing.T) {
+		config.BlockInternalUserContentRequests = true
+		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")
+		_, 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")
+	})
+
+	t.Run("blocks private IP when enabled", func(t *testing.T) {
+		config.BlockInternalUserContentRequests = true
+		Init()
+
+		_, err := UserContentRequestHTTPClient.Get("http://10.0.0.1:12345")
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "SSRF protection")
+	})
+
+	t.Run("allows internal when disabled", func(t *testing.T) {
+		config.BlockInternalUserContentRequests = false
+		Init()
+
+		// Should fail with connection refused, NOT SSRF protection
+		_, err := UserContentRequestHTTPClient.Get("http://127.0.0.1:12345")
+		require.Error(t, err)
+		require.NotContains(t, err.Error(), "SSRF protection")
+	})
 }

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds SSRF defenses to the “user-supplied content” outbound HTTP client path, aiming to prevent fetching user-controlled URLs that resolve to internal network addresses (e.g., localhost/private IPs) when downloading assets like images.

Changes:

  • Added network.IsInternalIP helper for identifying internal/private-style IPs.
  • Added config.BlockInternalUserContentRequests toggle (default enabled) and wired SSRF blocking into UserContentRequestHTTPClient via a custom dialer.
  • Added a unit test asserting localhost access is blocked, plus a Sentinel knowledge-base entry documenting the SSRF mitigation.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
common/network/ip.go Adds IsInternalIP(net.IP) helper used by the SSRF protection logic.
common/config/config.go Introduces BLOCK_INTERNAL_USER_CONTENT_REQUESTS configuration toggle (default true).
common/client/init.go Implements internal-IP blocking by hooking a custom dialer into the user-content HTTP transport.
common/client/init_test.go Adds a test intended to verify SSRF blocking behavior.
.jules/sentinel.md Documents the SSRF vulnerability class and prevention strategy.

Comment on lines 39 to 47
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)
}
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.
Comment on lines 62 to 75
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.
Comment on lines 32 to 43
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")
}
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.
google-labs-jules bot and others added 3 commits February 7, 2026 15:24
This update enhances the SSRF protection for user-supplied content
fetching by implementing a fail-closed policy and standardized
error handling.

- Updated `Control` function to block unparseable IP addresses.
- Implemented error wrapping using `github.com/Laisky/errors/v2` per guidelines.
- Ensured all security-related errors provide consistent context.
- Verified protection with unit tests.

This ensures that any address that cannot be explicitly validated as
an external IP is blocked, providing more robust protection against SSRF.

Co-authored-by: Laisky <4532436+Laisky@users.noreply.github.com>
This change ensures that the `UserContentRequestHTTPClient` uses the
configured `UserContentRequestTimeout` in both proxy and non-proxy
initialization paths.

- Updated `common/client/init.go` to use `config.UserContentRequestTimeout` consistently.
- Ensured SSRF protection remains intact.
- Verified with unit tests.

Co-authored-by: Laisky <4532436+Laisky@users.noreply.github.com>
…S rebinding mitigation

This update addresses feedback regarding DNS rebinding and proxy
interaction.

- SSRF protection now uses `net.Dialer.Control` which intercepts the actual
  IP addresses after DNS resolution, effectively mitigating DNS rebinding.
- Explicitly configured proxies are now exempted from the internal IP check
  in `dialer.Control` to allow legitimate local egress proxies.
- Proxy hostname resolution is performed once at initialization to minimize
  latency in the dial path.
- Unit tests were expanded to cover hostname resolution (localhost),
  proxy exemptions, and explicit configuration control.
- Maintained fail-closed logic and standardized error wrapping.

This provides a robust, production-ready SSRF mitigation for fetching
user-supplied content.

Co-authored-by: Laisky <4532436+Laisky@users.noreply.github.com>
@github-actions
Copy link

github-actions bot commented Feb 7, 2026

Merging this branch will not change overall coverage

Impacted Packages Coverage Δ 🤖
github.com/Laisky/one-api/common/client 0.00% (ø)
github.com/Laisky/one-api/common/config 0.00% (ø)
github.com/Laisky/one-api/common/network 0.00% (ø)

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/Laisky/one-api/common/client/init.go 0.00% (ø) 0 0 0
github.com/Laisky/one-api/common/config/config.go 0.00% (ø) 0 0 0
github.com/Laisky/one-api/common/network/ip.go 0.00% (ø) 0 0 0

Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code.

Changed unit test files

  • github.com/Laisky/one-api/common/client/init_test.go

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@common/client/init.go`:
- Around line 39-48: The code currently appends the raw proxy hostname to
proxyIPs which will never match the resolved IP passed into dialer.Control;
change init logic around proxyURL/proxyHost so you do not add the raw hostname
to proxyIPs, instead perform net.LookupIP(proxyHost) and if it succeeds append
the returned IP strings to proxyIPs, and if it fails either log the lookup error
and return/fail initialization (treat like an invalid proxy URL) so the SSRF
check won't block the proxy; update references in the init routine that populate
proxyIPs (using proxyURL, proxyHost, proxyIPs) and ensure dialer.Control uses
those resolved IP entries.

Comment on lines +39 to +48
var proxyIPs []string
if proxyURL != nil {
proxyHost := proxyURL.Hostname()
proxyIPs = append(proxyIPs, proxyHost)
if ips, err := net.LookupIP(proxyHost); err == nil {
for _, ip := range ips {
proxyIPs = append(proxyIPs, ip.String())
}
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Raw proxy hostname in proxyIPs won't match in Control.

dialer.Control receives the resolved IP (not the original hostname) from the dialer. Line 42 appends the raw proxyHost string (e.g. "my-proxy.internal"), but this will never match the host variable in the Control callback since host is always an IP after resolution. This entry is harmless but dead — only the IPs added on lines 44-45 are effective.

If net.LookupIP fails (line 43), no resolved IPs are added, and the only entry in proxyIPs is the unresolvable hostname — meaning connections to the proxy will be blocked by the SSRF check. Consider logging the lookup failure or treating it as a fatal init error, similar to how an invalid proxy URL is handled on line 90.

Suggested improvement
 		if proxyURL != nil {
 			proxyHost := proxyURL.Hostname()
-			proxyIPs = append(proxyIPs, proxyHost)
-			if ips, err := net.LookupIP(proxyHost); err == nil {
+			// If proxyHost is already an IP, add it directly.
+			if pip := net.ParseIP(proxyHost); pip != nil {
+				proxyIPs = append(proxyIPs, pip.String())
+			} else if ips, err := net.LookupIP(proxyHost); err == nil {
 				for _, ip := range ips {
 					proxyIPs = append(proxyIPs, ip.String())
 				}
+			} else {
+				logger.Logger.Fatal("failed to resolve proxy hostname for SSRF exemption",
+					zap.String("proxy", proxyHost), zap.Error(err))
 			}
 		}
🤖 Prompt for AI Agents
In `@common/client/init.go` around lines 39 - 48, The code currently appends the
raw proxy hostname to proxyIPs which will never match the resolved IP passed
into dialer.Control; change init logic around proxyURL/proxyHost so you do not
add the raw hostname to proxyIPs, instead perform net.LookupIP(proxyHost) and if
it succeeds append the returned IP strings to proxyIPs, and if it fails either
log the lookup error and return/fail initialization (treat like an invalid proxy
URL) so the SSRF check won't block the proxy; update references in the init
routine that populate proxyIPs (using proxyURL, proxyHost, proxyIPs) and ensure
dialer.Control uses those resolved IP entries.

@Laisky Laisky closed this Feb 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant