diff --git a/Makefile b/Makefile index ec4c0edb..94469753 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,8 @@ GOTENBERG_BUILD_DEBUG_DATA=true API_PORT=3000 API_PORT_FROM_ENV= API_BIND_IP= +API_TLS_CERT_FILE= +API_TLS_KEY_FILE= API_START_TIMEOUT=30s API_TIMEOUT=30s API_BODY_LIMIT= @@ -28,6 +30,9 @@ GOTENBERG_API_BASIC_AUTH_USERNAME= GOTENBERG_API_BASIC_AUTH_PASSWORD= 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_PRIVATE_IPS=false +API_DOWNLOAD_FROM_DENY_PUBLIC_IPS=false +API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY=false API_DOWNLOAD_FROM_MAX_RETRY=4 API_DISABLE_DOWNLOAD_FROM=false API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true @@ -47,8 +52,11 @@ CHROMIUM_DISABLE_WEB_SECURITY=false CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false CHROMIUM_HOST_RESOLVER_RULES= CHROMIUM_PROXY_SERVER= +CHROMIUM_ENABLE_ENVIRONMENT_PROXY=false CHROMIUM_ALLOW_LIST= CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).* +CHROMIUM_DENY_PRIVATE_IPS=false +CHROMIUM_DENY_PUBLIC_IPS=false CHROMIUM_CLEAR_CACHE=false CHROMIUM_CLEAR_COOKIES=false CHROMIUM_DISABLE_JAVASCRIPT=false @@ -60,6 +68,9 @@ LIBREOFFICE_AUTO_START=false LIBREOFFICE_START_TIMEOUT=20s LIBREOFFICE_ALLOW_LIST= LIBREOFFICE_DENY_LIST= +LIBREOFFICE_DENY_PRIVATE_IPS=false +LIBREOFFICE_DENY_PUBLIC_IPS=false +LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY=false LIBREOFFICE_DISABLE_ROUTES=false LOG_LEVEL=info LOG_FIELDS_PREFIX= @@ -97,6 +108,9 @@ OTEL_EXPORTER_OTLP_INSECURE=true WEBHOOK_ENABLE_SYNC_MODE=false WEBHOOK_ALLOW_LIST= 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_PRIVATE_IPS=false +WEBHOOK_DENY_PUBLIC_IPS=false +WEBHOOK_ENABLE_ENVIRONMENT_PROXY=false WEBHOOK_MAX_RETRY=4 WEBHOOK_RETRY_MIN_WAIT=1s WEBHOOK_RETRY_MAX_WAIT=30s diff --git a/compose.yaml b/compose.yaml index b6423eaa..0f864817 100644 --- a/compose.yaml +++ b/compose.yaml @@ -21,6 +21,8 @@ services: - "--api-port=${API_PORT}" - "--api-port-from-env=${API_PORT_FROM_ENV}" - "--api-bind-ip=${API_BIND_IP}" + - "--api-tls-cert-file=${API_TLS_CERT_FILE}" + - "--api-tls-key-file=${API_TLS_KEY_FILE}" - "--api-start-timeout=${API_START_TIMEOUT}" - "--api-timeout=${API_TIMEOUT}" - "--api-body-limit=${API_BODY_LIMIT}" @@ -29,6 +31,9 @@ services: - "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}" - "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}" - "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}" + - "--api-download-from-deny-private-ips=${API_DOWNLOAD_FROM_DENY_PRIVATE_IPS}" + - "--api-download-from-deny-public-ips=${API_DOWNLOAD_FROM_DENY_PUBLIC_IPS}" + - "--api-download-from-enable-environment-proxy=${API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY}" - "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}" - "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}" - "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}" @@ -48,8 +53,11 @@ services: - "--chromium-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}" - "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}" - "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}" + - "--chromium-enable-environment-proxy=${CHROMIUM_ENABLE_ENVIRONMENT_PROXY}" - "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}" - "--chromium-deny-list=${CHROMIUM_DENY_LIST}" + - "--chromium-deny-private-ips=${CHROMIUM_DENY_PRIVATE_IPS}" + - "--chromium-deny-public-ips=${CHROMIUM_DENY_PUBLIC_IPS}" - "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}" - "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}" - "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}" @@ -61,6 +69,9 @@ services: - "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}" - "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}" - "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}" + - "--libreoffice-deny-private-ips=${LIBREOFFICE_DENY_PRIVATE_IPS}" + - "--libreoffice-deny-public-ips=${LIBREOFFICE_DENY_PUBLIC_IPS}" + - "--libreoffice-enable-environment-proxy=${LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY}" - "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}" - "--log-level=${LOG_LEVEL}" - "--log-fields-prefix=${LOG_FIELDS_PREFIX}" @@ -91,6 +102,9 @@ services: - "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}" - "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}" - "--webhook-deny-list=${WEBHOOK_DENY_LIST}" + - "--webhook-deny-private-ips=${WEBHOOK_DENY_PRIVATE_IPS}" + - "--webhook-deny-public-ips=${WEBHOOK_DENY_PUBLIC_IPS}" + - "--webhook-enable-environment-proxy=${WEBHOOK_ENABLE_ENVIRONMENT_PROXY}" - "--webhook-max-retry=${WEBHOOK_MAX_RETRY}" - "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}" - "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}" diff --git a/pkg/gotenberg/outbound.go b/pkg/gotenberg/outbound.go index 5bfba479..874f315f 100644 --- a/pkg/gotenberg/outbound.go +++ b/pkg/gotenberg/outbound.go @@ -1,7 +1,10 @@ package gotenberg import ( + "bufio" "context" + "crypto/tls" + "encoding/base64" "errors" "fmt" "net" @@ -12,6 +15,7 @@ import ( "time" "github.com/dlclark/regexp2" + "golang.org/x/net/http/httpproxy" ) // ErrNonPublicIP indicates that an outbound URL targets an IP address that @@ -389,9 +393,37 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er // // 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 { +// +// When enableEnvironmentProxy is true, the client routes through the proxy +// defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, +// including any credentials embedded in those URLs. In that mode the proxy +// owns DNS and egress, so destination dial pinning does not apply; the URL +// allow/deny and IP-class validation still runs. Callers gate this behind +// their module's opt-in flag. See +// https://github.com/gotenberg/gotenberg/issues/1592. +func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, enableEnvironmentProxy bool, opts ...DecideOption) *http.Client { base := http.DefaultTransport.(*http.Transport).Clone() - base.DialContext = secureDialContext + + if enableEnvironmentProxy { + // Route through the operator's proxy (standard env vars, credentials + // included). NO_PROXY hosts get a direct, unpinned dial. + // httpproxy.FromEnvironment reads the environment now rather than + // caching it process-wide like http.ProxyFromEnvironment. + proxyFunc := httpproxy.FromEnvironment().ProxyFunc() + base.Proxy = func(req *http.Request) (*url.URL, error) { + return proxyFunc(req.URL) + } + base.DialContext = outboundDialer.DialContext + } else { + // Default: ignore any proxy environment variables and pin the dial to + // the IPs resolved during validation, closing the DNS-rebinding + // window. Clearing Proxy is deliberate: the cloned default transport + // carries http.ProxyFromEnvironment, which combined with the pinned + // dialer would connect to the destination IP on the proxy's port. + base.Proxy = nil + base.DialContext = secureDialContext + } + return &http.Client{ Timeout: timeout, Transport: &outboundRoundTripper{ @@ -452,3 +484,99 @@ func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port st } return nil, lastErr } + +// DialThroughProxy opens a TCP tunnel to target (a host:port) through the +// HTTP CONNECT proxy at proxyURL, authenticating with any credentials +// embedded in proxyURL. dialProxy dials the proxy's own address; callers pass +// a plain dialer. Chromium and soffice cannot authenticate to a proxy +// themselves, so Gotenberg performs the CONNECT handshake on their behalf. +// The returned connection carries the raw tunnel for the caller to splice +// with the client. See https://github.com/gotenberg/gotenberg/issues/1592. +func DialThroughProxy(ctx context.Context, proxyURL *url.URL, target string, dialProxy func(ctx context.Context, network, addr string) (net.Conn, error)) (net.Conn, error) { + conn, err := dialProxy(ctx, "tcp", proxyHostPort(proxyURL)) + if err != nil { + return nil, fmt.Errorf("dial proxy: %w", err) + } + + if proxyURL.Scheme == "https" { + tlsConn := tls.Client(conn, &tls.Config{ServerName: proxyURL.Hostname()}) + err = tlsConn.HandshakeContext(ctx) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("TLS handshake with proxy: %w", err) + } + conn = tlsConn + } + + // Bound the CONNECT handshake by the request deadline; cleared once the + // tunnel is established so splicing manages its own lifetime. + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + connectReq := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Opaque: target}, + Host: target, + Header: make(http.Header), + } + if user := proxyURL.User; user != nil { + password, _ := user.Password() + connectReq.Header.Set("Proxy-Authorization", proxyAuthHeader(user.Username(), password)) + } + + err = connectReq.Write(conn) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("write CONNECT to proxy: %w", err) + } + + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, connectReq) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("read CONNECT response from proxy: %w", err) + } + // A CONNECT response carries no body; discard defensively. + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + _ = conn.Close() + return nil, fmt.Errorf("proxy refused CONNECT to %q with status %d", target, resp.StatusCode) + } + + _ = conn.SetDeadline(time.Time{}) + + // The reader may hold bytes the proxy sent right after the response; + // overlay it so those tunnel bytes are not lost when splicing. + return &bufferedConn{Conn: conn, r: br}, nil +} + +// proxyHostPort returns proxyURL's host:port, defaulting the port from the +// scheme when the URL omits it. +func proxyHostPort(proxyURL *url.URL) string { + port := proxyURL.Port() + if port == "" { + port = "80" + if proxyURL.Scheme == "https" { + port = "443" + } + } + return net.JoinHostPort(proxyURL.Hostname(), port) +} + +// proxyAuthHeader builds a Basic Proxy-Authorization header value. +func proxyAuthHeader(username, password string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) +} + +// bufferedConn overlays a [bufio.Reader] on a [net.Conn] so that bytes +// buffered while reading a proxy's CONNECT response are not lost when the +// tunnel is spliced. +type bufferedConn struct { + net.Conn + r *bufio.Reader +} + +func (c *bufferedConn) Read(b []byte) (int, error) { + return c.r.Read(b) +} diff --git a/pkg/gotenberg/outbound_proxy_test.go b/pkg/gotenberg/outbound_proxy_test.go new file mode 100644 index 00000000..5004b248 --- /dev/null +++ b/pkg/gotenberg/outbound_proxy_test.go @@ -0,0 +1,153 @@ +package gotenberg + +import ( + "bufio" + "context" + "encoding/base64" + "io" + "net" + "net/http" + "net/url" + "sync" + "testing" + "time" +) + +// connectCapture records the CONNECT request a proxy stub received. +type connectCapture struct { + mu sync.Mutex + method string + host string + auth string +} + +func (c *connectCapture) set(method, host, auth string) { + c.mu.Lock() + defer c.mu.Unlock() + c.method, c.host, c.auth = method, host, auth +} + +func (c *connectCapture) get() (string, string, string) { + c.mu.Lock() + defer c.mu.Unlock() + return c.method, c.host, c.auth +} + +// startConnectProxyStub starts a raw TCP server that behaves like an HTTP +// CONNECT proxy: it reads the CONNECT request, records it, replies 200 with a +// greeting appended to the same write (to exercise buffered-byte handling), +// then echoes tunnel bytes back to the caller. +func startConnectProxyStub(t *testing.T, capture *connectCapture) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = l.Close() }) + + go func() { + conn, err := l.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + br := bufio.NewReader(conn) + req, err := http.ReadRequest(br) + if err != nil { + return + } + capture.set(req.Method, req.Host, req.Header.Get("Proxy-Authorization")) + + // The greeting rides along with the response so the client's CONNECT + // response parser buffers it; bufferedConn must not drop it. + _, _ = conn.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\nTUNNEL-HELLO")) + _, _ = io.Copy(conn, br) + }() + + return l.Addr().String() +} + +func TestDialThroughProxy(t *testing.T) { + capture := &connectCapture{} + addr := startConnectProxyStub(t, capture) + + proxyURL := &url.URL{Scheme: "http", Host: addr, User: url.UserPassword("alice", "s3cr3t")} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + conn, err := DialThroughProxy(ctx, proxyURL, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) + }) + if err != nil { + t.Fatalf("DialThroughProxy: %v", err) + } + defer func() { _ = conn.Close() }() + + // The greeting buffered while reading the CONNECT response must survive. + greeting := make([]byte, len("TUNNEL-HELLO")) + _, err = io.ReadFull(conn, greeting) + if err != nil { + t.Fatalf("read greeting: %v", err) + } + if string(greeting) != "TUNNEL-HELLO" { + t.Fatalf("greeting = %q, want TUNNEL-HELLO", greeting) + } + + // The tunnel must round-trip bytes. + _, err = conn.Write([]byte("ping")) + if err != nil { + t.Fatalf("write to tunnel: %v", err) + } + echo := make([]byte, 4) + _, err = io.ReadFull(conn, echo) + if err != nil { + t.Fatalf("read echo: %v", err) + } + if string(echo) != "ping" { + t.Fatalf("echo = %q, want ping", echo) + } + + method, host, auth := capture.get() + if method != http.MethodConnect { + t.Fatalf("proxy saw method %q, want CONNECT", method) + } + if host != "example.com:443" { + t.Fatalf("proxy saw target %q, want example.com:443", host) + } + wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("alice:s3cr3t")) + if auth != wantAuth { + t.Fatalf("proxy saw Proxy-Authorization %q, want %q", auth, wantAuth) + } +} + +func TestDialThroughProxy_RefusedStatus(t *testing.T) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = l.Close() }) + + go func() { + conn, err := l.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + br := bufio.NewReader(conn) + _, _ = http.ReadRequest(br) + _, _ = conn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n")) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err = DialThroughProxy(ctx, &url.URL{Scheme: "http", Host: l.Addr().String()}, "example.com:443", func(ctx context.Context, network, addr string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, network, addr) + }) + if err == nil { + t.Fatal("expected an error when the proxy refuses CONNECT, got nil") + } +} diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go index 69326e52..54423207 100644 --- a/pkg/modules/api/api.go +++ b/pkg/modules/api/api.go @@ -57,12 +57,13 @@ type Api struct { } type downloadFromConfig struct { - allowList []*regexp2.Regexp - denyList []*regexp2.Regexp - denyPrivateIPs bool - denyPublicIPs bool - maxRetry int - disable bool + allowList []*regexp2.Regexp + denyList []*regexp2.Regexp + denyPrivateIPs bool + denyPublicIPs bool + enableEnvironmentProxy bool + maxRetry int + disable bool } // Router is a module interface that adds routes to the [Api]. @@ -201,6 +202,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { 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.Bool("api-download-from-enable-environment-proxy", false, "Route downloadFrom fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials") 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") @@ -239,12 +241,13 @@ 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"), - 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"), + 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"), + enableEnvironmentProxy: flags.MustBool("api-download-from-enable-environment-proxy"), + 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 f9e11d45..88811d64 100644 --- a/pkg/modules/api/context.go +++ b/pkg/modules/api/context.go @@ -281,7 +281,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, ipOpts...), + HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, downloadFromCfg.enableEnvironmentProxy, 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 3bd1bb85..e2499bc4 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -38,6 +38,7 @@ type browserArguments struct { allowFileAccessFromFiles bool hostResolverRules string proxyServer string + enableEnvironmentProxy bool wsUrlReadTimeout time.Duration hyphenDataDirPath string @@ -77,7 +78,7 @@ func newChromiumBrowser(arguments browserArguments) browser { initialCtx: context.Background(), arguments: arguments, fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), - pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs), + pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.denyPrivateIPs, arguments.denyPublicIPs, arguments.enableEnvironmentProxy), } b.isStarted.Store(false) diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 999b04ef..5438b08d 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -460,6 +460,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor { fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs") fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver") 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") fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "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") @@ -507,6 +508,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error { allowFileAccessFromFiles: flags.MustBool("chromium-allow-file-access-from-files"), hostResolverRules: flags.MustString("chromium-host-resolver-rules"), proxyServer: flags.MustString("chromium-proxy-server"), + enableEnvironmentProxy: flags.MustBool("chromium-enable-environment-proxy"), wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"), hyphenDataDirPath: hyphenDataDirPath, diff --git a/pkg/modules/chromium/pinning_proxy.go b/pkg/modules/chromium/pinning_proxy.go index 53454d62..d018e5bc 100644 --- a/pkg/modules/chromium/pinning_proxy.go +++ b/pkg/modules/chromium/pinning_proxy.go @@ -9,10 +9,12 @@ import ( "net" "net/http" "net/netip" + "net/url" "sync" "time" "github.com/dlclark/regexp2" + "golang.org/x/net/http/httpproxy" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -43,6 +45,14 @@ type pinningProxy struct { // allow-list opt-in). Tests may override it. dialBypass func(ctx context.Context, network, addr string) (net.Conn, error) + // upstreamProxy resolves the upstream (corporate) proxy for a + // destination URL from the standard proxy environment variables, or + // returns a nil URL to connect directly. It is nil unless the operator + // opted into proxy-environment honoring. When set, the pinning proxy + // performs the authenticated proxy handshake that Chromium cannot. See + // https://github.com/gotenberg/gotenberg/issues/1592. + upstreamProxy func(*url.URL) (*url.URL, error) + listener net.Listener server *http.Server wg sync.WaitGroup @@ -57,8 +67,8 @@ type pinningProxy struct { // [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, denyPrivateIPs, denyPublicIPs bool) *pinningProxy { - return &pinningProxy{ +func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs, enableEnvironmentProxy bool) *pinningProxy { + p := &pinningProxy{ allowList: allowList, denyList: denyList, decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) { @@ -73,6 +83,14 @@ func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, deny return dialer.DialContext(ctx, network, addr) }, } + + if enableEnvironmentProxy { + // Honor the standard proxy environment variables, credentials + // included. httpproxy reads the environment now and applies NO_PROXY. + p.upstreamProxy = httpproxy.FromEnvironment().ProxyFunc() + } + + return p } // Start binds the proxy to 127.0.0.1 on an ephemeral port and serves in a @@ -188,8 +206,24 @@ func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) { return } + // When the operator routes egress through an authenticated proxy, + // Chromium cannot supply the credentials itself, so the pinning proxy + // performs the CONNECT (and authentication) upstream. The decision above + // still gated the destination through the allow/deny and IP-class rules. + var proxyURL *url.URL + if p.upstreamProxy != nil { + proxyURL, err = p.upstreamProxy(&url.URL{Scheme: "https", Host: req.Host}) + if err != nil { + p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.Host, err)) + http.Error(w, "upstream proxy error", http.StatusBadGateway) + return + } + } + var upstream net.Conn switch { + case proxyURL != nil: + upstream, err = p.dialThroughUpstreamProxy(req.Context(), proxyURL, req.Host) case decision.Bypass: upstream, err = p.dialBypass(req.Context(), "tcp", req.Host) case len(decision.Pinned) > 0: @@ -275,17 +309,35 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) { return } + var proxyURL *url.URL + if p.upstreamProxy != nil { + proxyURL, err = p.upstreamProxy(req.URL) + if err != nil { + p.logger.WarnContext(req.Context(), fmt.Sprintf("resolve upstream proxy for '%s': %s", req.URL.Redacted(), err)) + http.Error(w, "upstream proxy error", http.StatusBadGateway) + return + } + } + outReq := req.Clone(req.Context()) outReq.RequestURI = "" stripHopByHopHeaders(outReq.Header) + // Build a fresh transport per request. The decision contains the pinned + // IPs to dial; reusing a transport across requests would leak the + // decision's closure across unrelated targets. transport := &http.Transport{ - // Build a fresh transport per request. The decision contains the - // pinned IPs to dial; reusing a transport across requests would - // leak the decision's closure across unrelated targets. DisableKeepAlives: true, - Proxy: nil, - DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + } + if proxyURL != nil { + // The upstream proxy owns DNS and egress; Go adds Proxy-Authorization + // from the URL's credentials. The decision above already gated the + // destination, and dialBypass dials the proxy host directly. + transport.Proxy = http.ProxyURL(proxyURL) + transport.DialContext = p.dialBypass + } else { + transport.Proxy = nil + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { _, port, splitErr := net.SplitHostPort(addr) if splitErr != nil { return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr) @@ -298,7 +350,7 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) { default: return nil, errors.New("no pinned addresses and not bypassed") } - }, + } } defer transport.CloseIdleConnections() @@ -364,3 +416,11 @@ func isClientCancellation(ctx context.Context, err error) bool { } return ctx.Err() != nil } + +// dialThroughUpstreamProxy tunnels to target through the upstream proxy, +// letting [gotenberg.DialThroughProxy] perform the authenticated CONNECT that +// Chromium cannot. dialBypass dials the proxy itself and is overridable in +// tests. See https://github.com/gotenberg/gotenberg/issues/1592. +func (p *pinningProxy) dialThroughUpstreamProxy(ctx context.Context, proxyURL *url.URL, target string) (net.Conn, error) { + return gotenberg.DialThroughProxy(ctx, proxyURL, target, p.dialBypass) +} diff --git a/pkg/modules/chromium/pinning_proxy_test.go b/pkg/modules/chromium/pinning_proxy_test.go index b17aee1c..337945d0 100644 --- a/pkg/modules/chromium/pinning_proxy_test.go +++ b/pkg/modules/chromium/pinning_proxy_test.go @@ -112,7 +112,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) { upstreamURL := mustParseURL(t, upstream.URL) var decideCalls atomic.Int32 - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, 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 @@ -151,7 +151,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) { } func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) { - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered) } @@ -187,7 +187,7 @@ func TestPinningProxy_Forward_Bypass(t *testing.T) { upstreamURL := mustParseURL(t, upstream.URL) var bypassCalls atomic.Int32 - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { return gotenberg.OutboundDecision{Bypass: true}, nil } @@ -236,7 +236,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) { t.Cleanup(upstream.Close) upstreamURL := mustParseURL(t, upstream.URL) - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, 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 } @@ -276,7 +276,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) { } func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) { - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, 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 @@ -316,7 +316,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) { t.Cleanup(stop) var decideCalls atomic.Int32 - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, 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 @@ -386,7 +386,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) { } func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) { - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered) } @@ -445,7 +445,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) { return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered) } - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, 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" { @@ -488,7 +488,7 @@ func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) { // [TestPinningProxy_CONNECT_BlockedByDecide]. func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) { rec := &recordingHandler{} - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { // Mimic the wrap chain produced by outbound.resolveHost when the // DNS lookup is canceled mid-flight by Chromium hanging up. @@ -547,7 +547,7 @@ func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) { // HTTP forward requests aborted by the client must also log at debug. func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) { rec := &recordingHandler{} - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded) } @@ -596,7 +596,7 @@ func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) { // still surface at warn level so operators see real refusals. func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) { rec := &recordingHandler{} - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, false, false) p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) { return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered) } @@ -652,7 +652,7 @@ func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) { // [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn]. func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) { rec := &recordingHandler{} - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, 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 } @@ -713,7 +713,7 @@ func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) { // must still warn so operators see real problems. func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) { rec := &recordingHandler{} - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, 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 } @@ -770,7 +770,7 @@ func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) { // logs at debug, not warn. Genuine RoundTrip failures still warn. func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) { rec := &recordingHandler{} - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, 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 } @@ -868,7 +868,7 @@ func TestIsClientCancellation(t *testing.T) { } func TestPinningProxy_StartTwice(t *testing.T) { - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, false, false) err := p.Start(testLogger()) if err != nil { t.Fatalf("first Start: %v", err) @@ -882,7 +882,7 @@ func TestPinningProxy_StartTwice(t *testing.T) { } func TestPinningProxy_StopIdempotent(t *testing.T) { - p := newPinningProxy(nil, nil, false, false) + p := newPinningProxy(nil, nil, false, 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/chromium/pinning_proxy_upstream_test.go b/pkg/modules/chromium/pinning_proxy_upstream_test.go new file mode 100644 index 00000000..0da6f6e4 --- /dev/null +++ b/pkg/modules/chromium/pinning_proxy_upstream_test.go @@ -0,0 +1,76 @@ +package chromium + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/dlclark/regexp2" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" +) + +// TestPinningProxy_Forward_ThroughUpstreamProxy verifies that when the +// operator opts into proxy-environment honoring, a plain HTTP request is +// forwarded through the upstream (corporate) proxy with the credentials +// Chromium cannot supply. See https://github.com/gotenberg/gotenberg/issues/1592. +func TestPinningProxy_Forward_ThroughUpstreamProxy(t *testing.T) { + var gotAuth atomic.Value + gotAuth.Store("") + + // Stand-in for the corporate proxy: an HTTP server that receives the + // forwarded request and records the injected Proxy-Authorization. + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth.Store(r.Header.Get("Proxy-Authorization")) + _, _ = fmt.Fprint(w, "via-corporate-proxy") + })) + t.Cleanup(upstream.Close) + + upstreamURL := mustParseURL(t, upstream.URL) + upstreamURL.User = url.UserPassword("bob", "pw") + + p := newPinningProxy(nil, nil, false, false, true) + // Force every destination through our stub upstream proxy. + p.upstreamProxy = func(_ *url.URL) (*url.URL, error) { return upstreamURL, nil } + 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 + } + p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) { + t.Fatal("dialPinned must not be called when routing through an upstream proxy") + return nil, nil + } + proxyURL := newProxyForTest(t, p) + + client := &http.Client{ + Transport: &http.Transport{Proxy: http.ProxyURL(mustParseURL(t, proxyURL))}, + Timeout: 5 * time.Second, + } + + resp, err := client.Get("http://example.com/") + if err != nil { + t.Fatalf("GET via proxy: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if string(body) != "via-corporate-proxy" { + t.Fatalf("body = %q, want via-corporate-proxy", body) + } + + wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("bob:pw")) + if got := gotAuth.Load().(string); got != wantAuth { + t.Fatalf("upstream proxy saw Proxy-Authorization %q, want %q", got, wantAuth) + } +} diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index ea73ed1b..93f9fba5 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -336,6 +336,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { fs.StringSlice("libreoffice-deny-list", []string{}, "Set the denied URLs for LibreOffice outbound fetches using regular expressions - supports multiple values") fs.Bool("libreoffice-deny-private-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted documents to mitigate SSRF against internal services") fs.Bool("libreoffice-deny-public-ips", false, "Reject LibreOffice outbound 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("libreoffice-enable-environment-proxy", false, "Route LibreOffice outbound fetches through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials") return fs }(), @@ -363,10 +364,11 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { unoBinPath: unoBinPath, startTimeout: flags.MustDuration("libreoffice-start-timeout"), proxyOptions: outboundProxyOptions{ - allowList: flags.MustRegexpSlice("libreoffice-allow-list"), - denyList: flags.MustRegexpSlice("libreoffice-deny-list"), - denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"), - denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"), + allowList: flags.MustRegexpSlice("libreoffice-allow-list"), + denyList: flags.MustRegexpSlice("libreoffice-deny-list"), + denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"), + denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"), + enableEnvironmentProxy: flags.MustBool("libreoffice-enable-environment-proxy"), }, } diff --git a/pkg/modules/libreoffice/api/proxy.go b/pkg/modules/libreoffice/api/proxy.go index 100f9372..467583f7 100644 --- a/pkg/modules/libreoffice/api/proxy.go +++ b/pkg/modules/libreoffice/api/proxy.go @@ -15,16 +15,18 @@ import ( "time" "github.com/dlclark/regexp2" + "golang.org/x/net/http/httpproxy" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) // outboundProxyOptions configures a [libreOfficeProxy]. type outboundProxyOptions struct { - allowList []*regexp2.Regexp - denyList []*regexp2.Regexp - denyPrivateIPs bool - denyPublicIPs bool + allowList []*regexp2.Regexp + denyList []*regexp2.Regexp + denyPrivateIPs bool + denyPublicIPs bool + enableEnvironmentProxy bool } // libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes @@ -45,6 +47,12 @@ type libreOfficeProxy struct { opts outboundProxyOptions logger *slog.Logger + // upstreamProxy resolves the upstream (corporate) proxy for a destination + // URL from the standard proxy environment variables, or returns a nil URL + // to connect directly. Nil unless the operator opted into proxy- + // environment honoring. See https://github.com/gotenberg/gotenberg/issues/1592. + upstreamProxy func(*url.URL) (*url.URL, error) + stopOnce sync.Once } @@ -64,10 +72,15 @@ func newLibreOfficeProxy(logger *slog.Logger, opts outboundProxyOptions) (*libre p := &libreOfficeProxy{ listener: listener, - client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, decideOpts...), + client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, opts.enableEnvironmentProxy, decideOpts...), opts: opts, logger: logger.With(slog.String("logger", "libreoffice-proxy")), } + if opts.enableEnvironmentProxy { + // Honor the standard proxy environment variables, credentials + // included. httpproxy reads the environment now and applies NO_PROXY. + p.upstreamProxy = httpproxy.FromEnvironment().ProxyFunc() + } p.server = &http.Server{ Handler: p, ReadHeaderTimeout: 10 * time.Second, @@ -182,8 +195,25 @@ func (p *libreOfficeProxy) handleConnect(w http.ResponseWriter, r *http.Request) return } + // When the operator routes egress through an authenticated proxy, soffice + // cannot supply the credentials, so the proxy performs the CONNECT (and + // authentication) upstream. The decision above still gated the destination. + var proxyURL *url.URL + if p.upstreamProxy != nil { + proxyURL, err = p.upstreamProxy(&url.URL{Scheme: "https", Host: net.JoinHostPort(host, port)}) + if err != nil { + p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy resolve upstream proxy for '%s': %s", rawURL, err)) + http.Error(w, "proxy: upstream proxy error", http.StatusBadGateway) + return + } + } + var dest net.Conn switch { + case proxyURL != nil: + dest, err = gotenberg.DialThroughProxy(r.Context(), proxyURL, r.Host, func(ctx context.Context, network, addr string) (net.Conn, error) { + return net.DialTimeout(network, addr, 10*time.Second) + }) case len(decision.Pinned) > 0: dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port) default: diff --git a/pkg/modules/webhook/middleware.go b/pkg/modules/webhook/middleware.go index 0043c5ba..11a9e651 100644 --- a/pkg/modules/webhook/middleware.go +++ b/pkg/modules/webhook/middleware.go @@ -224,7 +224,7 @@ func webhookMiddleware(w *Webhook) api.Middleware { startTime: startTime, client: &retryablehttp.Client{ - HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, ipOpts...), + HTTPClient: gotenberg.NewOutboundHttpClient(w.clientTimeout, w.allowList, w.denyList, w.enableEnvironmentProxy, 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 1e7ebbdb..85fdae81 100644 --- a/pkg/modules/webhook/webhook.go +++ b/pkg/modules/webhook/webhook.go @@ -18,19 +18,20 @@ func init() { // Webhook is a module that provides a middleware for uploading output files // to any destinations in an asynchronous fashion. type Webhook struct { - enableSyncMode bool - allowList []*regexp2.Regexp - denyList []*regexp2.Regexp - errorAllowList []*regexp2.Regexp - errorDenyList []*regexp2.Regexp - denyPrivateIPs bool - denyPublicIPs bool - maxRetry int - retryMinWait time.Duration - retryMaxWait time.Duration - clientTimeout time.Duration - asyncCount atomic.Int64 - disable bool + enableSyncMode bool + allowList []*regexp2.Regexp + denyList []*regexp2.Regexp + errorAllowList []*regexp2.Regexp + errorDenyList []*regexp2.Regexp + denyPrivateIPs bool + denyPublicIPs bool + enableEnvironmentProxy bool + maxRetry int + retryMinWait time.Duration + retryMaxWait time.Duration + clientTimeout time.Duration + asyncCount atomic.Int64 + disable bool } // Descriptor returns an [Webhook]'s module descriptor. @@ -44,6 +45,7 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor { 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.Bool("webhook-enable-environment-proxy", false, "Route webhook callbacks through the proxy defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables, including credentials") fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature") // Deprecated flags. @@ -78,6 +80,7 @@ func (w *Webhook) Provision(ctx *gotenberg.Context) error { 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.enableEnvironmentProxy = flags.MustBool("webhook-enable-environment-proxy") w.maxRetry = flags.MustInt("webhook-max-retry") w.retryMinWait = flags.MustDuration("webhook-retry-min-wait") w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")