JuliusBrussee/caveman · error

ssrf: host %q is blocked (loopback)

Error message

ssrf: host %q is blocked (loopback)

What it means

Pre-flight host check in ssrf.ValidateURL: the literal hostname 'localhost' is blocked unconditionally in managed mode, and in self-hosted mode it is blocked unless an explicit allowlist entry covers it. DNS is never consulted for this name — the block is by string match (case-insensitive).

Source

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

func ValidateHost(ctx context.Context, host string, cfg Config) error {
	return validateHostPort(ctx, host, "", cfg)
}

func validateHostPort(ctx context.Context, host, port string, cfg Config) error {
	if err := validateHostInput(host); err != nil {
		return err
	}

	// If host is an IP literal, check it directly without a DNS round-trip.
	if addr, err := netip.ParseAddr(host); err == nil {
		return checkAddr(addr, host, port, cfg)
	}

	// "localhost" is explicitly blocked regardless of what DNS says — unless a
	// self-hosted operator allowlisted it (resolution still runs, so every
	// resolved address is range-checked below like any other).
	if strings.EqualFold(host, "localhost") && !(!cfg.ManagedMode && isInAllowList(host, port, cfg.AllowList)) {
		return fmt.Errorf("ssrf: host %q is blocked (loopback)", host)
	}

	// Resolve ALL addresses the hostname currently maps to.  A hostname that
	// returns even one blocked address is rejected (defense-in-depth against
	// split-horizon / DNS rebinding scenarios where the pre-flight check and
	// the dial see different answers).
	addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
	if err != nil {
		return fmt.Errorf("ssrf: DNS resolution failed for %q: %w", host, err)
	}
	if len(addrs) == 0 {
		return fmt.Errorf("ssrf: host %q resolved to no addresses", host)
	}

	for _, ia := range addrs {
		a, ok := netip.AddrFromSlice(ia.IP)
		if !ok {
			return fmt.Errorf("ssrf: could not parse resolved IP %v for host %q", ia.IP, host)

View on GitHub (pinned to 27d5a3981a)

Solutions

  1. Point the integration at the real external hostname instead of localhost.
  2. Self-hosted only: add 'localhost:<port>' (or the concrete loopback IP) to cfg.AllowList so the operator opt-in is explicit.
  3. In managed mode there is no escape — remove localhost targets from tenant-supplied config.

Example fix

// before
cfg := ssrf.Config{ManagedMode: false}
err := ssrf.ValidateURL(ctx, "https://localhost:8443/hook", cfg) // blocked

// after
cfg := ssrf.Config{
    ManagedMode: false,
    AllowList:   []string{"localhost:8443"},
}
err := ssrf.ValidateURL(ctx, "https://localhost:8443/hook", cfg)
Defensive patterns

Strategy: validation

Validate before calling

u, _ := url.Parse(raw)
if strings.EqualFold(u.Hostname(), "localhost") &&
    !(cfg.ManagedMode == false && isInAllowList(u.Hostname(), u.Port(), cfg.AllowList)) {
    return fmt.Errorf("localhost is not permitted; use the service's real hostname")
}

Try / catch

if err := ssrf.ValidateURL(ctx, raw, cfg); err != nil {
    if strings.Contains(err.Error(), "blocked (loopback)") {
        // reject config save with guidance instead of retrying
    }
}

Prevention

When it happens

Trigger: Calling ssrf.ValidateURL with host 'localhost' (any port) while ManagedMode is true; or with ManagedMode false but no matching AllowList entry for 'localhost:port'.

Common situations: Developer leaves a local test webhook (http://localhost:8000/hook — note http is also blocked in managed mode) in a config that ships to the managed environment; self-hosted operator pointing an integration at a local service without allowlisting it.

Related errors


AI-assisted analysis of JuliusBrussee/caveman@27d5a3981a (2026-08-15). Data as JSON: /api/errors/ba04a15eedda8312. Report an issue: GitHub.