Skip to content

Upstream-DCR resolver dials with an unguarded HTTP client (no private-IP/SSRF protection, no injection hook) #5825

Description

@tgrunnagle

Summary

The upstream-DCR resolver dials the discovery URL and the registration
endpoint with an HTTP client that has no SSRF protection, and it gives the
caller no way to supply a guarded client. A caller cannot restrict these
outbound calls to non-private hosts, so a DCR upstream is an SSRF vector
(CWE-918) even when the caller validates the configured URL up front.

Where

pkg/auth/dcr/resolver.go builds its HTTP client internally:

httpClient := newDCRHTTPClient(req.InitialAccessToken)

newDCRHTTPClient wraps oauthproto.NewDefaultDCRClient(), which is a plain
client with timeouts and redirect refusal only — no dialer control, no
private-IP guard:

// pkg/oauthproto/dcr.go
func NewDefaultDCRClient() *http.Client {
    return &http.Client{
        Timeout: 30 * time.Second,
        Transport: &http.Transport{
            TLSHandshakeTimeout:   10 * time.Second,
            ResponseHeaderTimeout: 10 * time.Second,
        },
    }
}

ResolveCredentials(ctx, req, cache) takes no *http.Client, and dcr.Request
has no AllowPrivateIPs field, so the caller cannot inject a guarded client or
opt into a private-IP policy.

Why it matters

The OAuth2 and OIDC upstream configs already carry an AllowPrivateIPs flag and
dial through a private-IP-guarded client (safe-by-default). The DCR resolver is
the one upstream-facing path with no equivalent. Two concrete vectors:

  • Discovery indirection. A DiscoveryURL on an allowed host resolves (RFC
    8414) to a registration_endpoint the metadata document controls. That
    resolved endpoint is dialed without any host check — it can point at a
    link-local metadata address or an in-cluster service.
  • DNS rebinding. A directly-configured RegistrationEndpoint that a caller
    validated at configure time can resolve to a private address at dial time.

Expected

Mirror the OAuth2/OIDC upstream posture. Either:

  • add an AllowPrivateIPs field to dcr.Request and dial through a private-IP-
    guarded client (safe-by-default), or
  • let ResolveCredentials accept a caller-supplied *http.Client so the caller
    can enforce its own dial policy on both the discovery fetch and the
    registration POST.

Version

Observed in v0.34.0. Please confirm on main.

Note

Downstream (Airlock) is proceeding with write-time URL validation as a
best-effort guard and documenting the residual (discovery indirection + DNS
rebinding) as a known risk, mitigated at deployment by a gateway egress
NetworkPolicy. A guarded/injectable DCR client would let us close it properly.
Related: toolhive#5823 (the DCR cache key omits the upstream identity).

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions