Skip to content

fix: prevent SSRF bypass via 302 redirect, DNS rebinding and IPv4-mapped IPv6 CGNAT evasion#305

Merged
iFurySt merged 1 commit intomainfrom
fix/ssrf-redirect-dns-rebinding
Mar 11, 2026
Merged

fix: prevent SSRF bypass via 302 redirect, DNS rebinding and IPv4-mapped IPv6 CGNAT evasion#305
iFurySt merged 1 commit intomainfrom
fix/ssrf-redirect-dns-rebinding

Conversation

@iFurySt
Copy link
Copy Markdown
Member

@iFurySt iFurySt commented Mar 11, 2026

Summary by Sourcery

Harden internal network protections for HTTP tools by validating DNS-resolved addresses, pinning connections to the validated IP, and enforcing redirect checks to prevent SSRF and DNS rebinding bypasses.

Bug Fixes:

  • Detect IPv4-mapped IPv6 loopback, private, and CGNAT addresses as internal to close an SSRF evasion vector.
  • Block HTTP redirects (including multi-hop) that resolve to non-allowlisted internal addresses while still permitting explicitly allowlisted internal targets.
  • Pin HTTP client connections to the initially validated resolved IP to prevent DNS rebinding between validation and request execution.
  • Ensure tool endpoint validation consistently returns detailed errors while exposing the resolved IP needed for connection pinning.

Enhancements:

  • Extend HTTP client creation to support redirect-aware validation and optional DNS pinning while preserving existing proxy configuration behavior.
  • Add tests covering IPv4-mapped IPv6 address handling, redirect blocking/allowing, chained redirects, and pinned-address behavior for HTTP tools.

Tests:

  • Expand internal network and HTTP tool tests to cover IPv4-mapped IPv6 detection, redirect handling (including chained redirects), and pinned address behavior for tool HTTP clients.

@sourcery-ai
Copy link
Copy Markdown
Contributor

sourcery-ai Bot commented Mar 11, 2026

Reviewer's Guide

This PR hardens internal network protection for HTTP tools by making validateToolEndpoint return a pinned resolved IP, updating HTTP client creation to use that pinned address and enforce redirect-time revalidation, and extending internal address detection to cover IPv4-mapped IPv6 (including CGNAT), along with focused tests for these behaviors.

Sequence diagram for HTTP tool execution with pinned DNS and redirect validation

sequenceDiagram
    actor User
    participant GinContext as GinContext
    participant Server as Server
    participant Session as SessionConnection
    participant Validator as validateToolEndpoint
    participant HTTPClientFactory as createHTTPClient
    participant HTTPClient as http_Client
    participant Resolver as net_DefaultResolver
    participant Endpoint as ToolEndpoint

    User->>GinContext: HTTP request triggers tool
    GinContext->>Server: executeHTTPTool(ctx, conn, tool, args)
    Server->>Validator: validateToolEndpoint(ctx, req.URL)
    alt internalNetEnabled is false
        Validator-->>Server: resolvedIP "", nil
    else internalNetEnabled is true
        Validator->>Validator: parse host
        alt host is IP literal
            Validator-->>Server: resolvedIP "", nil
        else host is hostname
            Validator->>Resolver: LookupIPAddr(ctx, host)
            Resolver-->>Validator: []IPAddr
            Validator->>Validator: Unmap IPs, track firstAddr
            alt internal IP found and allowed
                Validator-->>Server: resolvedIP firstAddr, nil
            else internal IP found but not allowed
                Validator-->>Server: "", error
            else only external IPs
                Validator-->>Server: resolvedIP firstAddr, nil
            end
        end
    end

    alt validation error
        Server-->>GinContext: log and return error
        GinContext-->>User: HTTP error response
    else validation ok
        Server->>HTTPClientFactory: createHTTPClient(tool, pinnedAddr)
        HTTPClientFactory->>HTTPClientFactory: configure baseTransport (proxy/default)
        alt pinnedAddr != "" and DialContext is nil
            HTTPClientFactory->>HTTPClientFactory: set DialContext to always use pinnedAddr
        end
        HTTPClientFactory-->>Server: http.Client with CheckRedirect
        Server->>HTTPClient: Do(req)
        HTTPClient->>Endpoint: initial HTTP request
        loop redirects (max 10)
            Endpoint-->>HTTPClient: 3xx redirect response
            HTTPClient->>Validator: CheckRedirect hook calls validateToolEndpoint(ctx, redirectURL)
            alt redirect target blocked
                Validator-->>HTTPClient: error
                HTTPClient-->>Server: error
                Server-->>GinContext: log and return error
                GinContext-->>User: HTTP error response
            else redirect allowed
                Validator-->>HTTPClient: nil
                HTTPClient->>Endpoint: follow redirect
            end
        end
        Endpoint-->>HTTPClient: final response
        HTTPClient-->>Server: http.Response
        Server-->>GinContext: write tool response
        GinContext-->>User: HTTP success response
    end
Loading

Updated class diagram for internal network validation and HTTP client creation

classDiagram
    class Server {
        bool internalNetEnabled
        InternalNetACL internalNetACL

        executeHTTPTool(c GinContext, conn SessionConnection, tool *ToolConfig, args map[string]any) error
        createHTTPClient(tool *ToolConfig, pinnedAddr string) (*http.Client, error)
        validateToolEndpoint(ctx context.Context, endpoint *url.URL) (resolvedIP string, err error)
    }

    class InternalNetACL {
        allowsHost(host string) bool
        allowsAddr(addr netip.Addr) bool
    }

    class ToolConfig {
        string Name
        ProxyConfig *ProxyConfig
    }

    class ProxyConfig {
        string Type
        string Host
        int Port
    }

    class http_Client {
        http.RoundTripper Transport
        func(req *http.Request, via []*http.Request) error CheckRedirect
    }

    class http_Transport {
        func(*http.Request) (*url.URL, error) Proxy
        func(ctx context.Context, network string, addr string) (net.Conn, error) DialContext
    }

    class net_Resolver {
        LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
    }

    class netip_Addr {
        bool IsPrivate()
        bool IsLoopback()
        bool IsLinkLocalUnicast()
        bool IsLinkLocalMulticast()
        bool IsMulticast()
        bool IsUnspecified()
        bool Is4()
        bool IsGlobalUnicast()
        netip.Addr Unmap()
    }

    class Functions {
        isInternalAddr(addr netip.Addr) bool
    }

    class otelhttp_Transport {
        NewTransport(base http.RoundTripper) http.RoundTripper
    }

    Server --> InternalNetACL : uses internalNetACL
    Server --> http_Client : creates
    http_Client --> http_Transport : uses as Transport
    http_Transport <|-- otelhttp_Transport : wrapped by
    Server --> net_Resolver : uses for DNS
    Functions --> netip_Addr : checks internal IPs
    Server --> ToolConfig : uses tool
    ToolConfig --> ProxyConfig : has Proxy
    Functions : isInternalAddr(addr netip.Addr) bool
    netip_Addr : Unmap()
    Server : validateToolEndpoint(...) returns resolvedIP for pinning
    Server : createHTTPClient(...) installs CheckRedirect and pins DNS
Loading

File-Level Changes

Change Details Files
Strengthen internal address detection to handle IPv4-mapped IPv6 and return a pinned IP from endpoint validation to support DNS rebinding mitigation.
  • Normalize IPv4-in-IPv6 addresses with Addr.Unmap() before applying internal address checks so private, loopback, link-local, multicast and CGNAT ranges are correctly classified.
  • Change validateToolEndpoint to return both a resolved IP string and an error, skipping DNS when internal checks are disabled or the host is an IP literal, and tracking the first successfully resolved IP.
  • Update internal resolution logic to unmap each resolved IP before checks, detect any internal IPs, enforce ACLs accordingly, and return the first resolved IP for later pinning.
internal/core/internal_network.go
Make HTTP client creation server-aware, pin connections to the validated IP to prevent DNS rebinding, and re-validate every redirect target to block SSRF via 3xx chains.
  • Convert createHTTPClient into a Server method that accepts a pinnedAddr and builds a base http.Transport either from proxy configuration or by cloning the default transport.
  • Add DNS pinning by overriding DialContext when pinnedAddr is non-empty so that connections always target the validated IP while preserving the original port.
  • Wrap the transport with otelhttp and add a CheckRedirect callback that limits redirect depth and re-runs validateToolEndpoint on each redirect target, producing descriptive errors when blocked.
internal/core/tool.go
Wire the new validation and HTTP client behavior into tool execution and extend tests to cover the new SSRF defenses and IPv4-mapped IPv6 handling.
  • Adjust executeHTTPTool to capture the pinnedAddr returned from validateToolEndpoint and pass it into the new Server.createHTTPClient method.
  • Update existing tests to handle the new validateToolEndpoint signature and createHTTPClient method receiver, while preserving prior behavioral expectations.
  • Add tests that verify IPv4-mapped IPv6 internal and public addresses are classified correctly, redirects from public to internal (including chained redirects) are blocked unless allowlisted, redirected internal targets on the allowlist succeed, and pinnedAddr is honored when establishing connections.
internal/core/internal_network_test.go
internal/core/tool_test.go
internal/core/tool.go

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • When a SOCKS5 proxy is configured, baseTransport.DialContext is already set and the pinnedAddr logic is skipped, so DNS rebinding protection won’t apply in that mode; consider either explicitly documenting this limitation or layering the pinning on top of the SOCKS5 dialer where feasible.
  • In CheckRedirect, you call validateToolEndpoint but ignore the returned resolved IP, so redirects are not connection-pinned the way the initial request is; if you intend to protect against DNS rebinding on redirect targets as well, consider updating the pinning strategy to account for the latest validated address.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- When a SOCKS5 proxy is configured, `baseTransport.DialContext` is already set and the `pinnedAddr` logic is skipped, so DNS rebinding protection won’t apply in that mode; consider either explicitly documenting this limitation or layering the pinning on top of the SOCKS5 dialer where feasible.
- In `CheckRedirect`, you call `validateToolEndpoint` but ignore the returned resolved IP, so redirects are not connection-pinned the way the initial request is; if you intend to protect against DNS rebinding on redirect targets as well, consider updating the pinning strategy to account for the latest validated address.

## Individual Comments

### Comment 1
<location path="internal/core/tool.go" line_range="311-313" />
<code_context>
+	// Pin DNS: when validateToolEndpoint resolved a hostname to an IP, we
+	// force the transport to connect to that IP instead of re-resolving.
+	// This closes the DNS-rebinding TOCTOU window.
+	if pinnedAddr != "" && baseTransport.DialContext == nil {
+		stdDialer := &net.Dialer{}
+		baseTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
+			_, port, err := net.SplitHostPort(addr)
+			if err != nil {
</code_context>
<issue_to_address>
**🚨 issue (security):** DNS pinning is skipped for the default transport because DialContext is already non-nil there.

Because `http.DefaultTransport.(*http.Transport).Clone()` already sets `DialContext`, the pinning logic will not apply in the typical non-proxy case, so the default path does not actually get the DNS-rebinding protection described. Only the custom `&http.Transport{}` path does. To make pinning effective and consistent, wrap whatever `DialContext` is present (capture the original and delegate to it with `addr` rewritten to use `pinnedAddr`) instead of only setting `DialContext` when it is nil. This preserves existing dial behavior (timeouts, keep-alive, etc.) while still closing the TOCTOU window.
</issue_to_address>

### Comment 2
<location path="internal/core/internal_network_test.go" line_range="149-122" />
<code_context>
+	assert.Contains(t, err.Error(), "blocked")
+}
+
+func TestCreateHTTPClient_PinnedAddr(t *testing.T) {
+	// Verify that when a pinnedAddr is provided, the client connects to it
+	target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, "pinned")
+	}))
+	defer target.Close()
+
+	// Extract host:port from the test server to use as pinnedAddr
+	u, _ := url.Parse(target.URL)
+
+	s := &Server{}
+	cli, err := s.createHTTPClient(nil, u.Hostname())
+	assert.NoError(t, err)
+
+	// Request a URL with the same port but "127.0.0.1" host;
+	// the pinned addr should override the hostname resolution.
+	req, _ := http.NewRequest("GET", target.URL, nil)
+	resp, err := cli.Do(req)
+	assert.NoError(t, err)
+	assert.Equal(t, http.StatusOK, resp.StatusCode)
+	resp.Body.Close()
 }
</code_context>
<issue_to_address>
**issue (testing):** Strengthen `TestCreateHTTPClient_PinnedAddr` to actually verify that the pinned address is used

The test comment promises to verify that the client actually connects to `pinnedAddr`, but the current assertions only check for a 200 response. Because `httptest.NewServer` already binds to `127.0.0.1`, the test would still pass even if the pinned address were ignored.

To better match the stated intent, you could either:
- Inject a custom `net.Dialer` (or wrapper around `DialContext`) that records the dialed `addr` and assert the host matches `pinnedAddr`, or
- Update the test comment to say it only checks that a client created with `pinnedAddr` works, and add a more targeted unit test around the dial function to validate the pinning behavior.

That way, the test either truly enforces DNS pinning or accurately documents its limited scope.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread internal/core/tool.go
Comment on lines +311 to +313
if pinnedAddr != "" && baseTransport.DialContext == nil {
stdDialer := &net.Dialer{}
baseTransport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
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.

🚨 issue (security): DNS pinning is skipped for the default transport because DialContext is already non-nil there.

Because http.DefaultTransport.(*http.Transport).Clone() already sets DialContext, the pinning logic will not apply in the typical non-proxy case, so the default path does not actually get the DNS-rebinding protection described. Only the custom &http.Transport{} path does. To make pinning effective and consistent, wrap whatever DialContext is present (capture the original and delegate to it with addr rewritten to use pinnedAddr) instead of only setting DialContext when it is nil. This preserves existing dial behavior (timeouts, keep-alive, etc.) while still closing the TOCTOU window.

resp, err := cli.Do(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
resp.Body.Close()
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.

issue (testing): Strengthen TestCreateHTTPClient_PinnedAddr to actually verify that the pinned address is used

The test comment promises to verify that the client actually connects to pinnedAddr, but the current assertions only check for a 200 response. Because httptest.NewServer already binds to 127.0.0.1, the test would still pass even if the pinned address were ignored.

To better match the stated intent, you could either:

  • Inject a custom net.Dialer (or wrapper around DialContext) that records the dialed addr and assert the host matches pinnedAddr, or
  • Update the test comment to say it only checks that a client created with pinnedAddr works, and add a more targeted unit test around the dial function to validate the pinning behavior.

That way, the test either truly enforces DNS pinning or accurately documents its limited scope.

@iFurySt iFurySt merged commit 3b6dc33 into main Mar 11, 2026
6 checks passed
@iFurySt iFurySt deleted the fix/ssrf-redirect-dns-rebinding branch March 11, 2026 02:26
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