fix(security): dial-time SSRF guard for participant InferenceUrl (#1470) - #1491
fix(security): dial-time SSRF guard for participant InferenceUrl (#1470)#1491Ryanchen911 wants to merge 1 commit into
Conversation
|
Registration only blocks literal private IPs / localhost on Informational (not High/Medium):
Disposition: Treat as incomplete defense-in-depth on egress. Optional hardening (dial-time private-IP block) is fine; severity Informational. For an Informational / constrained-path issue, this PR is larger and more invasive than it needs to be — and it still misses a live sink. ~21 files / +622 for wiring + duplication, not because the idea needs that surface Verdict on #1491Technically valid approach, overscoped for the real risk. CI is green (API /devshard / chain / lint). Dial-time But for an Informational / constrained-path issue, this PR is larger and more invasive than it needs to be — and it still misses a live sink. Smarter / smaller designTwo choke points cover almost everything that matters:
Optional: one env ( Skip or defer: classic TA/ That is roughly 3–6 files, not 21. Bottom line
|
Address @a-kuprin review (gonka-ai#1491): for an Informational, constrained-path finding the guard does not need to touch every sink. Keep the two choke points that cover the mandatory participant-URL dials and revert the rest. Kept: - payload-retrieval client (guarded dial + no redirect) — covers all FetchPayloadsHTTP callers incl. standalone devshardd - devshard/transport.getTransport Control — gateway/host peer dials Reverted (defense-in-depth churn, not needed for the real risk): - TA -> executor forward client split (post_chat_handler / server) - PoC proof client AllowPrivate plumbing - long ValidateBasic essay -> short comment - unused exported IsLocalhost helper The duplicated isPrivateOrLocalAddr in devshard/transport stays: the devshard module has no dependency on github.com/productscience/inference, so the chain util cannot be imported there (documented in the mirror). Co-Authored-By: Claude <noreply@anthropic.com>
|
Thanks for the review — agreed on scoping to the real risk. Pushed 489b80f narrowing the PR to the two choke points you identified. Kept
Reverted
Net is now ~11 source files (config + one env switch + the two guards + tests + deploy toggles), down from 21. On "standalone payload default client still unguarded": it is guarded in this PR — the standalone On the duplicated |
|
hi @a-kuprin , can you please check this PR again, thanks a lot! |
|
@Ryanchen911 I've added it to v5 milestone. I believe it will be merged next week |
|
@Ryanchen911 Please rebase. It should be targeted to devshard-0.2.15-v5 |
…onka-ai#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.
489b80f to
af78d88
Compare
|
Rebased onto Both choke points are now exactly the two you named:
The duplicated All decentralized-api changes are gone. v5 moved payload retrieval into The flag is a single One note on scope, since you'd asked about the standalone path earlier: devshardctl also dials through I kept own-ML-node and self-hosted-endpoint dials unguarded, and verified each: the ML engine uses its own client ( Tests: four modules build; Still happy to trim further if you'd prefer the devshardctl/Testermint wiring split into a separate PR. |
Fixes #1470.
Summary
Closes the residual SSRF left after #505/#534. The registration gate only rejects literal private IPs and does no DNS resolution, so any participant-controlled
InferenceUrlhostname passes — and DNS rebinding / attacker-controlled DNS can force validator/TA DAPIs to connect to loopback, cloud metadata (169.254.169.254), or RFC1918 during mandatory payload retrieval, PoC proof fetch, and TA→executor forwarding.The real fix is at dial time: a
net.Dialer.Controlhook re-checks the resolved IP on every connection (including each redirect hop and dual-stack candidate), which is what defeats rebinding. A registration-time resolve alone cannot.What changed
Dial-time guard applied to all sinks that dial a participant-controlled
InferenceUrl:internal/validation/payload_retrieval.gopoc/proof_client.gointernal/server/public/post_chat_handler.gointernal/devshard,cmd/devshardddevshard/transportNew helpers:
decentralized-api/internal/httpguard(reuses the chain'sutils.IsPrivateIP) anddevshard/transport/ssrf.go(a deliberate mirror, since thedevshardmodule can't import the chain utils).Redirects: the payload/proof clients refuse redirects (
http.ErrUseLastResponse), so a publicInferenceUrlcannot302a validator to a private target (attack chain C).Correctness: shared clients
The server and devshard HTTP clients are shared between the operator's own trusted ML nodes (
node.InferenceUrl(), routinely on localhost/private ranges) and untrusted participant URLs. Blanket-guarding them would break local ML calls. The guard is therefore applied via separate clients used only for the participant path — the trusted local-node path is never guarded.Deployment switch (secure by default)
Local dev / docker-compose / testermint register docker-internal hostnames (e.g.
http://genesis-api:9000) that resolve to private IPs, so a hard dial-time block would break them. A new switch —DAPI_API__ALLOW_PRIVATE_INFERENCE_URLS(andDEVSHARD_ALLOW_PRIVATE_ADDRESSESfor standalone devshardd), default false = blocked — is enabled only in those environments (docker-compose-base.yml,DockerGroup.kt). Production templates leave it unset, so real validators stay protected.Registration gate
Intentionally left DNS-free:
ValidateBasicis stateless and must be deterministic across validators — network I/O there would break consensus, and a registration-time lookup can't stop rebinding regardless. A code comment documents this. Also fixes an IPv4-mapped IPv6 gap inisPrivateIPso::ffff:169.254.169.254is caught.Tests
127.0.0.1, →169.254.169.254, public→302→private, IPv4-mapped IPv6, decimal/hex host encodings, RFC1918/ULA/link-local.allowPrivate=truepermits all of the above (dev/test regression).Verified with
go build ./...andgo test ./...acrossdecentralized-api,devshard, andinference-chain(only pre-existing Docker-dependent testcontainers tests are skipped locally for lack of a Docker host).