stacklok / stacklok/toolhive

Consolidate duplicated JWKS caching and IP classification

Open
#6,232 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement good-tenth-issue
Dominant language
Go
Stars
2.2k
Forks
300
Avg merge
1d 15h
Merged PRs (30d)
184

Description

Summary

Two pieces of security-relevant logic exist in more than one place in this repo, and in both cases the copies have drifted: one copy got hardened and the other didn't. This proposes consolidating them, which also closes the gaps the drift left behind.

Both were found while reviewing #6149 (RFC 8693 token exchange with external OIDC issuers), which fixed its own copies. Neither is an urgent exploit; the point is that fixing the copies individually leaves the next drift to happen again.

1. Two JWKS caches over the same library

pkg/auth/token.go (TokenValidator, ~1370 lines) and pkg/authserver/server/tokenexchange/multi_issuer_validator.go both:

  • build an HTTP client with networking.NewHttpClientBuilder()
  • create a jwk.Cache over httprc
  • register lazily on first use, under a mutex, caching the registration error
  • handle httprc.ErrNotReady() explicitly to distinguish "registered but first fetch pending" from "not registered"

They were written independently and converged on the same design, including the same non-obvious ErrNotReady insight. But #6149 added two protections that pkg/auth/token.go does not have:

No response-body cap on the JWKS path. token.go caps its discovery reads with io.LimitReader(resp.Body, maxResponseSize) (token.go:141, :324), but the JWKS fetch goes through jwx, whose own ceiling is httprc.MaxBufferSize (~1 GB), and jwx then does an unbounded io.ReadAll under that before parsing. #6149 wraps the client's transport with http.MaxBytesReader to restore the cap; token.go registers at :715 with only jwk.WithHTTPClient.

No bound on the refresh interval. Without jwk.WithConstantInterval, httprc derives the refresh interval from the endpoint's own Cache-Control/Expires, clamped only to [DefaultMinInterval, DefaultMaxInterval] = [15 minutes, 30 days]. So the issuer being validated chooses how long its keys stay cached. A key revoked upstream but still present locally keeps validating tokens until the next refresh — the unknown-kid refresh path doesn't help, since the kid is still known.

Worth noting the background refresh is autonomous: only the first registration is request-driven, every later fetch is dispatched by httprc's timer with no request involved, so neither gap is gated by client authentication.

Proposal: extract the narrow shared piece — register a JWKS URL with a body-capped client and a pinned refresh interval — into one helper (pkg/auth/ already has discovery/ and dcr/ subpackages that could host it), and have both validators call it. The rest of the two implementations differs legitimately: token.go is single-issuer, the token-exchange one is multi-issuer with per-issuer transport policy.

2. Zone-blind IP classification, repeated

net.ParseIP returns nil for a zoned IPv6 literal (::1%lo0, fe80::1%en0, ::ffff:127.0.0.1%zzz), while netip.ParseAddr parses them and Go's own resolver accepts them — net.lookupIPAddr short-circuits literals through netip.ParseAddr and preserves the zone.

Several guards classify addresses with the ip := net.ParseIP(host); ip != nil && ... shape, which therefore skips classification entirely for a zoned literal rather than rejecting it:

  • pkg/networking/utilities.goAddressReferencesPrivateIp, used by the dial-time guard installed when WithPrivateIPs(false)
  • pkg/authserver/server/tokenexchange/multi_issuer_validator.goValidateJWKSURL
  • pkg/authserver/config.govalidateJWKSEndpointURL (delegates to the above)
  • pkg/skills/gitresolver/reference.go
  • pkg/plugins/pluginsvc/oci.go (preceded by an IsLocalhost check, so partially covered)
  • cmd/thv-operator/pkg/validation/url_validation.go

Measured: through a client built with WithPrivateIPs(false), a request to http://[::ffff:127.0.0.1%25zzz]:<port>/ reaches a loopback listener and the handler runs, while plain http://127.0.0.1:<port>/ is correctly blocked. The 4-in-6 form is the one that matters — TCPAddr.family() sees an IPv4 address, drops the zone, and issues an ordinary connect.

This is hardening rather than a readily exploitable path from ToolHive: the affected inputs are operator configuration or a discovery document from an already-trusted issuer, and on HTTPS paths certificate verification against a loopback literal fails anyway. But the guards don't do what they are written to do.

Proposal: one zone-aware helper in pkg/networking (netip.ParseAddr-based, returning nil for non-literals so hostnames still pass through), used at the sites above. Separately, AddressReferencesPrivateIp should fail closed on any host it cannot classify — it only ever receives a resolved literal from the dialer's Control hook (net/sock_posix.go passes raddr.String()), never a hostname, so refusing the unclassifiable case is safe there and fixes the general problem rather than just the zone instance.

Note pkg/oauthproto/cimd/fetch.go is currently safe only incidentally — net.ParseIP returning nil leads to "no valid address found" rather than a dial — so it's worth including so a later refactor can't silently open it.

Scope

Not blocking #6149, which fixed its own copies. Filed so the consolidation happens once rather than each caller rediscovering it.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the JWKS registration in pkg/auth/token.go and pkg/authserver/server/tokenexchange/multi_issuer_validator.go, then compare the protections added in #6149. Review the listed net.ParseIP call sites, especially pkg/networking/utilities.go and the token-exchange validator, alongside pkg/oauthproto/cimd/fetch.go. Done means the shared JWKS behavior is consolidated and all listed address guards consistently handle zoned literals and unclassifiable resolved hosts.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
networking, security
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
42/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.