mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
feat(outbound): support authenticated proxy from environment variables
This commit is contained in:
14
Makefile
14
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
|
||||
|
||||
14
compose.yaml
14
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}"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
153
pkg/gotenberg/outbound_proxy_test.go
Normal file
153
pkg/gotenberg/outbound_proxy_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
76
pkg/modules/chromium/pinning_proxy_upstream_test.go
Normal file
76
pkg/modules/chromium/pinning_proxy_upstream_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user