mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-14 16:23:18 +01:00
fix(chromium): route loopback WebSockets through the pinning proxy when an outbound policy is set
This commit is contained in:
@@ -75,6 +75,20 @@ type browserArguments struct {
|
||||
disableJavaScript bool
|
||||
}
|
||||
|
||||
// restrictsNetwork reports whether the outbound policy can reject a network
|
||||
// URL. The default deny-list only rejects file:// URLs, so it does not count.
|
||||
func (a browserArguments) restrictsNetwork() bool {
|
||||
if a.denyPrivateIPs || a.denyPublicIPs || len(a.allowList) > 0 {
|
||||
return true
|
||||
}
|
||||
for _, pattern := range a.denyList {
|
||||
if pattern.String() != defaultDenyListPattern {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type chromiumBrowser struct {
|
||||
initialCtx context.Context
|
||||
ctx context.Context
|
||||
@@ -205,7 +219,7 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
}
|
||||
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
||||
|
||||
if b.arguments.denyPrivateIPs || b.arguments.denyPublicIPs {
|
||||
if b.arguments.restrictsNetwork() {
|
||||
// Chromium implicitly bypasses the proxy for loopback and
|
||||
// link-local destinations. A WebSocket handshake is never surfaced
|
||||
// as a fetch.EventRequestPaused, so listenForEventRequestPaused
|
||||
@@ -213,17 +227,14 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
||||
// it. Left alone, a page could open a WebSocket to 127.0.0.1, ::1,
|
||||
// localhost, or the link-local cloud metadata endpoint
|
||||
// (169.254.169.254) and reach it unfiltered. "<-loopback>" removes
|
||||
// the implicit bypass so those handshakes also traverse the pinning
|
||||
// proxy and go through [gotenberg.DecideOutbound] like every other
|
||||
// request.
|
||||
// the implicit bypass so those handshakes also traverse the
|
||||
// pinning proxy and go through [gotenberg.DecideOutbound] like
|
||||
// every other request.
|
||||
//
|
||||
// Gated on the IP-class policy: it is the control this closes, and
|
||||
// under it loopback and link-local HTTP sub-resources are already
|
||||
// blocked by listenForEventRequestPaused before they would reach
|
||||
// the proxy, so this adds only the missing WebSocket coverage. When
|
||||
// the policy is off, loopback is not restricted, and routing it
|
||||
// through the proxy would merely change how an unreachable loopback
|
||||
// sub-resource reports its failure.
|
||||
// Gated on a policy that can reject a network URL: without one,
|
||||
// loopback is not restricted, and routing it through the proxy
|
||||
// would only turn a refused loopback sub-resource into a 502
|
||||
// response instead of a loading failure.
|
||||
opts = append(opts, chromedp.Flag("proxy-bypass-list", "<-loopback>"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/dlclark/regexp2/v2"
|
||||
)
|
||||
|
||||
// TestChromiumBrowser_Start_rejectsOverlappingStart guards against the latch
|
||||
@@ -59,3 +61,36 @@ func TestChromiumDisableFeatures(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A WebSocket the outbound policy rejects must reach the pinning proxy, so
|
||||
// the implicit loopback proxy bypass is removed whenever a policy can reject
|
||||
// a network URL. See GHSA-73vq-mh8c-27wg.
|
||||
func TestBrowserArguments_restrictsNetwork(t *testing.T) {
|
||||
re := func(patterns ...string) []*regexp2.Regexp {
|
||||
var res []*regexp2.Regexp
|
||||
for _, p := range patterns {
|
||||
res = append(res, regexp2.MustCompile(p, regexp2.None))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
scenario string
|
||||
args browserArguments
|
||||
expect bool
|
||||
}{
|
||||
{scenario: "default deny-list only", args: browserArguments{denyList: re(defaultDenyListPattern)}, expect: false},
|
||||
{scenario: "no lists", args: browserArguments{}, expect: false},
|
||||
{scenario: "allow-list", args: browserArguments{allowList: re(`^https://example\.com`), denyList: re(defaultDenyListPattern)}, expect: true},
|
||||
{scenario: "custom deny-list", args: browserArguments{denyList: re(`^http://internal`)}, expect: true},
|
||||
{scenario: "default and custom deny-list", args: browserArguments{denyList: re(defaultDenyListPattern, `^http://internal`)}, expect: true},
|
||||
{scenario: "deny private IPs", args: browserArguments{denyList: re(defaultDenyListPattern), denyPrivateIPs: true}, expect: true},
|
||||
{scenario: "deny public IPs", args: browserArguments{denyPublicIPs: true}, expect: true},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
if got := tc.args.restrictsNetwork(); got != tc.expect {
|
||||
t.Fatalf("restrictsNetwork() = %t, want %t", got, tc.expect)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,10 @@ var (
|
||||
ErrPageRangesExceedsPageCount = errors.New("page ranges exceeds page count")
|
||||
)
|
||||
|
||||
// defaultDenyListPattern is the default --chromium-deny-list entry. It only
|
||||
// rejects file:// URLs outside /tmp.
|
||||
const defaultDenyListPattern = `^file:(?!//\/tmp/).*`
|
||||
|
||||
// Chromium is a module that provides both an [Api] and routes for converting
|
||||
// an HTML document to PDF.
|
||||
type Chromium struct {
|
||||
@@ -476,7 +480,7 @@ 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.Bool("chromium-enable-environment-proxy", false, "Route Chromium's outbound requests through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials. Use this instead of --chromium-proxy-server for authenticated proxies, and leave --chromium-proxy-server and --chromium-host-resolver-rules unset")
|
||||
fs.StringSlice("chromium-allow-list", []string{}, `Set the allowed URLs for Chromium using regular expressions - supports multiple values. A match bypasses --chromium-deny-private-ips (CHROMIUM_DENY_PRIVATE_IPS) and --chromium-deny-public-ips (CHROMIUM_DENY_PUBLIC_IPS), so terminate the host or the pattern also matches suffix hosts, for example ^https?://internal\.svc(:|/|$)`)
|
||||
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
|
||||
fs.StringSlice("chromium-deny-list", []string{defaultDenyListPattern}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
|
||||
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-shared-browser-context", false, "Run every conversion in one shared browser context, so cookies, cache, and storage carry over between conversions. Only enable it when all callers trust each other: a conversion can then reuse cookies another one set")
|
||||
|
||||
Reference in New Issue
Block a user