JuliusBrussee/caveman · error · ErrProxyInManagedMode

ssrf: Config.Proxy is not supported in managed mode

Error message

ssrf: Config.Proxy is not supported in managed mode

What it means

ErrProxyInManagedMode signals a wiring bug: a Config with ManagedMode=true also set Config.Proxy. Managed mode never routes through a proxy, so instead of silently ignoring the proxy or opening a hole, NewHTTPClient returns an http.Client whose refusingTransport fails every request with this exact error. No network I/O occurs; the security posture is unchanged and only the diagnosis is explicit.

Solutions

  1. Remove cfg.Proxy when ManagedMode is true — managed mode forbids proxying by design.
  2. Check with errors.Is(err, ssrf.ErrProxyInManagedMode) and fail fast at startup: detect the contradictory config when building the client rather than at first request.
  3. If a proxy is genuinely required (e.g. operator egress proxy), run the client in non-managed mode with the proxy hooks, keeping the literal-proxy-address guard.

Example fix

// before
cfg := ssrf.Config{ManagedMode: true, Proxy: http.ProxyFromEnvironment}
client, err := ssrf.NewHTTPClient(cfg) // every request fails with ErrProxyInManagedMode
// after
cfg := ssrf.Config{ManagedMode: true}
if cfg.ManagedMode && cfg.Proxy != nil {
    return errors.New("proxy and managed mode are mutually exclusive")
}
client, err := ssrf.NewHTTPClient(cfg)
Defensive patterns

Strategy: try-catch

Validate before calling

if cfg.ManagedMode && cfg.Proxy != nil {
    return errors.New("ssrf.Config: Proxy and ManagedMode are mutually exclusive")
}

Try / catch

client, err := ssrf.NewHTTPClient(cfg)
if err != nil { return err }
resp, err := client.Do(req)
if errors.Is(err, ssrf.ErrProxyInManagedMode) {
    return fmt.Errorf("config bug: proxy set while managed mode is on: %w", err)
}

Prevention

When it happens

Trigger: Calling ssrf.NewHTTPClient with a Config where both ManagedMode=true and Proxy != nil, then performing any request — every RoundTrip returns this error wrapped as the request error (checkable with errors.Is).

Common situations: Merging config structs so a proxy set for a non-managed environment bleeds into the managed production client; copying a client-construction snippet that always sets Proxy from env (HTTP_PROXY-derived selector) while forcing managed mode; feature-flag combination (managed + egress proxy) the library disallows.

Understand the failure class

Background: Conflicting config options: "cannot be used together" — configuration validation errors across open-source libraries — this error's family across 162 libraries.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@3ee70a1026 (2026-09-20). Data as JSON: /api/errors/80c72742116e17a4. Report an issue: GitHub.

Appendix: source

Thrown at shared/platform/ssrf/ssrf.go:545

			// exactly what it was; only the diagnosis changes.
			return &http.Client{Transport: refusingTransport{err: ErrProxyInManagedMode}}
		}
		t.Proxy, t.DialContext = proxiedHooks(cfg, t.DialContext)
	}
	return &http.Client{
		Transport: t,
		// Provider and webhook clients must not carry credentials across redirects.
		// Callers that intentionally implement redirects (for example OIDC) must
		// validate every hop explicitly and use their own bounded policy.
		CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
}

// ErrProxyInManagedMode reports a wiring bug: a managed-mode Config carrying a
// Proxy selector. Managed mode never proxies (see Config.Proxy).
var ErrProxyInManagedMode = errors.New("ssrf: Config.Proxy is not supported in managed mode")

// refusingTransport fails every request with one explanatory error instead of
// letting a client built from a contradictory Config look like it works.
type refusingTransport struct{ err error }

func (t refusingTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, t.err }

// proxiedHooks returns the Transport.Proxy and DialContext pair for a client
// with cfg.Proxy set. The proxy hook validates what it can about the request
// destination without DNS (the proxy resolves hostnames, often on a network the
// client cannot see), then remembers the proxy address it chose so the dial hook
// can pass that one address through unguarded. Every other address — including
// a direct dial when cfg.Proxy returns nil, e.g. a NO_PROXY match — still goes
// through the full guard. The dial hook cannot tell a proxied dial from a direct
// one to the same host:port, so a direct request whose destination collides with
// a remembered proxy address is refused here instead of reaching that pass-through.
func proxiedHooks(cfg Config, guarded func(context.Context, string, string) (net.Conn, error)) (func(*http.Request) (*url.URL, error), func(context.Context, string, string) (net.Conn, error)) {
	var proxyAddrs sync.Map // host:port as Transport dials it → struct{}

View on GitHub (pinned to 3ee70a1026)