mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-15 20:02:15 +01:00
fix(outbound)!: per-module deny-private-ips and deny-public-ips, permissive defaults
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user