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 atnet.Dialer.Controltime 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¶
- Construct once at service init, not per-request:
client := httpx.NewSafeClient(httpx.Options{...}). The client is safe to share across goroutines. - Pick a
UserAgentnaming the caller (Tomoda-<Service>/1.0). - 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). - If the target is docker-network, add an
AllowPrivatetoggle to that service's config and thread it through. Never enable it unconditionally in a service.
Where to look¶
backend/internal/httpx/safe.go— implementationbackend/internal/httpx/safe_test.go— asserts loopback rejection,AllowPrivateescape hatch, redirect cap, UA, timeout