mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-14 08:13:18 +01:00
fix(outbound)!: per-module deny-private-ips and deny-public-ips, permissive defaults
This commit is contained in:
@@ -21,14 +21,20 @@ import (
|
||||
// example [::ffff:127.0.0.1]).
|
||||
var ErrNonPublicIP = errors.New("non-public IP")
|
||||
|
||||
// netipResolver is the subset of [net.Resolver] used by
|
||||
// [ResolveAndCheckPublic]. Defining it as an interface allows tests to
|
||||
// substitute a stub resolver.
|
||||
// ErrPublicIP indicates that an outbound URL targets an IP address that is
|
||||
// reachable on the public internet. It is returned when a caller opts
|
||||
// into denying public destinations via [WithDenyPublicIPs]; typical use
|
||||
// cases are air-gapped or data-governed deployments where Gotenberg must
|
||||
// only talk to hosts on a private network.
|
||||
var ErrPublicIP = errors.New("public IP")
|
||||
|
||||
// netipResolver is the subset of [net.Resolver] used by [resolveHost].
|
||||
// Defining it as an interface allows tests to substitute a stub resolver.
|
||||
type netipResolver interface {
|
||||
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
// outboundResolver is the resolver used by [ResolveAndCheckPublic]. It is a
|
||||
// outboundResolver is the resolver used by [resolveHost]. It is a
|
||||
// package-level variable so that tests can substitute a stub resolver.
|
||||
var outboundResolver netipResolver = net.DefaultResolver
|
||||
|
||||
@@ -62,27 +68,40 @@ func IsPublicIP(addr netip.Addr) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ResolveAndCheckPublic resolves host and returns the resolved addresses,
|
||||
// or an error if any resolved address fails [IsPublicIP]. If host is itself
|
||||
// an IP literal, it is checked directly without performing a DNS lookup.
|
||||
// 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.
|
||||
// ResolveAndCheckPublic resolves host and rejects any resolved address
|
||||
// that fails [IsPublicIP] with [ErrNonPublicIP]. It is the strict
|
||||
// equivalent of [DecideOutbound] with [WithDenyPrivateIPs] true for a
|
||||
// bare host. Callers that need a different policy should use
|
||||
// [DecideOutbound] directly.
|
||||
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||
return resolveHost(ctx, host, false)
|
||||
return resolveHost(ctx, host, true, 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) {
|
||||
// resolveHost resolves host and returns the addresses. When denyPrivate
|
||||
// is true, a non-public address is rejected with [ErrNonPublicIP]. When
|
||||
// denyPublic is true, a public address is rejected with [ErrPublicIP].
|
||||
// Both checks may be active at the same time, in which case any
|
||||
// resolved address fails and the caller must rely on an allow-list
|
||||
// bypass.
|
||||
func resolveHost(ctx context.Context, host string, denyPrivate, denyPublic bool) ([]netip.Addr, error) {
|
||||
if host == "" {
|
||||
return nil, errors.New("empty host")
|
||||
}
|
||||
|
||||
check := func(a netip.Addr) error {
|
||||
public := IsPublicIP(a)
|
||||
if denyPublic && public {
|
||||
return fmt.Errorf("%q: %w", a, ErrPublicIP)
|
||||
}
|
||||
if denyPrivate && !public {
|
||||
return fmt.Errorf("%q: %w", a, ErrNonPublicIP)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if addr, err := netip.ParseAddr(host); err == nil {
|
||||
if !allowPrivate && !IsPublicIP(addr) {
|
||||
return nil, fmt.Errorf("%q: %w", addr, ErrNonPublicIP)
|
||||
if err := check(addr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []netip.Addr{addr}, nil
|
||||
}
|
||||
@@ -93,11 +112,9 @@ func resolveHost(ctx context.Context, host string, allowPrivate bool) ([]netip.A
|
||||
if len(addrs) == 0 {
|
||||
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
|
||||
}
|
||||
if !allowPrivate {
|
||||
for _, a := range addrs {
|
||||
if !IsPublicIP(a) {
|
||||
return nil, fmt.Errorf("%q resolves to non-public address %q: %w", host, a, ErrNonPublicIP)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if err := check(a); err != nil {
|
||||
return nil, fmt.Errorf("%q resolves to rejected address %w", host, err)
|
||||
}
|
||||
}
|
||||
return addrs, nil
|
||||
@@ -115,9 +132,9 @@ type OutboundDecision struct {
|
||||
// should dial directly without an additional IP check.
|
||||
Bypass bool
|
||||
|
||||
// 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 holds the IPs resolved for the URL host. The caller should
|
||||
// dial one of these via [DialPinned] to prevent DNS rebinding between
|
||||
// validation and connect.
|
||||
Pinned []netip.Addr
|
||||
}
|
||||
|
||||
@@ -128,30 +145,40 @@ type outboundDecisionKey struct{}
|
||||
// decideConfig carries optional settings for [DecideOutbound] and
|
||||
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
||||
type decideConfig struct {
|
||||
allowPrivateIPs bool
|
||||
denyPrivateIPs bool
|
||||
denyPublicIPs bool
|
||||
}
|
||||
|
||||
// DecideOption customizes how [DecideOutbound] and [FilterOutboundURL]
|
||||
// validate a URL. Options are applied in order and layered on top of the
|
||||
// defaults.
|
||||
// validate a URL. Options are applied in order on top of the permissive
|
||||
// defaults (no IP-class rejection).
|
||||
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 }
|
||||
// WithDenyPrivateIPs rejects URLs whose host resolves to a non-public IP
|
||||
// address (loopback, RFC1918, link-local, unique-local, multicast,
|
||||
// unspecified). DNS still runs and the returned [OutboundDecision] still
|
||||
// carries the resolved IPs for dial pinning, so enabling or disabling
|
||||
// this option does not affect DNS-rebinding protection. Use it on
|
||||
// internet-exposed deployments to mitigate SSRF against internal
|
||||
// services.
|
||||
func WithDenyPrivateIPs(deny bool) DecideOption {
|
||||
return func(c *decideConfig) { c.denyPrivateIPs = deny }
|
||||
}
|
||||
|
||||
// WithDenyPublicIPs rejects URLs whose host resolves to a public IP
|
||||
// address. Use it on air-gapped or data-governed deployments where
|
||||
// Gotenberg must only reach hosts on a private network; the option
|
||||
// prevents data exfiltration to attacker-controlled public servers via
|
||||
// webhook callbacks, downloadFrom URLs, or user-supplied stamp sources.
|
||||
// May be combined with [WithDenyPrivateIPs]; in that case every resolved
|
||||
// address fails and only an allow-list bypass permits a destination.
|
||||
func WithDenyPublicIPs(deny bool) DecideOption {
|
||||
return func(c *decideConfig) { c.denyPublicIPs = deny }
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Only these schemes go through the IP-based address check; data, blob,
|
||||
// file, and other schemes are filtered by the regex layer alone.
|
||||
func httpLikeScheme(scheme string) bool {
|
||||
switch scheme {
|
||||
case "http", "https", "ws", "wss":
|
||||
@@ -160,31 +187,29 @@ func httpLikeScheme(scheme string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 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] 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].
|
||||
// DecideOutbound parses rawURL, runs the regex allow/deny lists against
|
||||
// the normalized form, and (when no allow-list match) resolves the host
|
||||
// and applies the IP-class checks selected by opts. It returns the
|
||||
// resulting [OutboundDecision] so the caller can pin the dial to the IPs
|
||||
// that were resolved here and skip a second DNS lookup later, which
|
||||
// closes the DNS rebinding window that affects callers that only receive
|
||||
// an error from [FilterOutboundURL].
|
||||
//
|
||||
// The semantics match [FilterOutboundURL]:
|
||||
// The semantics:
|
||||
//
|
||||
// 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.
|
||||
// resolved address must satisfy the enabled IP-class checks
|
||||
// ([WithDenyPrivateIPs], [WithDenyPublicIPs]). An allow-list match
|
||||
// bypasses the IP-class checks 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 {
|
||||
@@ -254,12 +279,16 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
||||
return OutboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
|
||||
}
|
||||
|
||||
addrs, err := resolveHost(ctx, host, cfg.allowPrivateIPs)
|
||||
addrs, err := resolveHost(ctx, host, cfg.denyPrivateIPs, cfg.denyPublicIPs)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNonPublicIP) {
|
||||
switch {
|
||||
case errors.Is(err, ErrNonPublicIP):
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
||||
case errors.Is(err, ErrPublicIP):
|
||||
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
||||
default:
|
||||
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
|
||||
@@ -267,41 +296,29 @@ func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
||||
|
||||
// FilterOutboundURL validates that rawURL is acceptable for an outbound
|
||||
// request from Gotenberg. It is the URL-aware replacement for
|
||||
// [FilterDeadline] and should be preferred for any new code that filters a
|
||||
// URL before issuing or instructing an outbound request.
|
||||
// [FilterDeadline] and should be preferred for any new code that filters
|
||||
// a URL before issuing or instructing an outbound request.
|
||||
//
|
||||
// The function:
|
||||
//
|
||||
// 1. Parses rawURL with [net/url] and lowercases the scheme and host. This
|
||||
// prevents case-variant bypasses such as HTTP://127.0.0.1 from evading
|
||||
// case-sensitive deny-list regexes.
|
||||
// 2. Applies allowList and denyList against the normalized form using the
|
||||
// same OR semantics as [FilterDeadline].
|
||||
// 3. When no allow-list entry explicitly matched and the scheme is one of
|
||||
// http, https, ws, or wss, resolves the host and verifies every
|
||||
// resolved address with [IsPublicIP]. This blocks loopback, private,
|
||||
// link-local, and other internal targets even when the regex layer
|
||||
// does not cover the textual form (for example IPv4-mapped IPv6 like
|
||||
// [::ffff:127.0.0.1], or hostnames that resolve to a private address).
|
||||
//
|
||||
// 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.
|
||||
// The default behavior is permissive: the URL passes as long as it clears
|
||||
// the regex allow-list and deny-list. Callers that need IP-class checks
|
||||
// opt in via [WithDenyPrivateIPs] or [WithDenyPublicIPs]. 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, 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
|
||||
// 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
|
||||
// targets without a separate CheckRedirect.
|
||||
// outboundRoundTripper is an [http.RoundTripper] that validates each
|
||||
// request 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 targets without a separate CheckRedirect.
|
||||
type outboundRoundTripper struct {
|
||||
base http.RoundTripper
|
||||
allowList []*regexp2.Regexp
|
||||
denyList []*regexp2.Regexp
|
||||
opts []DecideOption
|
||||
}
|
||||
|
||||
// RoundTrip validates req.URL and delegates to the base transport.
|
||||
@@ -311,7 +328,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, rt.opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -321,15 +338,17 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
||||
}
|
||||
|
||||
// NewOutboundHttpClient returns an [http.Client] that validates every
|
||||
// outbound request URL via the same logic as [FilterOutboundURL] and pins
|
||||
// the resulting dial to a resolved public IP. An allow-list match
|
||||
// (operator opt-in to a specific destination) bypasses the IP check.
|
||||
// outbound request URL via the same logic as [FilterOutboundURL] and
|
||||
// pins the resulting dial to the resolved IPs.
|
||||
//
|
||||
// The client re-validates redirect targets automatically because the
|
||||
// underlying [http.Client] invokes the wrapping [http.RoundTripper] once
|
||||
// per hop. This closes the redirect-based SSRF bypass that affects raw
|
||||
// [http.Client] usage when no CheckRedirect is set.
|
||||
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp) *http.Client {
|
||||
//
|
||||
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
|
||||
// or [WithDenyPublicIPs] to opt into IP-class rejection.
|
||||
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, opts ...DecideOption) *http.Client {
|
||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||
base.DialContext = secureDialContext
|
||||
return &http.Client{
|
||||
@@ -338,6 +357,7 @@ func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2
|
||||
base: base,
|
||||
allowList: allowList,
|
||||
denyList: denyList,
|
||||
opts: opts,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -345,9 +365,11 @@ func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2
|
||||
// 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
|
||||
// dialer was used outside of [outboundRoundTripper]), it falls back to
|
||||
// resolving and checking the destination itself.
|
||||
// dials each in turn until one connects. When no decision is present
|
||||
// (the dialer was used outside of [outboundRoundTripper]), it falls back
|
||||
// to resolving the destination without IP-class checks so that the
|
||||
// fallback matches the permissive default and operators who need
|
||||
// restrictions configure them at the caller.
|
||||
func secureDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
@@ -363,7 +385,7 @@ func secureDialContext(ctx context.Context, network, addr string) (net.Conn, err
|
||||
}
|
||||
}
|
||||
|
||||
addrs, err := ResolveAndCheckPublic(ctx, host)
|
||||
addrs, err := resolveHost(ctx, host, false, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -373,8 +395,8 @@ func secureDialContext(ctx context.Context, network, addr string) (net.Conn, err
|
||||
// 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.
|
||||
// that [DecideOutbound] resolved, 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 {
|
||||
|
||||
@@ -106,6 +106,7 @@ func TestFilterOutboundURL(t *testing.T) {
|
||||
rawURL string
|
||||
allow []*regexp2.Regexp
|
||||
deny []*regexp2.Regexp
|
||||
opts []DecideOption
|
||||
stub func(host string) ([]netip.Addr, error)
|
||||
expectErr bool
|
||||
expectIs error
|
||||
@@ -135,6 +136,7 @@ func TestFilterOutboundURL(t *testing.T) {
|
||||
scenario: "Issue 2: IPv4-mapped IPv6 evades deny-list but blocked by IP check",
|
||||
rawURL: "http://[::ffff:127.0.0.1]:8080/page.pdf",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
@@ -142,28 +144,32 @@ func TestFilterOutboundURL(t *testing.T) {
|
||||
scenario: "Issue 2: IPv4-mapped IPv6 to RFC1918 blocked by IP check",
|
||||
rawURL: "http://[::ffff:10.0.0.1]/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to public IP passes",
|
||||
scenario: "hostname resolving to public IP passes with deny-private-ips",
|
||||
rawURL: "https://example.com/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to loopback blocked",
|
||||
scenario: "hostname resolving to loopback blocked with deny-private-ips",
|
||||
rawURL: "https://rebind.example/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "127.0.0.1"), nil },
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "hostname resolving to mixed public+private blocked",
|
||||
scenario: "hostname resolving to mixed public+private blocked with deny-private-ips",
|
||||
rawURL: "https://mixed.example/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "1.1.1.1", "10.0.0.1"), nil },
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
@@ -173,7 +179,7 @@ func TestFilterOutboundURL(t *testing.T) {
|
||||
rawURL: "http://internal.service/api",
|
||||
allow: []*regexp2.Regexp{regexp2.MustCompile(`^http://internal\.service`, 0)},
|
||||
deny: defaultDeny,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "10.0.0.1"), nil },
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
@@ -205,23 +211,25 @@ func TestFilterOutboundURL(t *testing.T) {
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 1: Chromium default does not block http to public host (regex layer)",
|
||||
scenario: "Chromium default permissive passes http to public host",
|
||||
rawURL: "https://example.com/",
|
||||
deny: chromiumDeny,
|
||||
stub: func(string) ([]netip.Addr, error) { return mustAddrs(t, "93.184.216.34"), nil },
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 1: Chromium default now blocks http to loopback via IP layer",
|
||||
scenario: "Chromium with deny-private-ips blocks http to loopback",
|
||||
rawURL: "http://127.0.0.1:3000/health",
|
||||
deny: chromiumDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "Issue 1: Chromium default now blocks cloud metadata via IP layer",
|
||||
scenario: "Chromium with deny-private-ips blocks cloud metadata",
|
||||
rawURL: "http://169.254.169.254/latest/meta-data/",
|
||||
deny: chromiumDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
@@ -237,9 +245,10 @@ func TestFilterOutboundURL(t *testing.T) {
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
{
|
||||
scenario: "userinfo cannot mask host",
|
||||
scenario: "userinfo cannot mask host when deny-private-ips enabled",
|
||||
rawURL: "http://example.com@127.0.0.1/",
|
||||
deny: defaultDeny,
|
||||
opts: []DecideOption{WithDenyPrivateIPs(true)},
|
||||
expectErr: true,
|
||||
expectIs: ErrFiltered,
|
||||
},
|
||||
@@ -255,7 +264,7 @@ func TestFilterOutboundURL(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
err := FilterOutboundURL(context.Background(), tc.rawURL, tc.allow, tc.deny, time.Now().Add(5*time.Second))
|
||||
err := FilterOutboundURL(context.Background(), tc.rawURL, tc.allow, tc.deny, time.Now().Add(5*time.Second), tc.opts...)
|
||||
|
||||
if tc.expectErr && err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
@@ -307,16 +316,111 @@ func TestResolveAndCheckPublic_HostResolvesToPublic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_AllowPrivateIPs_DenyListStillApplies(t *testing.T) {
|
||||
func TestDecideOutbound_DenyPrivateIPs_RejectsLoopbackLiteral(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.
|
||||
_, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://127.0.0.1:8080/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("WithDenyPrivateIPs(true) must reject loopback literal, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyPrivateIPs_AllowsPublic(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "93.184.216.34"), nil
|
||||
})
|
||||
|
||||
decision, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://example.com/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for public host, got: %v", err)
|
||||
}
|
||||
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "93.184.216.34" {
|
||||
t.Fatalf("decision.Pinned = %v, want [93.184.216.34]", decision.Pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyPublicIPs_RejectsPublic(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "1.1.1.1"), nil
|
||||
})
|
||||
|
||||
_, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://example.com/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPublicIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("WithDenyPublicIPs(true) must reject public host, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyPublicIPs_AllowsPrivate(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "10.0.0.5"), nil
|
||||
})
|
||||
|
||||
decision, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://internal.svc/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPublicIPs(true),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for private host, got: %v", err)
|
||||
}
|
||||
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
|
||||
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyBoth_WhitelistOnly(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "1.1.1.1"), nil
|
||||
})
|
||||
|
||||
// Both denies active and no allow-list match: every resolved address
|
||||
// fails. Only an allow-list match can permit a destination under
|
||||
// this posture.
|
||||
_, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://example.com/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithDenyPrivateIPs(true),
|
||||
WithDenyPublicIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("expected ErrFiltered with both denies enabled, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_DenyLists_WinOverDenyPrivateIPs(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
// The regex deny-list fires before any resolution; verifies that
|
||||
// operator-supplied deny patterns remain effective regardless of
|
||||
// IP-class options.
|
||||
deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, 0)}
|
||||
|
||||
_, err := DecideOutbound(
|
||||
@@ -324,9 +428,30 @@ func TestDecideOutbound_AllowPrivateIPs_DenyListStillApplies(t *testing.T) {
|
||||
"http://evil.local/",
|
||||
nil, deny,
|
||||
time.Now().Add(5*time.Second),
|
||||
WithAllowPrivateIPs(true),
|
||||
WithDenyPrivateIPs(true),
|
||||
)
|
||||
if !errors.Is(err, ErrFiltered) {
|
||||
t.Fatalf("deny-list must still win with WithAllowPrivateIPs(true), got: %v", err)
|
||||
t.Fatalf("deny-list must still reject, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecideOutbound_Permissive_AllowsPrivate(t *testing.T) {
|
||||
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||
return mustAddrs(t, "10.0.0.5"), nil
|
||||
})
|
||||
|
||||
// No options passed: default posture is permissive across both
|
||||
// IP classes. The caller still gets pinned IPs for dial safety.
|
||||
decision, err := DecideOutbound(
|
||||
context.Background(),
|
||||
"http://internal.svc/",
|
||||
nil, nil,
|
||||
time.Now().Add(5*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("permissive default must allow private host, got: %v", err)
|
||||
}
|
||||
if len(decision.Pinned) != 1 || decision.Pinned[0].String() != "10.0.0.5" {
|
||||
t.Fatalf("decision.Pinned = %v, want [10.0.0.5]", decision.Pinned)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user