mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-16 09:13:17 +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
|
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 {
|
type chromiumBrowser struct {
|
||||||
initialCtx context.Context
|
initialCtx context.Context
|
||||||
ctx 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()))
|
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
|
// Chromium implicitly bypasses the proxy for loopback and
|
||||||
// link-local destinations. A WebSocket handshake is never surfaced
|
// link-local destinations. A WebSocket handshake is never surfaced
|
||||||
// as a fetch.EventRequestPaused, so listenForEventRequestPaused
|
// 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,
|
// it. Left alone, a page could open a WebSocket to 127.0.0.1, ::1,
|
||||||
// localhost, or the link-local cloud metadata endpoint
|
// localhost, or the link-local cloud metadata endpoint
|
||||||
// (169.254.169.254) and reach it unfiltered. "<-loopback>" removes
|
// (169.254.169.254) and reach it unfiltered. "<-loopback>" removes
|
||||||
// the implicit bypass so those handshakes also traverse the pinning
|
// the implicit bypass so those handshakes also traverse the
|
||||||
// proxy and go through [gotenberg.DecideOutbound] like every other
|
// pinning proxy and go through [gotenberg.DecideOutbound] like
|
||||||
// request.
|
// every other request.
|
||||||
//
|
//
|
||||||
// Gated on the IP-class policy: it is the control this closes, and
|
// Gated on a policy that can reject a network URL: without one,
|
||||||
// under it loopback and link-local HTTP sub-resources are already
|
// loopback is not restricted, and routing it through the proxy
|
||||||
// blocked by listenForEventRequestPaused before they would reach
|
// would only turn a refused loopback sub-resource into a 502
|
||||||
// the proxy, so this adds only the missing WebSocket coverage. When
|
// response instead of a loading failure.
|
||||||
// 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.
|
|
||||||
opts = append(opts, chromedp.Flag("proxy-bypass-list", "<-loopback>"))
|
opts = append(opts, chromedp.Flag("proxy-bypass-list", "<-loopback>"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestChromiumBrowser_Start_rejectsOverlappingStart guards against the latch
|
// 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")
|
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
|
// Chromium is a module that provides both an [Api] and routes for converting
|
||||||
// an HTML document to PDF.
|
// an HTML document to PDF.
|
||||||
type Chromium struct {
|
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.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.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-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-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-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")
|
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")
|
||||||
|
|||||||
@@ -521,6 +521,22 @@ Feature: /forms/chromium/convert/html
|
|||||||
| 'wss://127.0.0.1:9999/wss-test' targets a non-public address |
|
| 'wss://127.0.0.1:9999/wss-test' targets a non-public address |
|
||||||
| CONNECT blocked for '127.0.0.1:9999' |
|
| CONNECT blocked for '127.0.0.1:9999' |
|
||||||
|
|
||||||
|
# An allow-list is enforced on its own, without the IP-class flags. Chromium
|
||||||
|
# skips the proxy for loopback and link-local destinations unless told
|
||||||
|
# otherwise, so a WebSocket the allow-list rejects must still reach the
|
||||||
|
# pinning proxy to be severed.
|
||||||
|
@chromium-ssrf
|
||||||
|
Scenario: POST /forms/chromium/convert/html (WebSocket not in the allow-list is filtered)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | ^file:///tmp/.* |
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/ssrf-websocket-html/index.html | file |
|
||||||
|
| waitDelay | 1s | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the Gotenberg container should log the following entries:
|
||||||
|
| 'ws://127.0.0.1:9999/ssrf-websocket' does not match any expression from the allowed list |
|
||||||
|
| CONNECT blocked for '127.0.0.1:9999' |
|
||||||
|
|
||||||
# EventSource issues an ordinary HTTP GET, so unlike a WebSocket it IS surfaced
|
# EventSource issues an ordinary HTTP GET, so unlike a WebSocket it IS surfaced
|
||||||
# as a fetch.EventRequestPaused and blocked by listenForEventRequestPaused.
|
# as a fetch.EventRequestPaused and blocked by listenForEventRequestPaused.
|
||||||
@chromium-ssrf
|
@chromium-ssrf
|
||||||
@@ -629,7 +645,10 @@ Feature: /forms/chromium/convert/html
|
|||||||
Then there should be 1 PDF(s) in the response
|
Then there should be 1 PDF(s) in the response
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
|
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
|
||||||
Given I have a default Gotenberg container
|
# A configured allow-list routes loopback through the pinning proxy, which
|
||||||
|
# reports a refused connection as a 502 response, not a loading failure.
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
| files | testdata/feature-rich-html/index.html | file |
|
| files | testdata/feature-rich-html/index.html | file |
|
||||||
| failOnResourceLoadingFailed | true | field |
|
| failOnResourceLoadingFailed | true | field |
|
||||||
|
|||||||
@@ -410,7 +410,10 @@ Feature: /forms/chromium/convert/markdown
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/markdown (Fail On Resource Loading Failed)
|
Scenario: POST /forms/chromium/convert/markdown (Fail On Resource Loading Failed)
|
||||||
Given I have a default Gotenberg container
|
# A configured allow-list routes loopback through the pinning proxy, which
|
||||||
|
# reports a refused connection as a 502 response, not a loading failure.
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
|
||||||
| files | testdata/feature-rich-markdown/index.html | file |
|
| files | testdata/feature-rich-markdown/index.html | file |
|
||||||
| files | testdata/feature-rich-markdown/table.md | file |
|
| files | testdata/feature-rich-markdown/table.md | file |
|
||||||
|
|||||||
@@ -711,7 +711,10 @@ Feature: /forms/chromium/convert/url
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/url (Fail On Resource Loading Failed)
|
Scenario: POST /forms/chromium/convert/url (Fail On Resource Loading Failed)
|
||||||
Given I have a default Gotenberg container
|
# A configured allow-list routes loopback through the pinning proxy, which
|
||||||
|
# reports a refused connection as a 502 response, not a loading failure.
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
Given I have a static server
|
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):
|
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/feature-rich-html-remote/index.html | field |
|
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
|
||||||
|
|||||||
Reference in New Issue
Block a user