fix(outbound)!: per-module deny-private-ips and deny-public-ips, permissive defaults

This commit is contained in:
Julien Neuhart
2026-04-23 20:01:27 +02:00
parent a2a8c42457
commit 7a914fce65
13 changed files with 365 additions and 162 deletions

View File

@@ -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,
})

View File

@@ -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"),

View File

@@ -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

View File

@@ -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) {

View File

@@ -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)