Skip to content

Commit af78d88

Browse files
committed
fix(security): add dial-time SSRF guard for participant InferenceUrl (#1470)
Registration-time validation of participant InferenceUrl only rejects literal private IPs and localhost strings; it never resolves DNS, because ValidateBasic must stay deterministic for consensus. A hostname therefore always passes, and DNS rebinding needs no new on-chain tx. Honest nodes then dial that URL during mandatory payload retrieval and devshard peer communication, so the dial can land on loopback, link-local metadata (169.254.169.254), or RFC1918. Fix at dial time, where the resolved IP is known. net.Dialer.Control runs after resolution and once per candidate IP, with the address already "ip:port", so it vets every real dial target: each dual-stack candidate and each redirect hop. That is what defeats rebinding, which a resolve-then-connect check cannot. One guard (common/httpguard), reusing the registration gate's predicate (utils.IsPrivateIP) so both agree on what "private" means. Two choke points: - common/validation.PayloadRetrievalClient: the default client for executor payload fetches, which also refuses redirects. Covers every FetchPayloadsHTTP caller. - devshard/transport.getTransport: gateway/host peer dials, whose baseURL is the on-chain Participant.InferenceUrl. Clients that dial our own infrastructure are deliberately left unguarded: the devshardd inference engine's ML-node client, and devshardctl's chain RPC / public-API clients. Those targets come from local config and legitimately live on localhost/private ranges. Local dev, docker-compose, and e2e register docker hostnames that resolve to private IPs, so an opt-out is required. Secure by default: the guard is active unless DEVSHARD_ALLOW_PRIVATE_ADDRESSES is set, which is enabled only in local-test-net, testenv (gencompose), and testermint. Production templates leave it unset. Also fixes an IPv4-mapped IPv6 gap in the existing predicate, so ::ffff:169.254.169.254 is now classified private.
1 parent 1026f2a commit af78d88

14 files changed

Lines changed: 435 additions & 16 deletions

File tree

common/httpguard/httpguard.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Package httpguard provides a dial-time SSRF guard for HTTP clients that
2+
// connect to participant-controlled URLs taken from chain state (devshard peer
3+
// host URLs, executor payload endpoints derived from Participant.InferenceUrl).
4+
//
5+
// The guard hooks net.Dialer.Control, which the stdlib calls after DNS
6+
// resolution and once per candidate IP, with address already "ip:port".
7+
// Checking there vets every real dial target: each dual-stack candidate and
8+
// each redirect hop's fresh dial. That is what defeats DNS rebinding, which a
9+
// resolve-then-connect check cannot -- the gap between the lookup and the
10+
// connect is exactly the attack.
11+
//
12+
// The on-chain registration gate (inference-chain x/inference/utils
13+
// ValidateURLWithSSRFProtection) can only reject literal private IPs: it runs
14+
// inside a stateless ValidateBasic, so it must stay deterministic and cannot
15+
// resolve DNS. This guard is the enforcement point, and it reuses that gate's
16+
// predicate (utils.IsPrivateIP) so both agree on what "private" means.
17+
package httpguard
18+
19+
import (
20+
"fmt"
21+
"net"
22+
"net/http"
23+
"sync/atomic"
24+
"syscall"
25+
"time"
26+
27+
"github.com/productscience/inference/x/inference/utils"
28+
)
29+
30+
// allowPrivate toggles the guard process-wide. Default false = secure.
31+
//
32+
// It is package-level rather than per-client because devshard's transport cache
33+
// is global and keyed only by baseURL, so a per-client setting could not be
34+
// threaded into an already-cached dialer. DialControl reads it on every dial,
35+
// so the toggle applies to clients constructed before it is set (notably the
36+
// package-level validation.PayloadRetrievalClient).
37+
var allowPrivate atomic.Bool
38+
39+
// SetAllowPrivate configures whether guarded dialers may connect to
40+
// private/internal addresses. Callers wire this once at startup from their
41+
// environment (devshardd reads DEVSHARD_ALLOW_PRIVATE_ADDRESSES). Set true only
42+
// in local dev / docker-compose / e2e, where hosts register docker-internal
43+
// hostnames that resolve to private IPs.
44+
func SetAllowPrivate(allow bool) {
45+
allowPrivate.Store(allow)
46+
}
47+
48+
// AllowPrivate reports whether the guard is currently disabled.
49+
func AllowPrivate() bool {
50+
return allowPrivate.Load()
51+
}
52+
53+
// DialControl is a net.Dialer.Control hook that rejects connections to
54+
// private/internal IP addresses unless SetAllowPrivate(true) was called.
55+
func DialControl(_, address string, _ syscall.RawConn) error {
56+
if allowPrivate.Load() {
57+
return nil
58+
}
59+
host, _, err := net.SplitHostPort(address)
60+
if err != nil {
61+
// address is already "ip:port" at this point; fail closed.
62+
return fmt.Errorf("ssrf guard: cannot parse dial address %q: %w", address, err)
63+
}
64+
ip := net.ParseIP(host)
65+
if ip == nil {
66+
return fmt.Errorf("ssrf guard: unresolved dial address %q", address)
67+
}
68+
if utils.IsPrivateIP(ip) {
69+
return fmt.Errorf("ssrf guard: blocked dial to private address %s", ip)
70+
}
71+
return nil
72+
}
73+
74+
// NewDialer returns a dialer carrying the guard, with the same timeouts the
75+
// stdlib default transport uses.
76+
func NewDialer() *net.Dialer {
77+
return &net.Dialer{
78+
Timeout: 30 * time.Second,
79+
KeepAlive: 30 * time.Second,
80+
Control: DialControl,
81+
}
82+
}
83+
84+
// NewNoRedirectClient builds a guarded *http.Client that also refuses to follow
85+
// 3xx responses, so a public host cannot redirect a fetch to a private target.
86+
// The caller observes the 3xx as a non-200 status. Redirect refusal is defense
87+
// in depth: DialControl already re-checks each hop's dial.
88+
//
89+
// The transport is cloned from http.DefaultTransport so proxy/keep-alive/HTTP2
90+
// behavior matches the stdlib.
91+
func NewNoRedirectClient(timeout time.Duration) *http.Client {
92+
transport := http.DefaultTransport.(*http.Transport).Clone()
93+
transport.DialContext = NewDialer().DialContext
94+
return &http.Client{
95+
Timeout: timeout,
96+
Transport: transport,
97+
CheckRedirect: func(*http.Request, []*http.Request) error {
98+
return http.ErrUseLastResponse
99+
},
100+
}
101+
}

common/httpguard/httpguard_test.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package httpguard
2+
3+
import (
4+
"context"
5+
"net"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
"time"
11+
)
12+
13+
// resolveTo builds a client whose dialer is the guarded one but whose DNS
14+
// answers are forced to target, simulating a participant who registered a
15+
// hostname resolving (or rebinding) to an address of their choosing.
16+
func resolveTo(t *testing.T, target string) *http.Client {
17+
t.Helper()
18+
client := NewNoRedirectClient(5 * time.Second)
19+
transport := client.Transport.(*http.Transport)
20+
dialer := NewDialer()
21+
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
22+
_, port, err := net.SplitHostPort(addr)
23+
if err != nil {
24+
return nil, err
25+
}
26+
return dialer.DialContext(ctx, network, net.JoinHostPort(target, port))
27+
}
28+
return client
29+
}
30+
31+
func requireBlocked(t *testing.T, err error) {
32+
t.Helper()
33+
if err == nil {
34+
t.Fatal("expected the dial to be blocked, got nil error")
35+
}
36+
if !strings.Contains(err.Error(), "ssrf guard") {
37+
t.Fatalf("expected an ssrf guard error, got %v", err)
38+
}
39+
}
40+
41+
func TestDialControlBlocksPrivateTargets(t *testing.T) {
42+
SetAllowPrivate(false)
43+
44+
// Each case is a hostname that resolves to a private target: the literal
45+
// forms an attacker can register plus the ones a naive string check misses.
46+
cases := map[string]string{
47+
"loopback": "127.0.0.1",
48+
"loopback_upper_8": "127.5.6.7",
49+
"cloud_metadata": "169.254.169.254",
50+
"rfc1918_10": "10.0.0.1",
51+
"rfc1918_172": "172.16.0.1",
52+
"rfc1918_192": "192.168.1.1",
53+
"unspecified": "0.0.0.0",
54+
"ipv6_loopback": "::1",
55+
"ipv6_link_local": "fe80::1",
56+
"ipv6_ula": "fc00::1",
57+
"ipv4_mapped_v6": "::ffff:10.0.0.1",
58+
"ipv4_mapped_meta_v6": "::ffff:169.254.169.254",
59+
}
60+
61+
for name, target := range cases {
62+
t.Run(name, func(t *testing.T) {
63+
client := resolveTo(t, target)
64+
_, err := client.Get("http://ssrf.attacker.tld/")
65+
requireBlocked(t, err)
66+
})
67+
}
68+
}
69+
70+
// A hostname that resolves to loopback is the core of issue #1470: the on-chain
71+
// registration gate cannot reject it (no DNS in ValidateBasic), so the dial is
72+
// where it must fail. Uses a real server to prove the request never lands.
73+
func TestGuardBlocksHostnameResolvingToLoopback(t *testing.T) {
74+
SetAllowPrivate(false)
75+
76+
var reached bool
77+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
78+
reached = true
79+
w.WriteHeader(http.StatusOK)
80+
}))
81+
t.Cleanup(server.Close)
82+
83+
_, port, err := net.SplitHostPort(strings.TrimPrefix(server.URL, "http://"))
84+
if err != nil {
85+
t.Fatal(err)
86+
}
87+
88+
client := resolveTo(t, "127.0.0.1")
89+
_, err = client.Get("http://ssrf.attacker.tld:" + port + "/")
90+
requireBlocked(t, err)
91+
if reached {
92+
t.Fatal("guard let the request through to the loopback server")
93+
}
94+
}
95+
96+
// Decimal and hex host forms are alternate spellings of 127.0.0.1. The guard
97+
// checks the resolved IP, so the spelling is irrelevant -- this pins that.
98+
func TestGuardBlocksNumericLoopbackSpellings(t *testing.T) {
99+
SetAllowPrivate(false)
100+
101+
for _, raw := range []string{"http://2130706433/", "http://0x7f000001/", "http://127.1/"} {
102+
t.Run(raw, func(t *testing.T) {
103+
client := NewNoRedirectClient(5 * time.Second)
104+
_, err := client.Get(raw)
105+
requireBlocked(t, err)
106+
})
107+
}
108+
}
109+
110+
// A public host answering 302 -> 127.0.0.1 must not reach the private target.
111+
// The redirect is refused outright; had it been followed, the new hop's dial
112+
// would hit the guard too.
113+
func TestNoRedirectClientDoesNotFollowRedirectToPrivate(t *testing.T) {
114+
SetAllowPrivate(true) // let the public-side server on loopback be reachable
115+
116+
var privateReached bool
117+
private := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118+
privateReached = true
119+
}))
120+
t.Cleanup(private.Close)
121+
122+
public := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
123+
http.Redirect(w, r, private.URL, http.StatusFound)
124+
}))
125+
t.Cleanup(public.Close)
126+
127+
client := NewNoRedirectClient(5 * time.Second)
128+
resp, err := client.Get(public.URL)
129+
if err != nil {
130+
t.Fatalf("expected the 3xx to surface as a response, got %v", err)
131+
}
132+
defer resp.Body.Close()
133+
134+
if resp.StatusCode != http.StatusFound {
135+
t.Fatalf("expected the caller to observe 302, got %d", resp.StatusCode)
136+
}
137+
if privateReached {
138+
t.Fatal("client followed the redirect into the private target")
139+
}
140+
}
141+
142+
// Dev/test environments register docker-internal hostnames that resolve to
143+
// private IPs, so the opt-out has to actually let them through.
144+
func TestAllowPrivateLetsPrivateTargetsThrough(t *testing.T) {
145+
SetAllowPrivate(true)
146+
t.Cleanup(func() { SetAllowPrivate(false) })
147+
148+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
149+
w.WriteHeader(http.StatusOK)
150+
}))
151+
t.Cleanup(server.Close)
152+
153+
client := NewNoRedirectClient(5 * time.Second)
154+
resp, err := client.Get(server.URL)
155+
if err != nil {
156+
t.Fatalf("allowPrivate should permit the loopback dial, got %v", err)
157+
}
158+
defer resp.Body.Close()
159+
if resp.StatusCode != http.StatusOK {
160+
t.Fatalf("expected 200, got %d", resp.StatusCode)
161+
}
162+
}
163+
164+
func TestPublicAddressAllowedInBothModes(t *testing.T) {
165+
for _, allow := range []bool{false, true} {
166+
SetAllowPrivate(allow)
167+
if err := DialControl("tcp", "93.184.216.34:80", nil); err != nil {
168+
t.Fatalf("public address rejected with allowPrivate=%v: %v", allow, err)
169+
}
170+
}
171+
SetAllowPrivate(false)
172+
}
173+
174+
func TestDialControlFailsClosedOnMalformedAddress(t *testing.T) {
175+
SetAllowPrivate(false)
176+
requireBlocked(t, DialControl("tcp", "not-an-address", nil))
177+
requireBlocked(t, DialControl("tcp", "still.a.hostname:80", nil))
178+
}

common/validation/payload_retrieval.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package validation
22

33
import (
44
"common/completionapi"
5+
"common/httpguard"
56
"common/logging"
67
"common/utils"
78
"context"
@@ -34,9 +35,13 @@ var ErrEpochStale = errors.New("inference epoch too old, validation no longer us
3435
var ErrPayloadGone = errors.New("payload no longer available on executor")
3536

3637
// PayloadRetrievalClient is the default HTTP client for payload retrieval.
37-
var PayloadRetrievalClient = &http.Client{
38-
Timeout: 30 * time.Second,
39-
}
38+
//
39+
// The request URL is built from the executor's on-chain InferenceUrl, which the
40+
// executor controls, so this client carries the dial-time SSRF guard and refuses
41+
// redirects. Without it a participant could register a hostname that resolves
42+
// (or later rebinds) to loopback/RFC1918/cloud-metadata and make every validator
43+
// fetching its payloads connect there. See common/httpguard.
44+
var PayloadRetrievalClient = httpguard.NewNoRedirectClient(30 * time.Second)
4045

4146
// PayloadResponse matches the executor endpoint response.
4247
// Used by both chain validation and devshard validation paths.

devshard/cmd/devshardctl/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"time"
1616

1717
"common/chain"
18+
"common/httpguard"
1819
"devshard/bridge"
1920
"devshard/state"
2021
"devshard/types"
@@ -128,6 +129,11 @@ var gatewayRuntimeBuilder = buildRuntime
128129
func main() {
129130
ConfigurePoCRequestMode(os.Getenv("DEVSHARD_POC_REQUEST_MODE"))
130131
ConfigureCapacityAwareLimits(os.Getenv("DEVSHARD_CAPACITY_AWARE_LIMITS"))
132+
// Wire the dial-time SSRF guard before any outbound dial. Host URLs come
133+
// from chain state and are participant-controlled; the gateway's own chain
134+
// RPC/public-API clients are unguarded, so private self-hosted endpoints
135+
// keep working. Default secure; dev/e2e opt out via env.
136+
httpguard.SetAllowPrivate(readBoolEnv("DEVSHARD_ALLOW_PRIVATE_ADDRESSES", false))
131137
flags := parseCLIFlags()
132138
runtimeOpts := mustLoadRuntimeOptions(flags)
133139
gatewayStore := mustOpenGatewayStore(runtimeOpts.baseStorageDir)

devshard/cmd/devshardd/app.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"time"
1313

1414
"common/chain"
15+
"common/httpguard"
1516
mlnodeclient "common/nodemanager"
1617
commrc "common/runtimeconfig"
1718
"common/storage/payloads"
@@ -77,6 +78,15 @@ func buildApp(ctx context.Context, cfg runtimeConfig) (_ *devshardApp, err error
7778
return nil, fmt.Errorf("create data dir %s: %w", cfg.DataDir, err)
7879
}
7980

81+
// Wire the dial-time SSRF guard before anything can dial out. Guarded
82+
// clients read the flag per dial, so this also covers the package-level
83+
// validation.PayloadRetrievalClient constructed at init.
84+
httpguard.SetAllowPrivate(cfg.AllowPrivateAddresses)
85+
if cfg.AllowPrivateAddresses {
86+
slog.Warn("SSRF guard disabled: dials to private/internal addresses are allowed",
87+
"env", "DEVSHARD_ALLOW_PRIVATE_ADDRESSES")
88+
}
89+
8090
var closers closeStack
8191
defer func() {
8292
if err != nil {

devshard/cmd/devshardd/config.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,20 @@ import (
2626
var sdkConfigOnce sync.Once
2727

2828
type runtimeConfig struct {
29-
Port int
30-
AdminAddr string
31-
DataDir string
32-
BinaryLogVersion string
33-
RuntimeVersion string
34-
ProtocolVersion string
35-
NodeManagerAddr string
36-
HostEventsEnabled bool
29+
Port int
30+
AdminAddr string
31+
DataDir string
32+
BinaryLogVersion string
33+
RuntimeVersion string
34+
ProtocolVersion string
35+
NodeManagerAddr string
36+
HostEventsEnabled bool
37+
// AllowPrivateAddresses disables the dial-time SSRF guard on outbound
38+
// connections to participant-controlled URLs (peer devshard hosts, executor
39+
// payload endpoints). Default false = secure. Set true only in local dev /
40+
// docker-compose / e2e, where hosts register docker-internal hostnames that
41+
// resolve to private IPs. Env: DEVSHARD_ALLOW_PRIVATE_ADDRESSES.
42+
AllowPrivateAddresses bool
3743
ValidationRetryInterval time.Duration
3844
ValidationLeaseTTL time.Duration
3945
ShutdownGrace time.Duration
@@ -146,6 +152,7 @@ func loadRuntimeConfig(args []string, protocolVersion, linkBinaryVersion string)
146152
ProtocolVersion: protocolVersion,
147153
NodeManagerAddr: envOr("NODE_MANAGER_ADDR", "localhost:9400"),
148154
HostEventsEnabled: envBoolOr("DEVSHARD_HOST_EVENTS_ENABLED", true),
155+
AllowPrivateAddresses: envBoolOr("DEVSHARD_ALLOW_PRIVATE_ADDRESSES", false),
149156
ValidationRetryInterval: retryInterval,
150157
ValidationLeaseTTL: leaseTTL,
151158
ShutdownGrace: shutdownGrace,

0 commit comments

Comments
 (0)