httpclient

package
v0.9.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Overview

Package httpclient is the only way out of the process. Every outbound call goes through GuardedClient (rule 6, ADR-0015, T-07); http.DefaultClient is banned, and an architecture test enforces it.

The threat is server-side request forgery: a webhook URL, an automation action, or an AI endpoint is configured by a user, and the server is the one that dials it. Without a guard, "https://169.254.169.254/latest/meta-data/" is a request the server makes with the server's network position - which is inside the network everything else is protecting.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func IsBlocked

func IsBlocked(err error) bool

IsBlocked reports whether err is the guard refusing a target. The webhook dispatcher needs it to disable a subscription rather than retry it for 24 hours.

Types

type Guard

type Guard struct {
	// contains filtered or unexported fields
}

Guard decides whether an address may be dialled. It is separate from the client because the decision is needed twice: once when a URL is configured or a call is prepared, and once at dial time, on the concrete IP the connection is about to use. Only the second one closes DNS rebinding, and only the first one produces a good error message.

func NewGuard

func NewGuard(cfg env.OutboundConfig) *Guard

NewGuard builds the guard from the outbound configuration.

func (*Guard) AllowsPrivateNetworks

func (g *Guard) AllowsPrivateNetworks() bool

AllowsPrivateNetworks reports whether the guard has been opened to private addresses. The health report reads it, so that the warning and the actual behaviour cannot drift apart.

func (*Guard) CheckAddr

func (g *Guard) CheckAddr(addr netip.Addr) error

CheckAddr is the address policy in one place.

func (*Guard) CheckControl

func (g *Guard) CheckControl(_, address string) error

CheckControl is the hook for net.Dialer.Control. It runs after resolution, with the concrete address the socket is about to connect to, and it is the only check that closes DNS rebinding: between Resolve and the connection, the answer can change, and a resolver under somebody else's control will make sure it does.

func (*Guard) CheckHost

func (g *Guard) CheckHost(host string) error

CheckHost applies the egress allowlist to a host name, for a protocol that is not HTTP - IMAP, SSH - and so never passes through CheckURL. An empty list allows every public host; the address policy is still Resolve's and CheckControl's.

func (*Guard) CheckURL

func (g *Guard) CheckURL(rawURL string) (*url.URL, error)

CheckURL validates a target before anything is dialled: the scheme, the shape, and the allowlist. It does not resolve - a caller that only wants to know whether a configured webhook URL is acceptable should not make a DNS query, and an operator typing a URL into a form should get an answer that does not depend on the network.

func (*Guard) Resolve

func (g *Guard) Resolve(ctx context.Context, host string) ([]netip.Addr, error)

Resolve checks every address a host resolves to. All of them have to pass: a name with one public and one private A record is not half acceptable, it is the standard way of hiding a private target behind a public one.

The returned addresses are what the dialler should use. Resolving here and dialling there leaves a window in which DNS can change its mind, which is why CheckAddr runs again at dial time - this pass is for the error message and for failing before a connection is attempted.

func (*Guard) WithResolver

func (g *Guard) WithResolver(lookup func(ctx context.Context, host string) ([]netip.Addr, error)) *Guard

WithResolver returns a copy that resolves through lookup. For the SSRF suite: a rebinding attack needs a resolver that answers differently the second time, and a test must not depend on a DNS server to have one.

type GuardedClient

type GuardedClient struct {
	// contains filtered or unexported fields
}

GuardedClient is the only way out of the process (rule 6). It is an http.Client with four things bolted on that the standard one does not do: the address guard at dial time, a re-check of every redirect hop, a hard cap on the response size, and a deadline that cannot be forgotten.

It carries no circuit breaker of its own. A breaker belongs to a dependency, and this client serves all of them - one breaker here would let a broken AI provider cut off every webhook in the installation. The caller wraps its own call in resilience.Breaker; that is what the building blocks are for.

func NewGuardedClient

func NewGuardedClient(cfg env.OutboundConfig, guard *Guard) *GuardedClient

NewGuardedClient builds the client from the outbound configuration.

func (*GuardedClient) Do

Do makes the call. It returns an error when the call could not be made or could not be completed; an answered request with a 500 is not an error here, because whether that is a failure depends on what was being asked (see port.Response).

func (*GuardedClient) HTTPClient

func (c *GuardedClient) HTTPClient(targetClass string) *http.Client

HTTPClient exposes the guard as a standard library client, for the one kind of dependency that insists on taking one (ADR-0036): a library that does its own discovery and key fetching cannot be handed `Do`, and forking it to make it would be worse than this.

Nothing is given up. The allowlist and scheme check run in front of every request, the resolution check runs before the socket, the dial-time address check is the transport's own - so DNS rebinding is caught here exactly as it is in Do - and each redirect hop is checked again. The duration is reported under the class the caller names.

What is deliberately absent is the circuit breaker `Do` carries. A breaker is keyed by dependency class, and the targets reached through this client are configured per workspace: one class per tenant's issuer would be an unbounded label (§3.2), and a single shared class would let one workspace's broken provider hold every other workspace's sign-in shut. An interactive sign-in fails fast on its own and says so with a code.

func (*GuardedClient) Stream

func (c *GuardedClient) Stream(ctx context.Context, req port.Request) (StreamResponse, error)

Stream makes a call whose response body is handed to the caller instead of being read here - a server-sent event stream lives until somebody hangs up, and a client that buffered it whole would never hand over a byte.

The guard is the same as Do's: the URL check, the resolve-before-connect, the dial-time control and the redirect re-check all apply, because a stream is exactly as capable of being pointed somewhere it should not go (T-07). What differs is the bounds. There is no overall deadline - the connection attempt, the TLS handshake and the wait for headers stay bounded by the transport's timeouts, and past the headers an open stream is the point, not a hang. And there is no response size cap - the body is unbounded by design, and bounding what one read may carry is the caller's job, since only the caller knows its framing. Liveness past the headers is also the caller's: it owns the read loop, so it is the one place a stalled stream can be noticed (rule 7 is satisfied by the caller's context, which cancels the body mid-read).

No span and no duration metric, deliberately: the lifetime of a stream measures how long the caller stayed, not how the target behaved, and averaging it into the outbound histogram would bury the signal that histogram exists for.

func (*GuardedClient) Upload

func (c *GuardedClient) Upload(
	ctx context.Context, req port.Request, body io.Reader, length int64,
) (port.Response, error)

Upload makes a call whose *request* body is a stream rather than a slice.

The mirror of Stream, and it exists for the same kind of payload seen from the other side: an archive on its way to a backup target has no known length and does not fit in memory, and port.Request carries a []byte because everything else this system sends outwards is a small, already-rendered document (core/port/httpclient). Buffering an archive to satisfy that shape is the OOM kill observability-reliability.md §6 calls an architecture defect (T-17).

Every guard applies unchanged - the URL check, the resolve before the connect, the dial-time control, the redirect budget and the response cap - and that is the point: a target whose address came from an API is exactly what rule 6 exists for. What is deliberately absent is the retry the resilient wrapper would otherwise add, because a stream can be read only once and a second attempt would send whatever was left of it.

func (*GuardedClient) WithObserver

func (c *GuardedClient) WithObserver(observe func(ctx context.Context, targetClass string, seconds float64)) *GuardedClient

WithObserver returns a copy that reports the duration of every call. The composition root passes the metrics adapter's OutboundHTTP here.

func (*GuardedClient) WithTracer

func (c *GuardedClient) WithTracer(tracer trace.Tracer) *GuardedClient

WithTracer returns a copy that opens a client span per call. The composition root passes the tracer of the observability adapter.

type StreamResponse

type StreamResponse struct {
	Status int
	Header map[string][]string
	Body   io.ReadCloser
}

StreamResponse is an answer whose body is still arriving. The caller owns Body and must close it; closing it is also how the caller ends a stream the server would happily keep feeding.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL