mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-15 08:43:17 +01:00
fix(chromium): harden outbound URL handling
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user