feat(outbound): support authenticated proxy from environment variables

This commit is contained in:
Julien Neuhart
2026-07-15 20:08:29 +02:00
parent d0e3991d16
commit a92a7fedba
15 changed files with 549 additions and 63 deletions

View File

@@ -18,6 +18,8 @@ GOTENBERG_BUILD_DEBUG_DATA=true
API_PORT=3000 API_PORT=3000
API_PORT_FROM_ENV= API_PORT_FROM_ENV=
API_BIND_IP= API_BIND_IP=
API_TLS_CERT_FILE=
API_TLS_KEY_FILE=
API_START_TIMEOUT=30s API_START_TIMEOUT=30s
API_TIMEOUT=30s API_TIMEOUT=30s
API_BODY_LIMIT= API_BODY_LIMIT=
@@ -28,6 +30,9 @@ GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD= GOTENBERG_API_BASIC_AUTH_PASSWORD=
API_DOWNLOAD_FROM_ALLOW_LIST= 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_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_DOWNLOAD_FROM_MAX_RETRY=4
API_DISABLE_DOWNLOAD_FROM=false API_DISABLE_DOWNLOAD_FROM=false
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
@@ -47,8 +52,11 @@ CHROMIUM_DISABLE_WEB_SECURITY=false
CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES=false
CHROMIUM_HOST_RESOLVER_RULES= CHROMIUM_HOST_RESOLVER_RULES=
CHROMIUM_PROXY_SERVER= CHROMIUM_PROXY_SERVER=
CHROMIUM_ENABLE_ENVIRONMENT_PROXY=false
CHROMIUM_ALLOW_LIST= CHROMIUM_ALLOW_LIST=
CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).* CHROMIUM_DENY_LIST=^file:(?!//\/tmp/).*
CHROMIUM_DENY_PRIVATE_IPS=false
CHROMIUM_DENY_PUBLIC_IPS=false
CHROMIUM_CLEAR_CACHE=false CHROMIUM_CLEAR_CACHE=false
CHROMIUM_CLEAR_COOKIES=false CHROMIUM_CLEAR_COOKIES=false
CHROMIUM_DISABLE_JAVASCRIPT=false CHROMIUM_DISABLE_JAVASCRIPT=false
@@ -60,6 +68,9 @@ LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_ALLOW_LIST= LIBREOFFICE_ALLOW_LIST=
LIBREOFFICE_DENY_LIST= LIBREOFFICE_DENY_LIST=
LIBREOFFICE_DENY_PRIVATE_IPS=false
LIBREOFFICE_DENY_PUBLIC_IPS=false
LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY=false
LIBREOFFICE_DISABLE_ROUTES=false LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info LOG_LEVEL=info
LOG_FIELDS_PREFIX= LOG_FIELDS_PREFIX=
@@ -97,6 +108,9 @@ OTEL_EXPORTER_OTLP_INSECURE=true
WEBHOOK_ENABLE_SYNC_MODE=false WEBHOOK_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_LIST= 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_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_MAX_RETRY=4
WEBHOOK_RETRY_MIN_WAIT=1s WEBHOOK_RETRY_MIN_WAIT=1s
WEBHOOK_RETRY_MAX_WAIT=30s WEBHOOK_RETRY_MAX_WAIT=30s

View File

@@ -21,6 +21,8 @@ services:
- "--api-port=${API_PORT}" - "--api-port=${API_PORT}"
- "--api-port-from-env=${API_PORT_FROM_ENV}" - "--api-port-from-env=${API_PORT_FROM_ENV}"
- "--api-bind-ip=${API_BIND_IP}" - "--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-start-timeout=${API_START_TIMEOUT}"
- "--api-timeout=${API_TIMEOUT}" - "--api-timeout=${API_TIMEOUT}"
- "--api-body-limit=${API_BODY_LIMIT}" - "--api-body-limit=${API_BODY_LIMIT}"
@@ -29,6 +31,9 @@ services:
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}" - "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}" - "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_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-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}" - "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}" - "--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-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}"
- "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}" - "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}"
- "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}" - "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}"
- "--chromium-enable-environment-proxy=${CHROMIUM_ENABLE_ENVIRONMENT_PROXY}"
- "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}" - "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}"
- "--chromium-deny-list=${CHROMIUM_DENY_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-cache=${CHROMIUM_CLEAR_CACHE}"
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}" - "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}" - "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
@@ -61,6 +69,9 @@ services:
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}" - "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
- "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}" - "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}"
- "--libreoffice-deny-list=${LIBREOFFICE_DENY_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}" - "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
- "--log-level=${LOG_LEVEL}" - "--log-level=${LOG_LEVEL}"
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}" - "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
@@ -91,6 +102,9 @@ services:
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}" - "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}" - "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
- "--webhook-deny-list=${WEBHOOK_DENY_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-max-retry=${WEBHOOK_MAX_RETRY}"
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}" - "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}" - "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"

View File

@@ -1,7 +1,10 @@
package gotenberg package gotenberg
import ( import (
"bufio"
"context" "context"
"crypto/tls"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@@ -12,6 +15,7 @@ import (
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"golang.org/x/net/http/httpproxy"
) )
// ErrNonPublicIP indicates that an outbound URL targets an IP address that // 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] // The default posture is permissive; callers pass [WithDenyPrivateIPs]
// or [WithDenyPublicIPs] to opt into IP-class rejection. // 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 := http.DefaultTransport.(*http.Transport).Clone()
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 base.DialContext = secureDialContext
}
return &http.Client{ return &http.Client{
Timeout: timeout, Timeout: timeout,
Transport: &outboundRoundTripper{ Transport: &outboundRoundTripper{
@@ -452,3 +484,99 @@ func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port st
} }
return nil, lastErr 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)
}

View File

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

View File

@@ -61,6 +61,7 @@ type downloadFromConfig struct {
denyList []*regexp2.Regexp denyList []*regexp2.Regexp
denyPrivateIPs bool denyPrivateIPs bool
denyPublicIPs bool denyPublicIPs bool
enableEnvironmentProxy bool
maxRetry int maxRetry int
disable bool disable bool
} }
@@ -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.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-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-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.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-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route") fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
@@ -243,6 +245,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
denyList: flags.MustRegexpSlice("api-download-from-deny-list"), denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"), denyPrivateIPs: flags.MustBool("api-download-from-deny-private-ips"),
denyPublicIPs: flags.MustBool("api-download-from-deny-public-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"), maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"), disable: flags.MustBool("api-disable-download-from"),
} }

View File

@@ -281,7 +281,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
} }
client := &retryablehttp.Client{ 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, RetryMax: downloadFromCfg.maxRetry,
RetryWaitMin: time.Duration(1) * time.Second, RetryWaitMin: time.Duration(1) * time.Second,
RetryWaitMax: time.Until(deadline), RetryWaitMax: time.Until(deadline),

View File

@@ -38,6 +38,7 @@ type browserArguments struct {
allowFileAccessFromFiles bool allowFileAccessFromFiles bool
hostResolverRules string hostResolverRules string
proxyServer string proxyServer string
enableEnvironmentProxy bool
wsUrlReadTimeout time.Duration wsUrlReadTimeout time.Duration
hyphenDataDirPath string hyphenDataDirPath string
@@ -77,7 +78,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
initialCtx: context.Background(), initialCtx: context.Background(),
arguments: arguments, arguments: arguments,
fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), 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) b.isStarted.Store(false)

View File

@@ -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.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-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.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-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.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") 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"), allowFileAccessFromFiles: flags.MustBool("chromium-allow-file-access-from-files"),
hostResolverRules: flags.MustString("chromium-host-resolver-rules"), hostResolverRules: flags.MustString("chromium-host-resolver-rules"),
proxyServer: flags.MustString("chromium-proxy-server"), proxyServer: flags.MustString("chromium-proxy-server"),
enableEnvironmentProxy: flags.MustBool("chromium-enable-environment-proxy"),
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"), wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
hyphenDataDirPath: hyphenDataDirPath, hyphenDataDirPath: hyphenDataDirPath,

View File

@@ -9,10 +9,12 @@ import (
"net" "net"
"net/http" "net/http"
"net/netip" "net/netip"
"net/url"
"sync" "sync"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"golang.org/x/net/http/httpproxy"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -43,6 +45,14 @@ type pinningProxy struct {
// allow-list opt-in). Tests may override it. // allow-list opt-in). Tests may override it.
dialBypass func(ctx context.Context, network, addr string) (net.Conn, error) 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 listener net.Listener
server *http.Server server *http.Server
wg sync.WaitGroup wg sync.WaitGroup
@@ -57,8 +67,8 @@ type pinningProxy struct {
// [gotenberg.DecideOutbound] on every request the proxy sees, so // [gotenberg.DecideOutbound] on every request the proxy sees, so
// Chromium inherits whatever posture the operator selected. The // Chromium inherits whatever posture the operator selected. The
// returned proxy is not yet listening; call Start. // returned proxy is not yet listening; call Start.
func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs bool) *pinningProxy { func newPinningProxy(allowList, denyList []*regexp2.Regexp, denyPrivateIPs, denyPublicIPs, enableEnvironmentProxy bool) *pinningProxy {
return &pinningProxy{ p := &pinningProxy{
allowList: allowList, allowList: allowList,
denyList: denyList, denyList: denyList,
decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) { 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) 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 // 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 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 var upstream net.Conn
switch { switch {
case proxyURL != nil:
upstream, err = p.dialThroughUpstreamProxy(req.Context(), proxyURL, req.Host)
case decision.Bypass: case decision.Bypass:
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host) upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
case len(decision.Pinned) > 0: case len(decision.Pinned) > 0:
@@ -275,17 +309,35 @@ func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
return 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 := req.Clone(req.Context())
outReq.RequestURI = "" outReq.RequestURI = ""
stripHopByHopHeaders(outReq.Header) 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{ 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, 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) _, port, splitErr := net.SplitHostPort(addr)
if splitErr != nil { if splitErr != nil {
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr) 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: default:
return nil, errors.New("no pinned addresses and not bypassed") return nil, errors.New("no pinned addresses and not bypassed")
} }
}, }
} }
defer transport.CloseIdleConnections() defer transport.CloseIdleConnections()
@@ -364,3 +416,11 @@ func isClientCancellation(ctx context.Context, err error) bool {
} }
return ctx.Err() != nil 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)
}

View File

@@ -112,7 +112,7 @@ func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
upstreamURL := mustParseURL(t, upstream.URL) upstreamURL := mustParseURL(t, upstream.URL)
var decideCalls atomic.Int32 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) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
decideCalls.Add(1) decideCalls.Add(1)
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil 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) { 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) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered) 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) upstreamURL := mustParseURL(t, upstream.URL)
var bypassCalls atomic.Int32 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) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{Bypass: true}, nil return gotenberg.OutboundDecision{Bypass: true}, nil
} }
@@ -236,7 +236,7 @@ func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
t.Cleanup(upstream.Close) t.Cleanup(upstream.Close)
upstreamURL := mustParseURL(t, upstream.URL) 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) { 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 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) { 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) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
t.Fatal("decide must not be called for malformed proxy request") t.Fatal("decide must not be called for malformed proxy request")
return gotenberg.OutboundDecision{}, nil return gotenberg.OutboundDecision{}, nil
@@ -316,7 +316,7 @@ func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
t.Cleanup(stop) t.Cleanup(stop)
var decideCalls atomic.Int32 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) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
decideCalls.Add(1) decideCalls.Add(1)
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("127.0.0.1")}}, nil 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) { 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) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered) 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) 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.decide = stubDecide
p.dialPinned = func(_ context.Context, network string, addrs []netip.Addr, _ string) (net.Conn, error) { 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" { 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]. // [TestPinningProxy_CONNECT_BlockedByDecide].
func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) { func TestPinningProxy_CONNECT_ClientCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{} 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) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
// Mimic the wrap chain produced by outbound.resolveHost when the // Mimic the wrap chain produced by outbound.resolveHost when the
// DNS lookup is canceled mid-flight by Chromium hanging up. // 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. // HTTP forward requests aborted by the client must also log at debug.
func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) { func TestPinningProxy_Forward_ClientCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{} 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) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("validate host: %w", context.DeadlineExceeded) 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. // still surface at warn level so operators see real refusals.
func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) { func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
rec := &recordingHandler{} 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) { p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered) return gotenberg.OutboundDecision{}, fmt.Errorf("denied: %w", gotenberg.ErrFiltered)
} }
@@ -652,7 +652,7 @@ func TestPinningProxy_PolicyDenial_LoggedAtWarn(t *testing.T) {
// [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn]. // [TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn].
func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) { func TestPinningProxy_CONNECT_DialCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{} 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) { 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 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. // must still warn so operators see real problems.
func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) { func TestPinningProxy_CONNECT_DialFailure_LoggedAtWarn(t *testing.T) {
rec := &recordingHandler{} 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) { 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 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. // logs at debug, not warn. Genuine RoundTrip failures still warn.
func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) { func TestPinningProxy_Forward_RoundTripCancellation_LoggedAtDebug(t *testing.T) {
rec := &recordingHandler{} 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) { 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 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) { func TestPinningProxy_StartTwice(t *testing.T) {
p := newPinningProxy(nil, nil, false, false) p := newPinningProxy(nil, nil, false, false, false)
err := p.Start(testLogger()) err := p.Start(testLogger())
if err != nil { if err != nil {
t.Fatalf("first Start: %v", err) t.Fatalf("first Start: %v", err)
@@ -882,7 +882,7 @@ func TestPinningProxy_StartTwice(t *testing.T) {
} }
func TestPinningProxy_StopIdempotent(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. // Stop on a never-started proxy is a no-op.
if err := p.Stop(testLogger()); err != nil { if err := p.Stop(testLogger()); err != nil {
t.Fatalf("Stop on never-started proxy: %v", err) t.Fatalf("Stop on never-started proxy: %v", err)

View File

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

View File

@@ -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.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-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-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 return fs
}(), }(),
@@ -367,6 +368,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
denyList: flags.MustRegexpSlice("libreoffice-deny-list"), denyList: flags.MustRegexpSlice("libreoffice-deny-list"),
denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"), denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"),
denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"), denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"),
enableEnvironmentProxy: flags.MustBool("libreoffice-enable-environment-proxy"),
}, },
} }

View File

@@ -15,6 +15,7 @@ import (
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"golang.org/x/net/http/httpproxy"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -25,6 +26,7 @@ type outboundProxyOptions struct {
denyList []*regexp2.Regexp denyList []*regexp2.Regexp
denyPrivateIPs bool denyPrivateIPs bool
denyPublicIPs bool denyPublicIPs bool
enableEnvironmentProxy bool
} }
// libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes // libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes
@@ -45,6 +47,12 @@ type libreOfficeProxy struct {
opts outboundProxyOptions opts outboundProxyOptions
logger *slog.Logger 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 stopOnce sync.Once
} }
@@ -64,10 +72,15 @@ func newLibreOfficeProxy(logger *slog.Logger, opts outboundProxyOptions) (*libre
p := &libreOfficeProxy{ p := &libreOfficeProxy{
listener: listener, listener: listener,
client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, decideOpts...), client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, opts.enableEnvironmentProxy, decideOpts...),
opts: opts, opts: opts,
logger: logger.With(slog.String("logger", "libreoffice-proxy")), 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{ p.server = &http.Server{
Handler: p, Handler: p,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
@@ -182,8 +195,25 @@ func (p *libreOfficeProxy) handleConnect(w http.ResponseWriter, r *http.Request)
return 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 var dest net.Conn
switch { 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: case len(decision.Pinned) > 0:
dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port) dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port)
default: default:

View File

@@ -224,7 +224,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
startTime: startTime, startTime: startTime,
client: &retryablehttp.Client{ 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, RetryMax: w.maxRetry,
RetryWaitMin: w.retryMinWait, RetryWaitMin: w.retryMinWait,
RetryWaitMax: w.retryMaxWait, RetryWaitMax: w.retryMaxWait,

View File

@@ -25,6 +25,7 @@ type Webhook struct {
errorDenyList []*regexp2.Regexp errorDenyList []*regexp2.Regexp
denyPrivateIPs bool denyPrivateIPs bool
denyPublicIPs bool denyPublicIPs bool
enableEnvironmentProxy bool
maxRetry int maxRetry int
retryMinWait time.Duration retryMinWait time.Duration
retryMaxWait time.Duration retryMaxWait time.Duration
@@ -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.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-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-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") fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
// Deprecated flags. // 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.errorDenyList = flags.MustDeprecatedRegexpSlice("webhook-error-deny-list", "webhook-deny-list")
w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips") w.denyPrivateIPs = flags.MustBool("webhook-deny-private-ips")
w.denyPublicIPs = flags.MustBool("webhook-deny-public-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.maxRetry = flags.MustInt("webhook-max-retry")
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait") w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait") w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")