mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-14 11:22:15 +01:00
687 lines
26 KiB
Go
687 lines
26 KiB
Go
package gotenberg
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/dlclark/regexp2"
|
|
"golang.org/x/net/http/httpproxy"
|
|
)
|
|
|
|
// ErrNonPublicIP indicates that an outbound URL targets an IP address that
|
|
// is not reachable on the public internet. This covers loopback, RFC1918
|
|
// private, link-local, unspecified, multicast, and IPv6 unique-local
|
|
// (fc00::/7) addresses, as well as their IPv4-mapped IPv6 wrappers (for
|
|
// example [::ffff:127.0.0.1]).
|
|
var ErrNonPublicIP = errors.New("non-public IP")
|
|
|
|
// ErrPublicIP indicates that an outbound URL targets an IP address that is
|
|
// reachable on the public internet. It is returned when a caller opts
|
|
// into denying public destinations via [WithDenyPublicIPs]; typical use
|
|
// cases are air-gapped or data-governed deployments where Gotenberg must
|
|
// only talk to hosts on a private network.
|
|
var ErrPublicIP = errors.New("public IP")
|
|
|
|
// netipResolver is the subset of [net.Resolver] used by [resolveHost].
|
|
// Defining it as an interface allows tests to substitute a stub resolver.
|
|
type netipResolver interface {
|
|
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
|
}
|
|
|
|
// outboundResolver is the resolver used by [resolveHost]. It is a
|
|
// package-level variable so that tests can substitute a stub resolver.
|
|
var outboundResolver netipResolver = net.DefaultResolver
|
|
|
|
// outboundDialer is the underlying dialer used by [secureDialContext]. It is
|
|
// a package-level variable so that tests can replace it.
|
|
var outboundDialer = &net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
}
|
|
|
|
// nonPublicIPv6Prefixes lists IPv6 ranges that the standard library does
|
|
// not classify via [netip.Addr] helpers but that must not be considered
|
|
// public:
|
|
//
|
|
// - 2002::/16 6to4 (RFC 3056, deprecated by RFC 7526). Bits 16-47
|
|
// embed an IPv4 destination, including private ones.
|
|
// - 2001::/32 Teredo (RFC 4380). Bits 96-127 embed an IPv4
|
|
// destination, including private ones.
|
|
// - 64:ff9b::/96 NAT64 well-known prefix (RFC 6052). Low 32 bits
|
|
// embed an IPv4 destination translated by a NAT64 gateway.
|
|
// - 64:ff9b:1::/48 NAT64 local-use prefix (RFC 8215). Same risk.
|
|
// - fec0::/10 Deprecated site-local (RFC 3879). Not covered by
|
|
// [netip.Addr.IsPrivate] which only handles fc00::/7.
|
|
// - ::/96 IPv4-compatible IPv6 (deprecated). Embeds an IPv4
|
|
// destination and is not handled by [netip.Addr.Unmap].
|
|
// - 2001:db8::/32 Documentation range (RFC 3849). Never routable.
|
|
// - 100::/64 Discard prefix (RFC 6666).
|
|
var nonPublicIPv6Prefixes = []netip.Prefix{
|
|
netip.MustParsePrefix("2002::/16"),
|
|
netip.MustParsePrefix("2001::/32"),
|
|
netip.MustParsePrefix("64:ff9b::/96"),
|
|
netip.MustParsePrefix("64:ff9b:1::/48"),
|
|
netip.MustParsePrefix("fec0::/10"),
|
|
netip.MustParsePrefix("::/96"),
|
|
netip.MustParsePrefix("2001:db8::/32"),
|
|
netip.MustParsePrefix("100::/64"),
|
|
}
|
|
|
|
// IsPublicIP reports whether addr is reachable on the public internet. It
|
|
// returns false for loopback, private (RFC1918), link-local, unspecified,
|
|
// multicast, and unique-local addresses. IPv4-mapped IPv6 addresses are
|
|
// unmapped before evaluation so that [::ffff:127.0.0.1] is correctly
|
|
// identified as loopback.
|
|
//
|
|
// IPv6 prefixes that tunnel or translate to an embedded IPv4 destination
|
|
// (6to4, Teredo, NAT64) are rejected wholesale rather than recursed into,
|
|
// because a host that routes them implicitly trusts the IPv4 mapping and
|
|
// the prefixes themselves are deprecated or translation-only. See
|
|
// [nonPublicIPv6Prefixes] for the full list and rationale.
|
|
func IsPublicIP(addr netip.Addr) bool {
|
|
if !addr.IsValid() {
|
|
return false
|
|
}
|
|
addr = addr.Unmap()
|
|
switch {
|
|
case addr.IsLoopback(),
|
|
addr.IsPrivate(),
|
|
addr.IsLinkLocalUnicast(),
|
|
addr.IsLinkLocalMulticast(),
|
|
addr.IsMulticast(),
|
|
addr.IsUnspecified(),
|
|
addr.IsInterfaceLocalMulticast():
|
|
return false
|
|
}
|
|
if addr.Is6() {
|
|
for _, p := range nonPublicIPv6Prefixes {
|
|
if p.Contains(addr) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ResolveAndCheckPublic resolves host and rejects any resolved address
|
|
// that fails [IsPublicIP] with [ErrNonPublicIP]. It is the strict
|
|
// equivalent of [DecideOutbound] with [WithDenyPrivateIPs] true for a
|
|
// bare host. Callers that need a different policy should use
|
|
// [DecideOutbound] directly.
|
|
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
|
|
return resolveHost(ctx, host, true, false)
|
|
}
|
|
|
|
// resolveHost resolves host and returns the addresses. When denyPrivate
|
|
// is true, a non-public address is rejected with [ErrNonPublicIP]. When
|
|
// denyPublic is true, a public address is rejected with [ErrPublicIP].
|
|
// Both checks may be active at the same time, in which case any
|
|
// resolved address fails and the caller must rely on an allow-list
|
|
// bypass.
|
|
func resolveHost(ctx context.Context, host string, denyPrivate, denyPublic bool) ([]netip.Addr, error) {
|
|
if host == "" {
|
|
return nil, errors.New("empty host")
|
|
}
|
|
|
|
check := func(a netip.Addr) error {
|
|
public := IsPublicIP(a)
|
|
if denyPublic && public {
|
|
return fmt.Errorf("%q: %w", a, ErrPublicIP)
|
|
}
|
|
if denyPrivate && !public {
|
|
return fmt.Errorf("%q: %w", a, ErrNonPublicIP)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if addr, err := netip.ParseAddr(host); err == nil {
|
|
if err := check(addr); err != nil {
|
|
return nil, err
|
|
}
|
|
return []netip.Addr{addr}, nil
|
|
}
|
|
addrs, err := outboundResolver.LookupNetIP(ctx, "ip", host)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve %q: %w", host, err)
|
|
}
|
|
if len(addrs) == 0 {
|
|
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
|
|
}
|
|
for _, a := range addrs {
|
|
if err := check(a); err != nil {
|
|
return nil, fmt.Errorf("%q resolves to rejected address %w", host, err)
|
|
}
|
|
}
|
|
return addrs, nil
|
|
}
|
|
|
|
// OutboundDecision is the result of validating an outbound URL via
|
|
// [DecideOutbound]. Callers use it to dial the destination either directly
|
|
// (operator-approved allow-list match, Bypass true) or via [DialPinned] so
|
|
// that the connect targets the IPs resolved at validation time. Passing
|
|
// the decision to the dialer closes the window between validation and
|
|
// connect that DNS rebinding exploits.
|
|
type OutboundDecision struct {
|
|
// Bypass is true when an allow-list pattern matched the URL. The
|
|
// operator has explicitly opted into the destination; the caller
|
|
// should dial directly without an additional IP check.
|
|
Bypass bool
|
|
|
|
// Pinned holds the IPs resolved for the URL host. The caller should
|
|
// dial one of these via [DialPinned] to prevent DNS rebinding between
|
|
// validation and connect.
|
|
Pinned []netip.Addr
|
|
}
|
|
|
|
// outboundDecisionKey is the context key under which an [OutboundDecision]
|
|
// is stored.
|
|
type outboundDecisionKey struct{}
|
|
|
|
// outboundProxiedKey is the context key under which [outboundRoundTripper]
|
|
// records that the environment proxy will carry this request, so that the
|
|
// dialer knows the address it receives is the proxy's rather than the
|
|
// destination's.
|
|
type outboundProxiedKey struct{}
|
|
|
|
// decideConfig carries optional settings for [DecideOutbound] and
|
|
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
|
type decideConfig struct {
|
|
denyPrivateIPs bool
|
|
denyPublicIPs bool
|
|
}
|
|
|
|
// DecideOption customizes how [DecideOutbound] and [FilterOutboundURL]
|
|
// validate a URL. Options are applied in order on top of the permissive
|
|
// defaults (no IP-class rejection).
|
|
type DecideOption func(*decideConfig)
|
|
|
|
// WithDenyPrivateIPs rejects URLs whose host resolves to a non-public IP
|
|
// address (loopback, RFC1918, link-local, unique-local, multicast,
|
|
// unspecified). DNS still runs and the returned [OutboundDecision] still
|
|
// carries the resolved IPs for dial pinning, so enabling or disabling
|
|
// this option does not affect DNS-rebinding protection. Use it on
|
|
// internet-exposed deployments to mitigate SSRF against internal
|
|
// services.
|
|
func WithDenyPrivateIPs(deny bool) DecideOption {
|
|
return func(c *decideConfig) { c.denyPrivateIPs = deny }
|
|
}
|
|
|
|
// WithDenyPublicIPs rejects URLs whose host resolves to a public IP
|
|
// address. Use it on air-gapped or data-governed deployments where
|
|
// Gotenberg must only reach hosts on a private network; the option
|
|
// prevents data exfiltration to attacker-controlled public servers via
|
|
// webhook callbacks, downloadFrom URLs, or user-supplied stamp sources.
|
|
// May be combined with [WithDenyPrivateIPs]; in that case every resolved
|
|
// address fails and only an allow-list bypass permits a destination.
|
|
func WithDenyPublicIPs(deny bool) DecideOption {
|
|
return func(c *decideConfig) { c.denyPublicIPs = deny }
|
|
}
|
|
|
|
// httpLikeScheme reports whether scheme is one of http, https, ws, or wss.
|
|
// Only these schemes go through the IP-based address check; data, blob,
|
|
// file, and other schemes are filtered by the regex layer alone.
|
|
func httpLikeScheme(scheme string) bool {
|
|
switch scheme {
|
|
case "http", "https", "ws", "wss":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DecideOutbound parses rawURL, runs the regex allow/deny lists against
|
|
// the normalized form, and (when no allow-list match) resolves the host
|
|
// and applies the IP-class checks selected by opts. It returns the
|
|
// resulting [OutboundDecision] so the caller can pin the dial to the IPs
|
|
// that were resolved here and skip a second DNS lookup later, which
|
|
// closes the DNS rebinding window that affects callers that only receive
|
|
// an error from [FilterOutboundURL].
|
|
//
|
|
// The semantics:
|
|
//
|
|
// 1. The URL is parsed and its scheme and host lowercased.
|
|
// 2. allowList and denyList apply against the normalized form with OR
|
|
// semantics. The deny-list always applies.
|
|
// 3. For http, https, ws, and wss, the host is resolved and every
|
|
// resolved address must satisfy the enabled IP-class checks
|
|
// ([WithDenyPrivateIPs], [WithDenyPublicIPs]). An allow-list match
|
|
// bypasses the IP-class checks and the returned decision carries
|
|
// Bypass true. Otherwise the decision carries Pinned with the
|
|
// resolved addresses.
|
|
//
|
|
// Callers that dial the destination themselves must honor Bypass and
|
|
// Pinned: bypassed URLs dial the hostname directly (operator opt-in);
|
|
// pinned URLs must dial one of Pinned via [DialPinned].
|
|
func DecideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) (OutboundDecision, error) {
|
|
cfg := decideConfig{}
|
|
for _, opt := range opts {
|
|
opt(&cfg)
|
|
}
|
|
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return OutboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
|
|
}
|
|
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
|
parsed.Host = strings.ToLower(parsed.Host)
|
|
normalized := parsed.String()
|
|
|
|
allowMatched := false
|
|
if len(allowList) > 0 {
|
|
for _, pattern := range allowList {
|
|
clone := regexp2.MustCompile(pattern.String(), 0)
|
|
clone.MatchTimeout = time.Until(deadline)
|
|
|
|
ok, err := clone.MatchString(normalized)
|
|
if err != nil {
|
|
if time.Now().After(deadline) {
|
|
return OutboundDecision{}, context.DeadlineExceeded
|
|
}
|
|
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
|
}
|
|
|
|
if ok {
|
|
allowMatched = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !allowMatched {
|
|
return OutboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
|
|
}
|
|
}
|
|
|
|
for _, pattern := range denyList {
|
|
clone := regexp2.MustCompile(pattern.String(), 0)
|
|
clone.MatchTimeout = time.Until(deadline)
|
|
|
|
ok, err := clone.MatchString(normalized)
|
|
if err != nil {
|
|
if time.Now().After(deadline) {
|
|
return OutboundDecision{}, context.DeadlineExceeded
|
|
}
|
|
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
|
}
|
|
|
|
if ok {
|
|
return OutboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
|
|
}
|
|
}
|
|
|
|
if allowMatched {
|
|
return OutboundDecision{Bypass: true}, nil
|
|
}
|
|
|
|
if !httpLikeScheme(parsed.Scheme) {
|
|
return OutboundDecision{}, nil
|
|
}
|
|
|
|
host := parsed.Hostname()
|
|
if host == "" {
|
|
return OutboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
|
|
}
|
|
|
|
addrs, err := resolveHost(ctx, host, cfg.denyPrivateIPs, cfg.denyPublicIPs)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, ErrNonPublicIP):
|
|
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
|
case errors.Is(err, ErrPublicIP):
|
|
return OutboundDecision{}, fmt.Errorf("'%s' targets a public address: %w", normalized, ErrFiltered)
|
|
case errors.Is(err, context.Canceled), errors.Is(err, context.DeadlineExceeded):
|
|
// A cancellation or timeout is not a policy decision; surface it
|
|
// as-is so callers do not report it as a filtered request.
|
|
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
|
default:
|
|
// The host could not be resolved, so its address class cannot be
|
|
// verified. Fail closed and treat it as filtered, the same as a
|
|
// host that resolves to a blocked address, so clients get a
|
|
// generic 403 rather than a 500. This also denies alternate IP
|
|
// encodings such as http://2130706433/ that the resolver rejects
|
|
// as a hostname but Chromium would read as a private IP.
|
|
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %v: %w", normalized, err, ErrFiltered)
|
|
}
|
|
}
|
|
|
|
return OutboundDecision{Pinned: addrs}, nil
|
|
}
|
|
|
|
// FilterOutboundURL validates that rawURL is acceptable for an outbound
|
|
// request from Gotenberg. It is the URL-aware replacement for
|
|
// [FilterDeadline] and should be preferred for any new code that filters
|
|
// a URL before issuing or instructing an outbound request.
|
|
//
|
|
// The default behavior is permissive: the URL passes as long as it clears
|
|
// the regex allow-list and deny-list. Callers that need IP-class checks
|
|
// opt in via [WithDenyPrivateIPs] or [WithDenyPublicIPs]. The deny-list
|
|
// always applies and cannot be bypassed by an allow-list match.
|
|
func FilterOutboundURL(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) error {
|
|
_, err := DecideOutbound(ctx, rawURL, allowList, denyList, deadline, opts...)
|
|
return err
|
|
}
|
|
|
|
// outboundRoundTripper is an [http.RoundTripper] that validates each
|
|
// request URL via [DecideOutbound] and stashes the resulting
|
|
// [OutboundDecision] in the request context so that [secureDialContext]
|
|
// can pin the dial or bypass the IP check as appropriate. Because the
|
|
// http.Client invokes RoundTrip again for each redirect hop, this also
|
|
// re-validates redirect targets without a separate CheckRedirect.
|
|
type outboundRoundTripper struct {
|
|
base http.RoundTripper
|
|
allowList []*regexp2.Regexp
|
|
denyList []*regexp2.Regexp
|
|
opts []DecideOption
|
|
|
|
// proxyFunc mirrors the transport's own proxy resolution. It is nil unless
|
|
// the environment proxy is enabled.
|
|
proxyFunc func(*url.URL) (*url.URL, error)
|
|
}
|
|
|
|
// RoundTrip validates req.URL and delegates to the base transport.
|
|
func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
deadline, ok := req.Context().Deadline()
|
|
if !ok {
|
|
deadline = time.Now().Add(30 * time.Second)
|
|
}
|
|
|
|
decision, err := DecideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline, rt.opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
|
|
|
|
// A request the proxy will not carry is dialed directly, so it still gets
|
|
// pinned. Without this, enabling the environment proxy would silently drop
|
|
// DNS-rebinding protection for every NO_PROXY host, and for all traffic
|
|
// when no proxy variable is set at all.
|
|
if rt.proxyFunc != nil {
|
|
proxyURL, proxyErr := rt.proxyFunc(req.URL)
|
|
if proxyErr == nil && proxyURL != nil {
|
|
ctx = context.WithValue(ctx, outboundProxiedKey{}, true)
|
|
}
|
|
}
|
|
|
|
return rt.base.RoundTrip(req.WithContext(ctx))
|
|
}
|
|
|
|
// NewOutboundHttpClient returns an [http.Client] that validates every
|
|
// outbound request URL via the same logic as [FilterOutboundURL] and
|
|
// pins the resulting dial to the resolved IPs.
|
|
//
|
|
// The client re-validates redirect targets automatically because the
|
|
// underlying [http.Client] invokes the wrapping [http.RoundTripper] once
|
|
// per hop. This closes the redirect-based SSRF bypass that affects raw
|
|
// [http.Client] usage when no CheckRedirect is set.
|
|
//
|
|
// The default posture is permissive; callers pass [WithDenyPrivateIPs]
|
|
// or [WithDenyPublicIPs] to opt into IP-class rejection.
|
|
//
|
|
// 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. Dial pinning does not apply
|
|
// to a hop the proxy carries, since the proxy owns DNS and egress there; a hop
|
|
// the proxy declines, such as a NO_PROXY host, is dialed directly and stays
|
|
// pinned. The URL allow/deny and IP-class validation runs either way. 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()
|
|
|
|
var proxyFunc func(*url.URL) (*url.URL, error)
|
|
|
|
if enableEnvironmentProxy {
|
|
// Route through the operator's proxy (standard env vars, credentials
|
|
// included). 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)
|
|
}
|
|
// Only a hop the proxy actually carries skips pinning: there the dial
|
|
// targets the proxy, not the destination, and the proxy owns DNS. A hop
|
|
// the proxy declines is dialed directly and stays pinned.
|
|
base.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
if proxied, _ := ctx.Value(outboundProxiedKey{}).(bool); proxied {
|
|
return outboundDialer.DialContext(ctx, network, addr)
|
|
}
|
|
return secureDialContext(ctx, network, addr)
|
|
}
|
|
} 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{
|
|
base: base,
|
|
allowList: allowList,
|
|
denyList: denyList,
|
|
opts: opts,
|
|
proxyFunc: proxyFunc,
|
|
},
|
|
}
|
|
}
|
|
|
|
// environmentProxyVariables are the variables golang.org/x/net/http/httpproxy
|
|
// reads, in the casing precedence it applies.
|
|
var environmentProxyVariables = []string{
|
|
"HTTP_PROXY", "http_proxy",
|
|
"HTTPS_PROXY", "https_proxy",
|
|
"ALL_PROXY", "all_proxy",
|
|
}
|
|
|
|
// ValidateEnvironmentProxyVariables checks that every proxy variable currently
|
|
// set can be parsed as a proxy URL.
|
|
//
|
|
// httpproxy discards a parse error and falls back to a direct connection, so an
|
|
// operator who mistypes a proxy URL would silently lose the egress path they
|
|
// meant to enforce. Modules exposing an environment proxy flag call this from
|
|
// their Validate so that startup fails loudly instead.
|
|
//
|
|
// Values are never included in the error: a proxy URL may carry credentials.
|
|
func ValidateEnvironmentProxyVariables() error {
|
|
var err error
|
|
|
|
for _, name := range environmentProxyVariables {
|
|
if os.Getenv(name) == "" {
|
|
continue
|
|
}
|
|
|
|
if !isUsableProxyURL(os.Getenv(name)) {
|
|
err = errors.Join(err, fmt.Errorf("environment variable %s is not a usable proxy URL; unset it, or set it to a value like 'http://user:password@host:3128'", name))
|
|
}
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// isUsableProxyURL mirrors httpproxy's own parsing: a URL with a proxy scheme,
|
|
// or anything that becomes one once a scheme is prefixed.
|
|
func isUsableProxyURL(value string) bool {
|
|
proxyURL, err := url.Parse(value)
|
|
if err == nil {
|
|
switch proxyURL.Scheme {
|
|
case "http", "https", "socks5", "socks5h":
|
|
return true
|
|
}
|
|
}
|
|
|
|
// httpproxy retries bare values such as "host:3128" with a scheme.
|
|
_, err = url.Parse("http://" + value)
|
|
return err == nil
|
|
}
|
|
|
|
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
|
|
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
|
// match), it dials directly. When the decision contains pinned IPs, it
|
|
// dials each in turn until one connects. When no decision is present
|
|
// (the dialer was used outside of [outboundRoundTripper]), it falls back
|
|
// to resolving the destination without IP-class checks so that the
|
|
// fallback matches the permissive default and operators who need
|
|
// restrictions configure them at the caller.
|
|
func secureDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("split host:port %q: %w", addr, err)
|
|
}
|
|
|
|
if decision, ok := ctx.Value(outboundDecisionKey{}).(OutboundDecision); ok {
|
|
if decision.Bypass {
|
|
return outboundDialer.DialContext(ctx, network, addr)
|
|
}
|
|
if len(decision.Pinned) > 0 {
|
|
return DialPinned(ctx, network, decision.Pinned, port)
|
|
}
|
|
}
|
|
|
|
addrs, err := resolveHost(ctx, host, false, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return DialPinned(ctx, network, addrs, port)
|
|
}
|
|
|
|
// DialPinned dials each addr in turn until one connects, returning the
|
|
// first successful connection or the last error. Callers pass the Pinned
|
|
// slice from [OutboundDecision] so that the dial targets exactly the IPs
|
|
// that [DecideOutbound] resolved, preventing DNS rebinding between
|
|
// validation and connect.
|
|
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) {
|
|
var lastErr error
|
|
for _, a := range addrs {
|
|
conn, err := outboundDialer.DialContext(ctx, network, net.JoinHostPort(a.String(), port))
|
|
if err == nil {
|
|
return conn, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
if lastErr == nil {
|
|
return nil, errors.New("no addresses to dial")
|
|
}
|
|
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)
|
|
}
|
|
|
|
// CloseWrite half-closes the underlying connection. Embedding [net.Conn] hides
|
|
// the method, so a CONNECT splice over this connection could never signal EOF
|
|
// to the upstream and both sides waited for the other until a timeout.
|
|
func (c *bufferedConn) CloseWrite() error {
|
|
cw, ok := c.Conn.(interface{ CloseWrite() error })
|
|
if !ok {
|
|
return fmt.Errorf("underlying %T does not support half-close", c.Conn)
|
|
}
|
|
return cw.CloseWrite()
|
|
}
|