fix(chromium): harden outbound URL handling

This commit is contained in:
Julien Neuhart
2026-04-21 20:05:38 +02:00
parent 7729bd0590
commit 35f1a990a6
11 changed files with 1076 additions and 55 deletions

View File

@@ -68,11 +68,20 @@ func IsPublicIP(addr netip.Addr) bool {
// The returned slice can be used to pin a subsequent dial to a specific IP
// and prevent DNS rebinding between this validation and the connect.
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
return resolveHost(ctx, host, false)
}
// resolveHost resolves host and returns the addresses. When checkPublic is
// true, each resolved address must pass [IsPublicIP] or the call returns
// [ErrNonPublicIP]. When false, non-public addresses are accepted and
// returned; the caller must pin the dial to them to retain rebind
// protection even when the public-IP filter is off.
func resolveHost(ctx context.Context, host string, allowPrivate bool) ([]netip.Addr, error) {
if host == "" {
return nil, errors.New("empty host")
}
if addr, err := netip.ParseAddr(host); err == nil {
if !IsPublicIP(addr) {
if !allowPrivate && !IsPublicIP(addr) {
return nil, fmt.Errorf("%q: %w", addr, ErrNonPublicIP)
}
return []netip.Addr{addr}, nil
@@ -84,34 +93,62 @@ func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, erro
if len(addrs) == 0 {
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
}
for _, a := range addrs {
if !IsPublicIP(a) {
return nil, fmt.Errorf("%q resolves to non-public address %q: %w", host, a, ErrNonPublicIP)
if !allowPrivate {
for _, a := range addrs {
if !IsPublicIP(a) {
return nil, fmt.Errorf("%q resolves to non-public address %q: %w", host, a, ErrNonPublicIP)
}
}
}
return addrs, nil
}
// outboundDecision is the result of validating an outbound URL. It is
// stashed in the request context by [outboundRoundTripper] so that
// [secureDialContext] can either bypass the IP check (allow-list match) or
// pin the dial to the IPs that were resolved at validation time.
type outboundDecision struct {
// bypass is true when an allow-list pattern matched the URL. In that
// case the operator has explicitly opted into the destination and the
// dial should proceed without an IP check.
bypass bool
// OutboundDecision is the result of validating an outbound URL via
// [DecideOutbound]. Callers use it to dial the destination either directly
// (operator-approved allow-list match, Bypass true) or via [DialPinned] so
// that the connect targets the IPs resolved at validation time. Passing
// the decision to the dialer closes the window between validation and
// connect that DNS rebinding exploits.
type OutboundDecision struct {
// Bypass is true when an allow-list pattern matched the URL. The
// operator has explicitly opted into the destination; the caller
// should dial directly without an additional IP check.
Bypass bool
// pinned holds the IPs resolved by [ResolveAndCheckPublic] for the URL
// host. The dial should be pinned to one of these to prevent DNS
// rebinding between validation and connect.
pinned []netip.Addr
// Pinned holds the IPs resolved by [ResolveAndCheckPublic] for the URL
// host. The caller should dial one of these via [DialPinned] to
// prevent DNS rebinding between validation and connect.
Pinned []netip.Addr
}
// outboundDecisionKey is the context key under which an [outboundDecision]
// outboundDecisionKey is the context key under which an [OutboundDecision]
// is stored.
type outboundDecisionKey struct{}
// decideConfig carries optional settings for [DecideOutbound] and
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
type decideConfig struct {
allowPrivateIPs bool
}
// DecideOption customizes how [DecideOutbound] and [FilterOutboundURL]
// validate a URL. Options are applied in order and layered on top of the
// defaults.
type DecideOption func(*decideConfig)
// WithAllowPrivateIPs disables the public-IP filter on the resolved host.
// DNS is still resolved and the returned [OutboundDecision] still carries
// the pinned IPs, so the caller retains rebind protection. Only the
// "non-public address" rejection is lifted.
//
// Use this for Chromium deployments behind private networks (Docker
// Compose, Kubernetes with ClusterIP services) where legitimate
// sub-resources resolve to RFC1918 or loopback addresses. The regex
// allow-list and deny-list still apply.
func WithAllowPrivateIPs(allow bool) DecideOption {
return func(c *decideConfig) { c.allowPrivateIPs = allow }
}
// httpLikeScheme reports whether scheme is one of http, https, ws, or wss.
// Only these schemes go through the IP-based public-address check; data,
// blob, file, and other schemes are filtered by the regex layer alone.
@@ -123,14 +160,40 @@ func httpLikeScheme(scheme string) bool {
return false
}
// decideOutbound parses rawURL, runs the regex allow/deny lists against the
// DecideOutbound parses rawURL, runs the regex allow/deny lists against the
// normalized form, and (when no allow-list match) resolves the host and
// rejects any non-public address. It returns the resulting
// [outboundDecision] which the caller can stash in a context for the dial.
func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) (outboundDecision, error) {
// [OutboundDecision] so the caller can pin the dial to the IPs that were
// resolved here and skip a second DNS lookup later. This closes the DNS
// rebinding window that affects callers that only receive an error from
// [FilterOutboundURL].
//
// The semantics match [FilterOutboundURL]:
//
// 1. The URL is parsed and its scheme and host lowercased.
// 2. allowList and denyList apply against the normalized form with OR
// semantics. The deny-list always applies.
// 3. For http, https, ws, and wss, the host is resolved and every
// resolved address must pass [IsPublicIP]. An allow-list match
// bypasses the IP check and the returned decision carries Bypass
// true. Otherwise the decision carries Pinned with the resolved
// addresses.
//
// Callers that dial the destination themselves must honor Bypass and
// Pinned: bypassed URLs dial the hostname directly (operator opt-in);
// pinned URLs must dial one of Pinned via [DialPinned].
//
// Options customize behavior. [WithAllowPrivateIPs] for example disables
// the non-public-address rejection while keeping DNS pinning.
func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) (OutboundDecision, error) {
cfg := decideConfig{}
for _, opt := range opts {
opt(&cfg)
}
parsed, err := url.Parse(rawURL)
if err != nil {
return outboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
return OutboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
parsed.Host = strings.ToLower(parsed.Host)
@@ -145,9 +208,9 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
ok, err := clone.MatchString(normalized)
if err != nil {
if time.Now().After(deadline) {
return outboundDecision{}, context.DeadlineExceeded
return OutboundDecision{}, context.DeadlineExceeded
}
return outboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
}
if ok {
@@ -157,7 +220,7 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
}
if !allowMatched {
return outboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
return OutboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
}
}
@@ -168,38 +231,38 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
ok, err := clone.MatchString(normalized)
if err != nil {
if time.Now().After(deadline) {
return outboundDecision{}, context.DeadlineExceeded
return OutboundDecision{}, context.DeadlineExceeded
}
return outboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
}
if ok {
return outboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
return OutboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
}
}
if allowMatched {
return outboundDecision{bypass: true}, nil
return OutboundDecision{Bypass: true}, nil
}
if !httpLikeScheme(parsed.Scheme) {
return outboundDecision{}, nil
return OutboundDecision{}, nil
}
host := parsed.Hostname()
if host == "" {
return outboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
return OutboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
}
addrs, err := ResolveAndCheckPublic(ctx, host)
addrs, err := resolveHost(ctx, host, cfg.allowPrivateIPs)
if err != nil {
if errors.Is(err, ErrNonPublicIP) {
return outboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
}
return outboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
}
return outboundDecision{pinned: addrs}, nil
return OutboundDecision{Pinned: addrs}, nil
}
// FilterOutboundURL validates that rawURL is acceptable for an outbound
@@ -224,13 +287,13 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
// An allow-list match bypasses the IP check, allowing operators to opt
// into specific internal destinations via --*-allow-list flags. The
// deny-list always applies and cannot be bypassed by an allow-list match.
func FilterOutboundURL(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) error {
_, err := decideOutbound(ctx, rawURL, allowList, denyList, deadline)
func FilterOutboundURL(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) error {
_, err := DecideOutbound(ctx, rawURL, allowList, denyList, deadline, opts...)
return err
}
// outboundRoundTripper is an [http.RoundTripper] that validates each request
// URL via [decideOutbound] and stashes the resulting [outboundDecision] in
// URL via [DecideOutbound] and stashes the resulting [OutboundDecision] in
// the request context so that [secureDialContext] can pin the dial or
// bypass the IP check as appropriate. Because the http.Client invokes
// RoundTrip again for each redirect hop, this also re-validates redirect
@@ -248,7 +311,7 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
deadline = time.Now().Add(30 * time.Second)
}
decision, err := decideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline)
decision, err := DecideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline)
if err != nil {
return nil, err
}
@@ -279,7 +342,7 @@ func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2
}
}
// secureDialContext consumes the [outboundDecision] stashed in ctx by
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
// [outboundRoundTripper]. When the decision is to bypass (allow-list
// match), it dials directly. When the decision contains pinned IPs, it
// dials each in turn until one connects. When no decision is present (the
@@ -291,12 +354,12 @@ func secureDialContext(ctx context.Context, network, addr string) (net.Conn, err
return nil, fmt.Errorf("split host:port %q: %w", addr, err)
}
if decision, ok := ctx.Value(outboundDecisionKey{}).(outboundDecision); ok {
if decision.bypass {
if decision, ok := ctx.Value(outboundDecisionKey{}).(OutboundDecision); ok {
if decision.Bypass {
return outboundDialer.DialContext(ctx, network, addr)
}
if len(decision.pinned) > 0 {
return dialPinned(ctx, network, decision.pinned, port)
if len(decision.Pinned) > 0 {
return DialPinned(ctx, network, decision.Pinned, port)
}
}
@@ -304,12 +367,15 @@ func secureDialContext(ctx context.Context, network, addr string) (net.Conn, err
if err != nil {
return nil, err
}
return dialPinned(ctx, network, addrs, port)
return DialPinned(ctx, network, addrs, port)
}
// dialPinned dials each addr in turn until one connects, returning the
// first successful connection or the last error.
func dialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) {
// DialPinned dials each addr in turn until one connects, returning the
// first successful connection or the last error. Callers pass the Pinned
// slice from [OutboundDecision] so that the dial targets exactly the IPs
// that [DecideOutbound] resolved and validated, preventing DNS rebinding
// between validation and connect.
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) {
var lastErr error
for _, a := range addrs {
conn, err := outboundDialer.DialContext(ctx, network, net.JoinHostPort(a.String(), port))

View File

@@ -306,3 +306,27 @@ func TestResolveAndCheckPublic_HostResolvesToPublic(t *testing.T) {
t.Fatalf("expected [1.1.1.1], got: %v", addrs)
}
}
func TestDecideOutbound_AllowPrivateIPs_DenyListStillApplies(t *testing.T) {
withStubResolver(t, func(host string) ([]netip.Addr, error) {
t.Fatalf("unexpected DNS lookup for %q", host)
return nil, nil
})
// Denial must still win over WithAllowPrivateIPs(true). Gherkin
// coverage exercises flag on/off against the IP check but not the
// interaction with the regex deny-list; keep this primitive test for
// that specific combination.
deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, 0)}
_, err := DecideOutbound(
context.Background(),
"http://evil.local/",
nil, deny,
time.Now().Add(5*time.Second),
WithAllowPrivateIPs(true),
)
if !errors.Is(err, ErrFiltered) {
t.Fatalf("deny-list must still win with WithAllowPrivateIPs(true), got: %v", err)
}
}