Documentation
¶
Overview ¶
Package dialguard opens the platform's outbound connections to customer-supplied destinations, and blocks the addresses they must never reach.
Anything that dials a URL supplied by a customer — a vault credential's MCP server or token endpoint, an agent's `mcp_servers` entry — is an SSRF vector, and one that returns response bodies is a full-read vector. The guard refuses the address classes that are never a legitimate third-party endpoint but are prime exfiltration targets: loopback (the platform's own surfaces), link-local (cloud metadata, 169.254.169.254 / fe80::/10), the unspecified address, and multicast.
Two properties matter more than the list. The check runs on the *resolved* address, on every dial, and this package performs that resolution itself (Dialer): a name is looked up once, every address that came back is judged before any connect, and those addresses are what the socket uses. So a name cannot be re-resolved below a caller's decision into somewhere the floor never saw — which is what DNS rebinding is, and which was the one thing the gate, the MCP client, the two credential dials and the key fetch could not previously say about their own connections (#601, docs/plan/44). What a single resolution does not change is which answer a resolver gives: a name may resolve to a private address by a `search`-list completion, by split-horizon DNS, or by a zone somebody controls publishing an RFC 1918 record for it, and every one of those is admitted here — deliberately, for the reason the next paragraph gives. #601 carries the policy question that leaves open. And RFC 1918 private ranges are deliberately allowed: this platform's premise is on-prem / in-VPC operation (CLAUDE.md), where MCP servers and token endpoints legitimately live on the operator's own private network — the address-based guard is therefore not the network policy, only the floor beneath it (an agent's egress policy is enforced separately, and refusing RFC 1918 here would break the deployment model rather than protect it).
Redirects are a separate matter this package does not address: a caller that follows one replays its request body to a new target, which a per-hop IP check cannot see is wrong. Callers refuse to follow them (http.ErrUseLastResponse).
Index ¶
Constants ¶
This section is empty.
Variables ¶
var ErrRefused = errors.New("disallowed address")
ErrRefused is wrapped by every refusal this guard produces, so a caller can tell a destination that can never be dialled from a network that may recover: no retry makes a refused address reachable. For an address the guard refuses by class the sentinel is the phrase the message already ended with, so that text is unchanged; the unreadable-address refusal carries it as a suffix, and that refusal is a live path rather than a defensive one — Dialer asks about a nil address for every authority it cannot read as a host and a port, which is how a malformed authority, a zone-scoped literal and the empty host of ":443" keep the answer the deleted Control hook gave them.
Functions ¶
func IPAllowed ¶
IPAllowed reports whether a resolved address may be dialed, returning an error naming the refusal. The text names the resolved address and says only that it is disallowed — never which class matched it, and never whether anything is listening there: the refusal is produced before any connect(2), so it reads identically either way and a caller that surfaces it cannot become an internal-host oracle.
Types ¶
type Dialer ¶ added in v0.4.0
type Dialer struct {
// Timeout bounds the whole dial — the lookup and every address attempt
// together, which is where net.Dialer applies its own. Zero means no bound;
// a negative value is already expired when the dial starts, as net.Dialer's
// is.
Timeout time.Duration
// FallbackDelay is how long the second address family waits before racing
// the first (RFC 6555 "Happy Eyeballs"). Zero selects
// defaultFallbackDelay; negative disables the race, as it does on
// net.Dialer.
FallbackDelay time.Duration
// Lookup resolves host to addresses. Nil selects
// net.DefaultResolver.LookupIPAddr, and a test can substitute the whole
// resolution without a DNS server.
//
// LookupIPAddr rather than LookupIP, which is what net.Dialer resolves
// through and for the same reason: LookupIP builds its result as the IP of
// each answer and drops IPAddr.Zone with it, so a name whose answer is a
// zone-scoped address would be dialled without the zone that makes it
// routable. Filtering the families a "tcp4" dial wants is therefore done
// here, after the answer, rather than asked of the resolver. net.Dialer
// does both — it hints the resolver by family and filters what comes back —
// so this reproduces the filtering half only, and always asks for both
// families. No caller here dials tcp4 or tcp6, so what that costs is a
// wasted AAAA lookup rather than a wrong answer, and closing it would mean
// putting the network back into the signature that the zone above is the
// reason for keeping out.
//
// The answer's order is honoured: a resolver has already applied RFC 6724
// to it.
Lookup func(ctx context.Context, host string) ([]net.IPAddr, error)
// Allow judges every resolved address before any connect. Nil selects
// IPAllowed.
//
// It takes a context because a caller's answer can depend on one: the gate
// floors a dial for some admission classes and not others, and the class
// travels in the request context. It is called once per address, never per
// connection attempt, so an address refused here is never dialled at all.
Allow func(ctx context.Context, ip net.IP) error
// contains filtered or unexported fields
}
Dialer connects by resolving a name **once**, judging every address that came back, and dialling what survives.
It exists because the platform's decisions are made about names while the socket goes to an address, and until this type the resolution that turned one into the other happened inside net.Dialer — below every decision, visible only to a Control hook one syscall before connect(2). A gate that has admitted a host, or an executor that has chosen a credential for it, cannot see where the connection is actually going, so nothing above the socket can be said about it (#601, docs/plan/44).
What that buys is one resolution per dial, with the addresses the floor judged being exactly the addresses connect(2) is called on. What it does not buy is a different answer from the resolver. A private answer for a name — from a `search`-list completion, from split-horizon DNS, or from a poisoned response — is admitted here, deliberately (see this package's doc comment), and a credential chosen for that name still reaches it. Closing that is a policy decision about *which name may answer*, and it belongs above this type.
It is a drop-in for net.Dialer on the port-carrying networks — the tcp and udp families — and refuses every other, because a raw "ip:proto" or unix address has no host and port for it to judge and delegating one would open a socket the floor never saw. Every caller here dials TCP.
The zero value is usable and behaves like net.Dialer with the platform floor: no overall timeout, the process resolver, IPAllowed.