Documentation
¶
Overview ¶
Package destination is the PR-7 destination-safety engine: destination extraction, URL/host/IP canonicalization, immutable destination policy, an INJECTED DNS resolver, connect-time pinned resolution + peer verification (MCP-INSP-005 rebinding guard), and a shared reusable redirect guard (MCP-INSP-006). SSRF classification reuses the AUTHORITATIVE private/internal ranges from internal/ssrf (ssrf.PrivateIP) — there is no second, divergent private-address table.
Load-bearing properties, all asserted by tests:
- Reuse the one SSRF table. Every private/link-local/metadata/loopback/ reserved/multicast decision goes through ssrf.PrivateIP (and the same ssrf.Control at connect). No parallel CIDR list.
- Pinned resolve→connect. Resolution happens ONCE (Resolve) and produces an immutable PinnedDestination whose permitted IP set the connect-time VerifyPeer checks; the core never re-resolves a hostname for connect. A DNS answer that mixes public and private addresses fails closed as a whole.
- Injected resolver. The core NEVER uses net.DefaultResolver directly; the resolver is an interface a caller injects, so unit tests use no real DNS.
- Canonical facts. Canonicalization rejects userinfo, fragments-as-policy, malformed ports, control chars, ambiguous percent-encoding, non-canonical numeric-IP spellings, IPv6 zone identifiers, and (in V1) non-ASCII hosts; policy facts use exact canonical values.
- Request-local redirect state. The redirect guard holds per-request state (hop count, origin); there is one shared implementation, not per-client copies, and every hop is independently re-canonicalized, re-SSRF-checked and re-pinned.
Index ¶
- func Canonicalize(raw string, pol Policy, lim limits.InspectionLimits) (Canonical, Class, error)
- func Resolve(ctx context.Context, c Canonical, pol Policy, r Resolver, ...) (PinnedDestination, Status, error)
- func VerifyPeer(pin PinnedDestination, peer netip.Addr, pol Policy, now time.Time) error
- type Candidate
- type Canonical
- type Class
- type ExtractionRules
- type PinnedDestination
- type Policy
- type PolicyConfig
- type RedirectEvidence
- type RedirectGuard
- type Resolver
- type Status
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Canonicalize ¶
Canonicalize validates and canonicalizes a raw destination URL under the immutable policy and limits, BEFORE any resolution. It returns the canonical facts and the destination Class (ClassPublic/private/… for an IP literal, or ClassUnknown for a hostname that must still be resolved). It rejects — with a typed mcperr — a blocked scheme, userinfo, a fragment, a missing host, a malformed/over-bound URL, control chars, ambiguous percent-encoding, a non-canonical numeric-IP spelling, an IPv6 zone identifier, or (in V1) a non-ASCII host.
func Resolve ¶
func Resolve(ctx context.Context, c Canonical, pol Policy, r Resolver, lim limits.InspectionLimits, now time.Time, ttl time.Duration) (PinnedDestination, Status, error)
Resolve performs bounded, pinned resolution of a canonical destination. For an IP literal it pins the single address; for a hostname it resolves via the injected resolver, rejects an empty/oversized/malformed answer, fails the WHOLE answer closed if ANY address is private/forbidden (mixed public/private is a single private-address poison), and builds an immutable PinnedDestination. It never re-resolves for connect — VerifyPeer checks the pinned set. now + ttl bound the pin lifetime. Resolution is single-shot; there is no per-address goroutine.
func VerifyPeer ¶
VerifyPeer is the connect-time rebinding guard (MCP-INSP-005). It confirms the actual peer (1) still passes the AUTHORITATIVE ssrf.Control (a private peer is refused even if it somehow reached the pin), (2) is a MEMBER of the immutable pinned set (the resolved answer did not change between resolve and connect), and (3) the pin is not stale. Any failure fails closed with a typed reason. It does NOT re-resolve the hostname.
Types ¶
type Candidate ¶
type Candidate struct {
Path string // bounded JSON-pointer-like location
RawURL string // the raw string (canonicalized by the caller; never trusted)
Modeled bool // true: from an explicit compiled rule; false: heuristic backstop
}
Candidate is one extracted destination-bearing string from a value.
type Canonical ¶
type Canonical struct {
Scheme string
Host string // canonical host (lowercased ASCII, or canonical IP literal text)
Port string // explicit or scheme-default port, always present
IsIP bool
IP netip.Addr // valid iff IsIP
// HasQuery/HasUserinfo/HasFragment are booleans (facts), never the values.
HasQuery bool
// contains filtered or unexported fields
}
Canonical is the immutable canonicalized destination. It holds only exact canonical facts — never the raw URL, never a query string with secrets.
func (Canonical) Origin ¶
Origin returns the canonical scheme://host:port origin string (safe evidence).
func (Canonical) RequestURL ¶ added in v1.0.185
RequestURL returns the full canonical request URL (origin + path + optional query) a client must POST to. Unlike Origin() this is NOT evidence — it is the validated destination the pinned transport connects to, so it preserves any path a Streamable-HTTP server was mounted under (e.g. /mcp). A path-less endpoint yields the origin with a single "/" (the pre-existing default). The path/query stay unexported for evidence; RequestURL is the ONLY sanctioned surface that reconstructs them, for the connect leg only.
type Class ¶
type Class uint8
Class is the safe, bounded destination classification used as a policy fact and in evidence. It never carries the raw host/URL.
const ( // ClassUnknown — not classified (fails closed for high-risk). ClassUnknown Class = iota // ClassPublic — a public, routable address (the only permitted class). ClassPublic // ClassLoopback — 127.0.0.0/8, ::1. ClassLoopback // ClassPrivate — RFC1918 / ULA / CGN internal ranges. ClassPrivate // ClassLinkLocal — 169.254/16, fe80::/10 (link-local). ClassLinkLocal // ClassMetadata — a cloud metadata endpoint (169.254.169.254 / fd00:ec2::254). ClassMetadata // ClassReserved — unspecified/reserved/benchmark ranges. ClassReserved // ClassMulticast — multicast ranges. ClassMulticast // ClassBlockedScheme — a rejected URL scheme (file/data/javascript/…). ClassBlockedScheme // ClassMalformed — a URL/host/IP that failed canonicalization. ClassMalformed )
type ExtractionRules ¶
type ExtractionRules struct {
// contains filtered or unexported fields
}
ExtractionRules is an immutable, compiled set of explicit destination-extraction pointers plus an optional conservative name-heuristic backstop. Destinations are NOT discovered by scanning arbitrary strings for "http": extraction is tied to explicit schema-property paths (JSON pointers). The name heuristic is a CONSERVATIVE BACKSTOP only — a heuristic hit is reported as an unmodeled destination, never used as the sole allow mechanism.
func CompileRules ¶
func CompileRules(pointers []string, heuristic bool, lim limits.InspectionLimits) (ExtractionRules, error)
CompileRules compiles explicit JSON-pointer extraction paths (bounded by MaxExtractionPaths) plus the optional heuristic backstop.
func (ExtractionRules) Extract ¶
func (r ExtractionRules) Extract(v *canonical.Node, lim limits.InspectionLimits) ([]Candidate, error)
Extract returns the destination candidates in v under the compiled rules, bounded by MaxExtractedDests. Explicit-rule hits are Modeled; heuristic hits are not. Deterministic order: explicit rules first (in rule order), then heuristic hits in document order.
type PinnedDestination ¶
type PinnedDestination struct {
Scheme string
Host string
Port string
AllowedIPs []netip.Addr // every entry already passed the SSRF check
ResolverRevision uint64 // profile/resolver revision the pin was made under
Expiry time.Time // bounded pin lifetime (resolver deadline)
}
PinnedDestination is the immutable resolve-time pin the connect leg verifies against. It is produced ONCE by Resolve and consumed by VerifyPeer; a future upstream client MUST dial only an address in AllowedIPs, never re-resolve Host.
type Policy ¶
type Policy struct {
// contains filtered or unexported fields
}
Policy is an immutable destination policy. Its scheme set is stored as a sorted slice (tiny; linear membership) so the value shares no mutable map with callers.
func DefaultGatewayPolicy ¶
func DefaultGatewayPolicy() Policy
DefaultGatewayPolicy returns the narrow production Gateway destination policy: https only, no private, no cross-origin redirect, no scheme downgrade.
func NewPolicy ¶
func NewPolicy(cfg PolicyConfig) (Policy, error)
NewPolicy validates cfg and returns an immutable Policy. It rejects an empty scheme allowlist (fail closed) and any forbidden scheme.
func (Policy) AllowPrivate ¶
AllowPrivate reports the test/env-scoped private-destination flag.
func (Policy) ResolverRevision ¶
ResolverRevision returns the revision pins are stamped with.
type PolicyConfig ¶
type PolicyConfig struct {
// Schemes is the allowlist (e.g. {"https"}). Empty ⇒ fail closed (no scheme
// permitted). A forbidden scheme in the list is a hard configuration error.
Schemes []string
// AllowPrivate permits private/internal destinations. It is TEST- or
// ENVIRONMENT-scoped ONLY; a production policy must leave it false. There is no
// broad production allow_private bypass.
AllowPrivate bool
// AllowCrossOriginRedirect permits a redirect to a different origin.
AllowCrossOriginRedirect bool
// AllowSchemeDowngrade permits an https→http redirect (default false).
AllowSchemeDowngrade bool
// ResolverRevision stamps pins made under this policy.
ResolverRevision uint64
}
PolicyConfig is the mutable input to NewPolicy.
type RedirectEvidence ¶
RedirectEvidence is one bounded, safe redirect-hop record (no raw URL, only the canonical origin and hop index).
type RedirectGuard ¶
type RedirectGuard struct {
Evidence []RedirectEvidence
// contains filtered or unexported fields
}
RedirectGuard is the single, shared, reusable redirect guard (MCP-INSP-006). It holds REQUEST-LOCAL state (hop count, previous origin, visited set) — there is one implementation, never a per-client copy. A future upstream MCP client consumes this guard: every hop is independently re-canonicalized, re-SSRF-checked (for IP-literal targets) and must be re-pinned by the caller (Resolve on the returned Canonical), authorization is never carried across an origin (cross-origin is rejected by default), and the chain is bounded.
func NewRedirectGuard ¶
func NewRedirectGuard(initial Canonical, pol Policy, lim limits.InspectionLimits) *RedirectGuard
NewRedirectGuard starts a guard at the initial canonical destination.
func (*RedirectGuard) ForwardAuthAllowed ¶
func (g *RedirectGuard) ForwardAuthAllowed(next Canonical) bool
ForwardAuthAllowed reports whether an authorization/credential header may be forwarded to next given the current origin. It is false across origins — the future client must strip credentials on any cross-origin hop.
func (*RedirectGuard) Next ¶
func (g *RedirectGuard) Next(location string) (Canonical, error)
Next validates ONE redirect hop given the Location value (absolute or relative). It returns the new canonical destination (which the caller MUST re-resolve and re-pin) or a typed rejection. It rejects hop-count overflow, loops, scheme downgrade, userinfo, cross-origin (unless allowed), a malformed relative target, and — for an IP-literal target — a public→private/metadata escape.
type Resolver ¶
type Resolver interface {
// LookupIP resolves host to a set of IP addresses (already deduplicated is not
// required). It MUST honor ctx (deadline/cancel). It returns addresses only —
// never CNAME chains — so CNAME depth is bounded inside the implementation.
LookupIP(ctx context.Context, host string) ([]netip.Addr, error)
}
Resolver is the INJECTED DNS resolver. The core inspector never uses net.DefaultResolver directly — a caller supplies a bounded implementation, so unit tests use no real external DNS. Production wiring passes an adapter over a bounded net.Resolver.