Skip to content

feat(networking): port ToolHive's networking package into core - #223

Merged
jhrozek merged 4 commits into
mainfrom
feat/networking
Aug 10, 2026
Merged

feat(networking): port ToolHive's networking package into core#223
jhrozek merged 4 commits into
mainfrom
feat/networking

Conversation

@jhrozek

@jhrozek jhrozek commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Ports the networking package from stacklok/toolhive's pkg/networking into toolhive-core: a hardened outbound HTTP client (SSRF egress policy — private-IP/link-local dial blocking, same-host redirect policy), a body-capped JSON fetch helper, HTTP error mapping, URL/issuer validation helpers, and port/listener allocation utilities.

  • networking/fetch.go, http_client.go, http_error.go, utilities.go, port.go (+ tests)
  • Review fixes applied on top of the straight port:
    • Private-IP check now fails closed on an unparsable host instead of treating it as safe
    • ErrPrivateIpAddress converted to a real sentinel error (errors.Is-compatible)
    • Build() now installs a same-host redirect policy whenever WithTokenFromFile is used, closing a bearer-token-replay-on-redirect gap — confirmed to also exist unmitigated upstream in toolhive's pkg/auth/token.go
    • NewHostScopedClientBuilderWithReader added for env-reader dependency injection (additive, existing constructor unchanged)
    • FindOrUsePort/FindOrUseListener/ParsePortSpec now reject out-of-range ports instead of silently substituting an unrelated port
    • WithEnvReader normalizes a nil reader instead of panicking
  • A new race-free port API (FindAvailableListener, FindOrUseListener) added alongside the ported FindAvailable/FindOrUsePort, which have a known bind-then-close-then-return race that is still unfixed upstream

Closes #221.

Test plan

  • task lint — 0 issues
  • task test (race detector) — all packages pass
  • task license-check — clean

🤖 Generated with Claude Code

jhrozek and others added 4 commits August 10, 2026 13:13
Adds toolhive-core/networking, ported from ToolHive's pkg/networking so the
two stop maintaining separate copies of the same security-critical logic. The
private-IP/NAT64 CIDR table in particular is easy to get subtly wrong, and
forking it across two independently released repos gives the compiler no way
to keep them in sync.

Package name and exported identifiers are preserved so ToolHive's migration is
an import swap for its ~47 call sites. Three adaptations were required:

- The reverse dependency is gone. ToolHive's IsLocalhost delegates to
  pkg/oauthproto.IsLoopbackHost; toolhive-core must never import toolhive, so
  that body is inlined. Behavior is identical, and the separate, more thorough
  IsLoopbackHost (SplitHostPort + ParseIP) is untouched.

- INSECURE_DISABLE_URL_VALIDATION now flows through env.Reader instead of a
  bare os.Getenv. A shared library must not disable a security check based on
  a process-wide env var whose provenance it cannot see. Existing exported
  signatures are unchanged; WithEnvReader and the *WithReader variants are
  additive.

- The dial guard and DisableKeepAlives are now inseparable. The guard checks
  the resolved address per dial, so a pooled connection skips it on later
  requests -- which is why it must be paired with keep-alives off. ToolHive
  ships that exact bug in its JWKS client (pkg/auth/token.go), while two other
  call sites get it right. WithDisableKeepAlives is therefore deleted: the
  branch that installs the guard is the branch that disables keep-alives, so
  the unsafe combination is unreachable rather than merely discouraged.

Not ported: port.go, which is the sole gopsutil consumer (dragging in
ebitengine/purego for one Connections() call) and is local-port allocation plus
OAuth pre-registered-client logic, not egress policy; and IsBridgeMode with the
NetworkIsolation* messages, which are container network-mode concerns.

Coverage 89.3%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Fail closed in AddressReferencesPrivateIp when net.ParseIP can't
  parse the host, instead of silently treating it as safe.
- Turn ErrPrivateIpAddress into a real sentinel error (ErrPrivateIPAddress)
  so callers can use errors.Is, matching ErrRedirectRefused.
- Wire SameHostRedirectPolicy into Build() whenever WithTokenFromFile is
  used: oauth2.Transport re-adds the bearer token on every redirect hop,
  so an unset CheckRedirect lets a malicious/compromised server replay
  the token to an attacker-controlled host on a 30x. This same gap is
  confirmed live and unmitigated upstream in toolhive's pkg/networking
  today (pkg/auth/token.go's RFC 7662 and JWKS/OIDC clients).
- Add NewHostScopedClientBuilderWithReader so callers can inject an
  env.Reader for testing; the existing constructor becomes a thin
  wrapper with unchanged behavior.
- Fix a dangling doc-comment reference to a package that only exists in
  the main toolhive repo, not here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Port port.go/port_test.go from toolhive's pkg/networking to keep the
package in one place rather than split across two repos. Preserves all
7 exported symbols (IsAvailable, FindAvailable, FindOrUsePort,
ValidateCallbackPort, IsPreRegisteredClient, GetProcessOnPort,
ParsePortSpec) with unchanged signatures so toolhive can later swap its
import path with zero call-site changes.

GetProcessOnPort pulls in github.com/shirou/gopsutil/v4 for
cross-platform process-on-port lookup.

FindAvailable/IsAvailable have a bind-then-close-then-return race:
confirmed still live and unfixed upstream (toolhive#6141 tracked fixing
the 7 production call sites and was closed without a fix landing).
Rather than port that gap forward silently, add a race-free
alternative alongside the preserved API: FindAvailableListener and
FindOrUseListener return a still-open *net.TCPListener instead of a
bare int, so nothing else can grab the port before the caller binds
it. A concurrency test with 20 goroutines confirms no two ever get the
same port.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- FindOrUsePort/FindOrUseListener: an explicit port outside 1-65535
  (e.g. -1, 65536) was falling into the "port busy, pick another one"
  branch and silently returning an unrelated random port. For
  pre-registered OAuth callback configs this could conceal a bad
  redirect URI. Reject out-of-range explicit ports up front; 0 still
  means auto-select.
- ParsePortSpec: validate containerPort (1-65535) and hostPort
  (0-65535) instead of only checking they parse as integers, so specs
  like "-1:0", "70000:8001", "8000:99999" now fail with an attributed
  error instead of surfacing a confusing failure later. Host port 0 is
  intentionally still passed through unchanged, since Docker's own
  PortBinding.HostPort treats "0" as dynamic allocation.
- WithEnvReader: normalize a nil reader to &env.OSReader{} instead of
  storing it as-is, matching the fallback ValidatingTransport already
  had. Fixes a nil-interface-method panic in
  NewHostScopedClientBuilderWithReader(..., nil).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@jhrozek
jhrozek merged commit 557b22c into main Aug 10, 2026
5 checks passed
@jhrozek
jhrozek deleted the feat/networking branch August 10, 2026 14:33
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.

Port ToolHive's networking package into core

2 participants