Skip to content

Outbound HTTP: httpx.SafeClient

Purpose

internal/httpx.SafeClient is the shared outbound HTTP client for every service that makes external network calls. It exists so SSRF protections, timeout policy, redirect caps, response-size caps, and User-Agent hygiene stay consistent across the codebase without each service reimplementing them.

Callers making outbound requests should use httpx.NewSafeClient(...) instead of &http.Client{...}.

What it does

  • Dialer-level SSRF guard. Resolves the target host and rejects the connection when the resolved IP is loopback / RFC1918 / link-local / cloud-metadata (169.254.169.254). The check runs at net.Dialer.Control time on the actual IP being dialed, so DNS rebinding is defeated: even if the attacker's DNS server returns a public IP at check time and a private IP moments later, the second lookup still hits the guard.
  • Redirect cap. Default 6 hops; configurable. Prevents redirect loops from exhausting client resources.
  • Per-request timeout. Default 10s; configurable.
  • Canonical User-Agent. Default Tomoda/1.0 (+https://tomoda.life); per-service overrides let partner APIs allow-list us cleanly.
  • Response body size cap via httpx.ReadLimited(r, n). Callers call this at read time; the client doesn't cap unilaterally (some callers legitimately stream long responses).

Callers

Caller Trust level Options used
internal/services/plan/capture_parsers.go user-input URLs (pasted into plan items) strict; Timeout: 6s, MaxRedirects: 6
internal/services/media/link_preview_handler.go user-input URLs (chat DMs) strict; Timeout: 3s, body cap 64KB
internal/webhooks/service.go user-configured outbound URLs strict; Timeout: 10s
internal/platform/llm/client.go SEMANTIC_BASE_URL (docker-network in dev) AllowPrivate: SEMANTIC_ALLOW_PRIVATE
internal/services/location/photon_service.go PHOTON_URL (docker-network in dev) AllowPrivate: PHOTON_ALLOW_PRIVATE
internal/platform/llm/search_enrichment.go Serper (public API) strict; Timeout: serperTimeout
internal/services/location/places_service.go Google Places (public API) strict; Timeout: 10s
internal/platform/email/email.go Resend (public API) strict in prod; AllowPrivate: true in test config only
internal/services/media/klipy_handler.go Klipy sticker API (public) strict; Timeout: 10s
internal/services/auth/auth_service.go (Apple JWKS) appleid.apple.com (public) strict; Timeout: 10s

API

type Options struct {
    Timeout      time.Duration // default 10s
    MaxRedirects int           // default 6
    UserAgent    string        // default httpx.DefaultUserAgent
    AllowPrivate bool          // default false (SSRF guard on)
}

func NewSafeClient(opts Options) *http.Client
func ReadLimited(r io.Reader, n int64) ([]byte, error)

Callers that need internal-network reach (docker-network hostnames like semantic:11434, photon:2322) opt out of the SSRF guard by setting AllowPrivate: true. Wire that to a config toggle (SEMANTIC_ALLOW_PRIVATE, PHOTON_ALLOW_PRIVATE) so prod stays strict and dev/self-hosted deployments can reach their sidecars.

Tests that stand up httptest.NewServer (which binds to loopback) set AllowPrivate: true on the config they hand to the constructor; production configs leave it false.

Adding a new outbound caller

  1. Construct once at service init, not per-request: client := httpx.NewSafeClient(httpx.Options{...}). The client is safe to share across goroutines.
  2. Pick a UserAgent naming the caller (Tomoda-<Service>/1.0).
  3. Cap the response body at read time via httpx.ReadLimited(resp.Body, cap) — pick a cap matching what you actually parse (64KB for HTML previews, 5MB for images, etc).
  4. If the target is docker-network, add an AllowPrivate toggle to that service's config and thread it through. Never enable it unconditionally in a service.

Where to look