JuliusBrussee/caveman · error

githubapp: base_url rejected by SSRF guard

Error message

githubapp: base_url rejected by SSRF guard: %w

What it means

githubapp.New performs a pre-flight SSRF check (ssrf.ValidateURL) on a custom base URL (GitHub Enterprise) before constructing its HTTP client, and this error wraps the guard's rejection. It protects the production path from pointing the App at internal/private network addresses. Callers that inject their own client (tests) bypass the check because they own host policy.

Solutions

  1. Use an HTTPS, publicly resolvable URL for the GHE instance (the SSRF guard blocks private/loopback ranges by design)
  2. For local testing, inject your own *http.Client into Config (the pre-flight is skipped when a client is provided)
  3. If GHE sits behind a proxy/allowlist, configure the ssrf ManagedConfig allowlist appropriately instead of weakening the guard
  4. Verify the base URL has no scheme typos (e.g. http instead of https) and no internal-only hostnames

Example fix

// before
base := "http://ghe.internal.corp/api/v3"
app, err := githubapp.New(githubapp.Config{AppID: id, PrivateKeyPEM: pem, BaseURL: base})
// after
base := "https://ghe.mycompany.example/api/v3"
app, err := githubapp.New(githubapp.Config{AppID: id, PrivateKeyPEM: pem, BaseURL: base})
Defensive patterns

Strategy: try-catch

Validate before calling

u, err := url.Parse(base)
if err != nil || u.Scheme != "https" {
    return fmt.Errorf("base URL must be https: %q", base)
}

Try / catch

app, err := githubapp.New(cfg)
if err != nil && strings.Contains(err.Error(), "SSRF guard") {
    // base URL points at a disallowed (private/non-https) host;
    // fix the URL or, for tests, inject a custom client
    return ErrBaseURLRejected
}

Prevention

When it happens

Trigger: Calling New with a non-nil default client and cfg.BaseURL set to something other than the default api.github.com base, where ssrf.ValidateURL rejects the URL — private/loopback/link-local IPs, non-HTTPS schemes, disallowed hosts.

Common situations: Pointing the base URL at a local GHE dev box or mock server via production code path; a config value like http://localhost:8080/api/v3; DNS that resolves a hostname to a private address; typo'd internal URLs.

Understand the failure class

Background: "Invalid URL" / "URL cannot be empty": fix the malformed or missing URL behind request-construction failures — this error's family across 50 libraries.

Related errors


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

Appendix: source

Thrown at shared/platform/githubapp/githubapp.go:89

	if len(cfg.PrivateKeyPEM) == 0 {
		return nil, fmt.Errorf("githubapp: private key PEM is required")
	}
	key, err := parseRSAPrivateKey(cfg.PrivateKeyPEM)
	if err != nil {
		return nil, err
	}
	base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
	if base == "" {
		base = defaultBaseURL
	}
	client := cfg.HTTPClient
	if client == nil {
		// Production path: SSRF-guarded client + pre-flight host check on a custom
		// (GHE) base. When a caller injects a client (tests), it owns the host policy,
		// so we skip the pre-flight — but production never injects one.
		if base != defaultBaseURL {
			if err := ssrf.ValidateURL(context.Background(), base, ssrf.ManagedConfig()); err != nil {
				return nil, fmt.Errorf("githubapp: base_url rejected by SSRF guard: %w", err)
			}
		}
		client = ssrf.NewHTTPClient(ssrf.ManagedConfig())
		client.Timeout = 20 * time.Second
	}
	return &App{
		appID:         strings.TrimSpace(cfg.AppID),
		slug:          strings.TrimSpace(cfg.AppSlug),
		privateKey:    key,
		webhookSecret: cfg.WebhookSecret,
		baseURL:       base,
		httpClient:    client,
	}, nil
}

// Slug returns the App slug used to build the install URL.
func (a *App) Slug() string { return a.slug }

View on GitHub (pinned to 3ee70a1026)