diff --git a/pkg/gotenberg/outbound.go b/pkg/gotenberg/outbound.go index 01329306..bb9aaf75 100644 --- a/pkg/gotenberg/outbound.go +++ b/pkg/gotenberg/outbound.go @@ -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 { diff --git a/pkg/gotenberg/outbound_test.go b/pkg/gotenberg/outbound_test.go index 8252835a..c07f851a 100644 --- a/pkg/gotenberg/outbound_test.go +++ b/pkg/gotenberg/outbound_test.go @@ -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) } } diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go index e8a85a5a..69326e52 100644 --- a/pkg/modules/api/api.go +++ b/pkg/modules/api/api.go @@ -57,10 +57,12 @@ type Api struct { } type downloadFromConfig struct { - allowList []*regexp2.Regexp - denyList []*regexp2.Regexp - maxRetry int - disable bool + allowList []*regexp2.Regexp + denyList []*regexp2.Regexp + denyPrivateIPs bool + denyPublicIPs bool + maxRetry int + disable bool } // Router is a module interface that adds routes to the [Api]. @@ -196,7 +198,9 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests") fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables") fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values") - fs.StringSlice("api-download-from-deny-list", []string{`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values") + fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values") + fs.Bool("api-download-from-deny-private-ips", false, "Reject downloadFrom URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted downloadFrom sources to mitigate SSRF against internal services") + fs.Bool("api-download-from-deny-public-ips", false, "Reject downloadFrom URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent downloads from reaching the public internet") fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature") fs.Bool("api-disable-download-from", false, "Disable the download from feature") fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route") @@ -235,10 +239,12 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { a.rootPath = flags.MustString("api-root-path") a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header") a.downloadFromCfg = downloadFromConfig{ - allowList: flags.MustRegexpSlice("api-download-from-allow-list"), - denyList: flags.MustRegexpSlice("api-download-from-deny-list"), - maxRetry: flags.MustInt("api-download-from-max-retry"), - disable: flags.MustBool("api-disable-download-from"), + allowList: flags.MustRegexpSlice("api-download-from-allow-list"), + denyList: flags.MustRegexpSlice("api-download-from-deny-list"), + denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"), + denyPublicIPs: flags.MustBool("api-download-from-deny-public-ips"), + maxRetry: flags.MustInt("api-download-from-max-retry"), + disable: flags.MustBool("api-disable-download-from"), } a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry") a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry") diff --git a/pkg/modules/api/context.go b/pkg/modules/api/context.go index 802923dd..a0bc3d2f 100644 --- a/pkg/modules/api/context.go +++ b/pkg/modules/api/context.go @@ -232,7 +232,11 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys ) } - err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline) + ipOpts := []gotenberg.DecideOption{ + gotenberg.WithDenyPrivateIPs(downloadFromCfg.denyPrivateIPs), + gotenberg.WithDenyPublicIPs(downloadFromCfg.denyPublicIPs), + } + err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline, ipOpts...) if err != nil { return fmt.Errorf("filter URL: %w", err) } @@ -268,7 +272,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys } client := &retryablehttp.Client{ - HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList), + HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, ipOpts...), RetryMax: downloadFromCfg.maxRetry, RetryWaitMin: time.Duration(1) * time.Second, RetryWaitMax: time.Until(deadline), diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index ccbf9597..4eb63c13 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -44,7 +44,8 @@ type browserArguments struct { // Tasks specific. allowList []*regexp2.Regexp denyList []*regexp2.Regexp - allowPrivateIPs bool + denyPrivateIPs bool + denyPublicIPs bool clearCache bool clearCookies bool disableJavaScript bool @@ -68,7 +69,7 @@ func newChromiumBrowser(arguments browserArguments) browser { initialCtx: context.Background(), arguments: arguments, fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), - pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.allowPrivateIPs), + pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs), } b.isStarted.Store(false) @@ -369,7 +370,10 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin // We validate the "main" URL against our allowed / deny lists, and // against the IP-based outbound URL guard. See [gotenberg.FilterOutboundURL]. - err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline, gotenberg.WithAllowPrivateIPs(b.arguments.allowPrivateIPs)) + err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline, + gotenberg.WithDenyPrivateIPs(b.arguments.denyPrivateIPs), + gotenberg.WithDenyPublicIPs(b.arguments.denyPublicIPs), + ) if err != nil { return fmt.Errorf("filter URL: %w", err) } @@ -390,7 +394,8 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{ allowList: b.arguments.allowList, denyList: b.arguments.denyList, - allowPrivateIPs: b.arguments.allowPrivateIPs, + denyPrivateIPs: b.arguments.denyPrivateIPs, + denyPublicIPs: b.arguments.denyPublicIPs, allowedFilePrefixes: options.AllowedFilePrefixes, extraHttpHeaders: options.ExtraHttpHeaders, }) diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index c392c1bc..ecabb54b 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -450,7 +450,8 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor { fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests") fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values") fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values") - fs.Bool("chromium-allow-private-ips", false, "Accept sub-resources that resolve to private, loopback, or link-local addresses. Intended for operators running Gotenberg inside a private network (Docker Compose, Kubernetes ClusterIP); the regex allow-list and deny-list still apply") + fs.Bool("chromium-deny-private-ips", false, "Reject URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted form input to mitigate SSRF against internal services") + fs.Bool("chromium-deny-public-ips", false, "Reject URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network") fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion") fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion") fs.Bool("chromium-disable-javascript", false, "Disable JavaScript") @@ -499,7 +500,8 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error { allowList: flags.MustRegexpSlice("chromium-allow-list"), denyList: flags.MustRegexpSlice("chromium-deny-list"), - allowPrivateIPs: flags.MustBool("chromium-allow-private-ips"), + denyPrivateIPs: flags.MustBool("chromium-deny-private-ips"), + denyPublicIPs: flags.MustBool("chromium-deny-public-ips"), clearCache: flags.MustBool("chromium-clear-cache"), clearCookies: flags.MustBool("chromium-clear-cookies"), disableJavaScript: flags.MustBool("chromium-disable-javascript"), diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go index b09ee1b6..e0597c49 100644 --- a/pkg/modules/chromium/events.go +++ b/pkg/modules/chromium/events.go @@ -25,7 +25,8 @@ import ( type eventRequestPausedOptions struct { allowList, denyList []*regexp2.Regexp - allowPrivateIPs bool + denyPrivateIPs bool + denyPublicIPs bool allowedFilePrefixes []string extraHttpHeaders []ExtraHttpHeader } @@ -53,7 +54,10 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio return } - err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline, gotenberg.WithAllowPrivateIPs(options.allowPrivateIPs)) + err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline, + gotenberg.WithDenyPrivateIPs(options.denyPrivateIPs), + gotenberg.WithDenyPublicIPs(options.denyPublicIPs), + ) if err != nil { logger.WarnContext(ctx, err.Error()) allow = false diff --git a/pkg/modules/chromium/pinning_proxy.go b/pkg/modules/chromium/pinning_proxy.go index 6bc92066..6e7ad9c7 100644 --- a/pkg/modules/chromium/pinning_proxy.go +++ b/pkg/modules/chromium/pinning_proxy.go @@ -53,15 +53,19 @@ type pinningProxy struct { } // newPinningProxy returns a pinning proxy configured with the given -// allow/deny lists. When allowPrivateIPs is true, the proxy skips the -// public-IP filter while still pinning resolved IPs to the dial. The +// allow/deny lists and IP-class policy. The policy bools are applied via +// [gotenberg.DecideOutbound] on every request the proxy sees, so +// Chromium inherits whatever posture the operator selected. The // returned proxy is not yet listening; call Start. -func newPinningProxy(allowList, denyList []*regexp2.Regexp, allowPrivateIPs bool) *pinningProxy { +func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs bool) *pinningProxy { return &pinningProxy{ allowList: allowList, denyList: denyList, decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) { - return gotenberg.DecideOutbound(ctx, rawURL, allow, deny, deadline, gotenberg.WithAllowPrivateIPs(allowPrivateIPs)) + return gotenberg.DecideOutbound(ctx, rawURL, allow, deny, deadline, + gotenberg.WithDenyPrivateIPs(denyPrivateIPs), + gotenberg.WithDenyPublicIPs(denyPublicIPs), + ) }, dialPinned: gotenberg.DialPinned, dialBypass: func(ctx context.Context, network, addr string) (net.Conn, error) { diff --git a/pkg/modules/chromium/pinning_proxy_test.go b/pkg/modules/chromium/pinning_proxy_test.go index 7371e0ea..2b60f618 100644 --- a/pkg/modules/chromium/pinning_proxy_test.go +++ b/pkg/modules/chromium/pinning_proxy_test.go @@ -84,7 +84,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) { upstreamURL := mustParseURL(t, upstream.URL) var decideCalls atomic.Int32 - p := newPinningProxy(nil, nil, false) + p := newPinningProxy(nil, nil, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { decideCalls.Add(1) return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil @@ -123,7 +123,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) { } func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) { - p := newPinningProxy(nil, nil, false) + p := newPinningProxy(nil, nil, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered) } @@ -159,7 +159,7 @@ func TestPinningProxy_Forward_Bypass(t *testing.T) { upstreamURL := mustParseURL(t, upstream.URL) var bypassCalls atomic.Int32 - p := newPinningProxy(nil, nil, false) + p := newPinningProxy(nil, nil, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { return gotenberg.OutboundDecision{Bypass: true}, nil } @@ -208,7 +208,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) { t.Cleanup(upstream.Close) upstreamURL := mustParseURL(t, upstream.URL) - p := newPinningProxy(nil, nil, false) + p := newPinningProxy(nil, nil, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil } @@ -248,7 +248,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) { } func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) { - p := newPinningProxy(nil, nil, false) + p := newPinningProxy(nil, nil, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { t.Fatal("decide must not be called for malformed proxy request") return gotenberg.OutboundDecision{}, nil @@ -288,7 +288,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) { t.Cleanup(stop) var decideCalls atomic.Int32 - p := newPinningProxy(nil, nil, false) + p := newPinningProxy(nil, nil, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { decideCalls.Add(1) return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil @@ -358,7 +358,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) { } func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) { - p := newPinningProxy(nil, nil, false) + p := newPinningProxy(nil, nil, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered) } @@ -417,7 +417,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) { return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered) } - p := newPinningProxy(nil, nil, false) + p := newPinningProxy(nil, nil, false, false) p.decide = stubDecide p.dialPinned = func(_ context.Context, network string, addrs []netip.Addr, _ string) (net.Conn, error) { if len(addrs) != 1 || addrs[0].String() != "93.184.216.34" { @@ -453,7 +453,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) { } func TestPinningProxy_StartTwice(t *testing.T) { - p := newPinningProxy(nil, nil, false) + p := newPinningProxy(nil, nil, false, false) err := p.Start(testLogger()) if err != nil { t.Fatalf("first Start: %v", err) @@ -467,7 +467,7 @@ func TestPinningProxy_StartTwice(t *testing.T) { } func TestPinningProxy_StopIdempotent(t *testing.T) { - p := newPinningProxy(nil, nil, false) + p := newPinningProxy(nil, nil, false, false) // Stop on a never-started proxy is a no-op. if err := p.Stop(testLogger()); err != nil { t.Fatalf("Stop on never-started proxy: %v", err) diff --git a/pkg/modules/webhook/middleware.go b/pkg/modules/webhook/middleware.go index 0660214f..eafcd092 100644 --- a/pkg/modules/webhook/middleware.go +++ b/pkg/modules/webhook/middleware.go @@ -127,15 +127,19 @@ func webhookMiddleware(w *Webhook) api.Middleware { } // Let's check if the webhook URLs are acceptable according to our - // allowed/denied lists, and against the IP-based outbound URL - // guard. See [gotenberg.FilterOutboundURL]. - err := gotenberg.FilterOutboundURL(ctx, webhookUrl, w.allowList, w.denyList, deadline) + // allowed/denied lists, and against the IP-class options. + // See [gotenberg.FilterOutboundURL]. + ipOpts := []gotenberg.DecideOption{ + gotenberg.WithDenyPrivateIPs(w.denyPrivateIPs), + gotenberg.WithDenyPublicIPs(w.denyPublicIPs), + } + err := gotenberg.FilterOutboundURL(ctx, webhookUrl, w.allowList, w.denyList, deadline, ipOpts...) if err != nil { return fmt.Errorf("filter webhook URL: %w", err) } if webhookErrorUrl != "" { - err = gotenberg.FilterOutboundURL(ctx, webhookErrorUrl, w.errorAllowList, w.errorDenyList, deadline) + err = gotenberg.FilterOutboundURL(ctx, webhookErrorUrl, w.errorAllowList, w.errorDenyList, deadline, ipOpts...) if err != nil { return fmt.Errorf("filter webhook error URL: %w", err) } @@ -198,7 +202,7 @@ func webhookMiddleware(w *Webhook) api.Middleware { // Filter the events URL if provided. if webhookEventsUrl != "" { - err = gotenberg.FilterOutboundURL(ctx, webhookEventsUrl, w.allowList, w.denyList, deadline) + err = gotenberg.FilterOutboundURL(ctx, webhookEventsUrl, w.allowList, w.denyList, deadline, ipOpts...) if err != nil { return fmt.Errorf("filter webhook events URL: %w", err) } @@ -220,7 +224,7 @@ func webhookMiddleware(w *Webhook) api.Middleware { startTime: startTime, client: &retryablehttp.Client{ - HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList), + HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, ipOpts...), RetryMax: w.maxRetry, RetryWaitMin: w.retryMinWait, RetryWaitMax: w.retryMaxWait, diff --git a/pkg/modules/webhook/webhook.go b/pkg/modules/webhook/webhook.go index 544f936c..1e7ebbdb 100644 --- a/pkg/modules/webhook/webhook.go +++ b/pkg/modules/webhook/webhook.go @@ -23,6 +23,8 @@ type Webhook struct { denyList []*regexp2.Regexp errorAllowList []*regexp2.Regexp errorDenyList []*regexp2.Regexp + denyPrivateIPs bool + denyPublicIPs bool maxRetry int retryMinWait time.Duration retryMaxWait time.Duration @@ -39,7 +41,9 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor { fs := flag.NewFlagSet("webhook", flag.ExitOnError) fs.Bool("webhook-enable-sync-mode", false, "Enable synchronous mode for the webhook feature") fs.StringSlice("webhook-allow-list", []string{}, "Set the allowed URLs for the webhook feature using regular expressions - supports multiple values") - fs.StringSlice("webhook-deny-list", []string{`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values") + fs.StringSlice("webhook-deny-list", []string{}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values") + fs.Bool("webhook-deny-private-ips", false, "Reject webhook URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted webhook destinations to mitigate SSRF against internal services") + fs.Bool("webhook-deny-public-ips", false, "Reject webhook URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent callbacks from leaving a private network") fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature") // Deprecated flags. @@ -72,6 +76,8 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error { w.denyList = flags.MustRegexpSlice("webhook-deny-list") w.errorAllowList = flags.MustDeprecatedRegexpSlice("webhook-error-allow-list", "webhook-allow-list") w.errorDenyList = flags.MustDeprecatedRegexpSlice("webhook-error-deny-list", "webhook-deny-list") + w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips") + w.denyPublicIPs = flags.MustBool("webhook-deny-public-ips") w.maxRetry = flags.MustInt("webhook-max-retry") w.retryMinWait = flags.MustDuration("webhook-retry-min-wait") w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait") diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature index b55a187c..d441e411 100644 --- a/test/integration/features/chromium_convert_url.feature +++ b/test/integration/features/chromium_convert_url.feature @@ -489,10 +489,21 @@ Feature: /forms/chromium/convert/url file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML """ - Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, allow-private-ips off) + Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, permissive default) Given I have a Gotenberg container with the following environment variable(s): | CHROMIUM_ALLOW_LIST | | Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + + Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, deny-private-ips on) + Given I have a Gotenberg container with the following environment variable(s): + | CHROMIUM_ALLOW_LIST | | + | CHROMIUM_DENY_PRIVATE_IPS | true | + Given I have a static server When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | Then the response status code should be 403 @@ -502,10 +513,10 @@ Feature: /forms/chromium/convert/url Forbidden """ - Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, allow-private-ips on) + Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, deny-private-ips on with allow-list bypass) Given I have a Gotenberg container with the following environment variable(s): - | CHROMIUM_ALLOW_LIST | | - | CHROMIUM_ALLOW_PRIVATE_IPS | true | + | CHROMIUM_ALLOW_LIST | .+ | + | CHROMIUM_DENY_PRIVATE_IPS | true | Given I have a static server When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | diff --git a/test/integration/features/debug.feature b/test/integration/features/debug.feature index f6edca33..1cc15a1e 100644 --- a/test/integration/features/debug.feature +++ b/test/integration/features/debug.feature @@ -63,7 +63,9 @@ Feature: /debug "api-disable-root-route-telemetry": "true", "api-disable-version-route-telemetry": "true", "api-download-from-allow-list": "[.+]", - "api-download-from-deny-list": "[^https?://(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|169\\.254\\.|0\\.0\\.0\\.0|127\\.|localhost|\\[::1\\]|\\[fd)]", + "api-download-from-deny-list": "[]", + "api-download-from-deny-private-ips": "false", + "api-download-from-deny-public-ips": "false", "api-download-from-max-retry": "4", "api-enable-basic-auth": "false", "api-enable-debug-route": "true", @@ -78,11 +80,12 @@ Feature: /debug "chromium-allow-file-access-from-files": "false", "chromium-allow-insecure-localhost": "false", "chromium-allow-list": "[.+]", - "chromium-allow-private-ips": "false", "chromium-auto-start": "false", "chromium-clear-cache": "false", "chromium-clear-cookies": "false", "chromium-deny-list": "[^file:(?!//\\/tmp/).*]", + "chromium-deny-private-ips": "false", + "chromium-deny-public-ips": "false", "chromium-disable-javascript": "false", "chromium-disable-routes": "false", "chromium-disable-web-security": "false", @@ -127,7 +130,9 @@ Feature: /debug "prometheus-metrics-path": "/prometheus/metrics", "webhook-allow-list": "[.+]", "webhook-client-timeout": "30s", - "webhook-deny-list": "[^https?://(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|169\\.254\\.|0\\.0\\.0\\.0|127\\.|localhost|\\[::1\\]|\\[fd)]", + "webhook-deny-list": "[]", + "webhook-deny-private-ips": "false", + "webhook-deny-public-ips": "false", "webhook-disable": "false", "webhook-error-allow-list": "[]", "webhook-error-deny-list": "[]", @@ -196,7 +201,9 @@ Feature: /debug "api-disable-root-route-telemetry": "true", "api-disable-version-route-telemetry": "true", "api-download-from-allow-list": "[.+]", - "api-download-from-deny-list": "[^https?://(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|169\\.254\\.|0\\.0\\.0\\.0|127\\.|localhost|\\[::1\\]|\\[fd)]", + "api-download-from-deny-list": "[]", + "api-download-from-deny-private-ips": "false", + "api-download-from-deny-public-ips": "false", "api-download-from-max-retry": "4", "api-enable-basic-auth": "false", "api-enable-debug-route": "true", @@ -211,11 +218,12 @@ Feature: /debug "chromium-allow-file-access-from-files": "false", "chromium-allow-insecure-localhost": "false", "chromium-allow-list": "[.+]", - "chromium-allow-private-ips": "false", "chromium-auto-start": "false", "chromium-clear-cache": "false", "chromium-clear-cookies": "false", "chromium-deny-list": "[^file:(?!//\\/tmp/).*]", + "chromium-deny-private-ips": "false", + "chromium-deny-public-ips": "false", "chromium-disable-javascript": "false", "chromium-disable-routes": "false", "chromium-disable-web-security": "false", @@ -260,7 +268,9 @@ Feature: /debug "prometheus-metrics-path": "/prometheus/metrics", "webhook-allow-list": "[.+]", "webhook-client-timeout": "30s", - "webhook-deny-list": "[^https?://(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|169\\.254\\.|0\\.0\\.0\\.0|127\\.|localhost|\\[::1\\]|\\[fd)]", + "webhook-deny-list": "[]", + "webhook-deny-private-ips": "false", + "webhook-deny-public-ips": "false", "webhook-disable": "false", "webhook-error-allow-list": "[]", "webhook-error-deny-list": "[]",