mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
fix(chromium): harden outbound URL handling
This commit is contained in:
@@ -68,11 +68,20 @@ func IsPublicIP(addr netip.Addr) bool {
|
|||||||
// The returned slice can be used to pin a subsequent dial to a specific IP
|
// The returned slice can be used to pin a subsequent dial to a specific IP
|
||||||
// and prevent DNS rebinding between this validation and the connect.
|
// and prevent DNS rebinding between this validation and the connect.
|
||||||
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
|
func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, error) {
|
||||||
|
return resolveHost(ctx, host, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveHost resolves host and returns the addresses. When checkPublic is
|
||||||
|
// true, each resolved address must pass [IsPublicIP] or the call returns
|
||||||
|
// [ErrNonPublicIP]. When false, non-public addresses are accepted and
|
||||||
|
// returned; the caller must pin the dial to them to retain rebind
|
||||||
|
// protection even when the public-IP filter is off.
|
||||||
|
func resolveHost(ctx context.Context, host string, allowPrivate bool) ([]netip.Addr, error) {
|
||||||
if host == "" {
|
if host == "" {
|
||||||
return nil, errors.New("empty host")
|
return nil, errors.New("empty host")
|
||||||
}
|
}
|
||||||
if addr, err := netip.ParseAddr(host); err == nil {
|
if addr, err := netip.ParseAddr(host); err == nil {
|
||||||
if !IsPublicIP(addr) {
|
if !allowPrivate && !IsPublicIP(addr) {
|
||||||
return nil, fmt.Errorf("%q: %w", addr, ErrNonPublicIP)
|
return nil, fmt.Errorf("%q: %w", addr, ErrNonPublicIP)
|
||||||
}
|
}
|
||||||
return []netip.Addr{addr}, nil
|
return []netip.Addr{addr}, nil
|
||||||
@@ -84,34 +93,62 @@ func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, erro
|
|||||||
if len(addrs) == 0 {
|
if len(addrs) == 0 {
|
||||||
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
|
return nil, fmt.Errorf("resolve %q: no addresses returned", host)
|
||||||
}
|
}
|
||||||
for _, a := range addrs {
|
if !allowPrivate {
|
||||||
if !IsPublicIP(a) {
|
for _, a := range addrs {
|
||||||
return nil, fmt.Errorf("%q resolves to non-public address %q: %w", host, a, ErrNonPublicIP)
|
if !IsPublicIP(a) {
|
||||||
|
return nil, fmt.Errorf("%q resolves to non-public address %q: %w", host, a, ErrNonPublicIP)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return addrs, nil
|
return addrs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// outboundDecision is the result of validating an outbound URL. It is
|
// OutboundDecision is the result of validating an outbound URL via
|
||||||
// stashed in the request context by [outboundRoundTripper] so that
|
// [DecideOutbound]. Callers use it to dial the destination either directly
|
||||||
// [secureDialContext] can either bypass the IP check (allow-list match) or
|
// (operator-approved allow-list match, Bypass true) or via [DialPinned] so
|
||||||
// pin the dial to the IPs that were resolved at validation time.
|
// that the connect targets the IPs resolved at validation time. Passing
|
||||||
type outboundDecision struct {
|
// the decision to the dialer closes the window between validation and
|
||||||
// bypass is true when an allow-list pattern matched the URL. In that
|
// connect that DNS rebinding exploits.
|
||||||
// case the operator has explicitly opted into the destination and the
|
type OutboundDecision struct {
|
||||||
// dial should proceed without an IP check.
|
// Bypass is true when an allow-list pattern matched the URL. The
|
||||||
bypass bool
|
// operator has explicitly opted into the destination; the caller
|
||||||
|
// should dial directly without an additional IP check.
|
||||||
|
Bypass bool
|
||||||
|
|
||||||
// pinned holds the IPs resolved by [ResolveAndCheckPublic] for the URL
|
// Pinned holds the IPs resolved by [ResolveAndCheckPublic] for the URL
|
||||||
// host. The dial should be pinned to one of these to prevent DNS
|
// host. The caller should dial one of these via [DialPinned] to
|
||||||
// rebinding between validation and connect.
|
// prevent DNS rebinding between validation and connect.
|
||||||
pinned []netip.Addr
|
Pinned []netip.Addr
|
||||||
}
|
}
|
||||||
|
|
||||||
// outboundDecisionKey is the context key under which an [outboundDecision]
|
// outboundDecisionKey is the context key under which an [OutboundDecision]
|
||||||
// is stored.
|
// is stored.
|
||||||
type outboundDecisionKey struct{}
|
type outboundDecisionKey struct{}
|
||||||
|
|
||||||
|
// decideConfig carries optional settings for [DecideOutbound] and
|
||||||
|
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
||||||
|
type decideConfig struct {
|
||||||
|
allowPrivateIPs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecideOption customizes how [DecideOutbound] and [FilterOutboundURL]
|
||||||
|
// validate a URL. Options are applied in order and layered on top of the
|
||||||
|
// defaults.
|
||||||
|
type DecideOption func(*decideConfig)
|
||||||
|
|
||||||
|
// WithAllowPrivateIPs disables the public-IP filter on the resolved host.
|
||||||
|
// DNS is still resolved and the returned [OutboundDecision] still carries
|
||||||
|
// the pinned IPs, so the caller retains rebind protection. Only the
|
||||||
|
// "non-public address" rejection is lifted.
|
||||||
|
//
|
||||||
|
// Use this for Chromium deployments behind private networks (Docker
|
||||||
|
// Compose, Kubernetes with ClusterIP services) where legitimate
|
||||||
|
// sub-resources resolve to RFC1918 or loopback addresses. The regex
|
||||||
|
// allow-list and deny-list still apply.
|
||||||
|
func WithAllowPrivateIPs(allow bool) DecideOption {
|
||||||
|
return func(c *decideConfig) { c.allowPrivateIPs = allow }
|
||||||
|
}
|
||||||
|
|
||||||
// httpLikeScheme reports whether scheme is one of http, https, ws, or wss.
|
// httpLikeScheme reports whether scheme is one of http, https, ws, or wss.
|
||||||
// Only these schemes go through the IP-based public-address check; data,
|
// Only these schemes go through the IP-based public-address check; data,
|
||||||
// blob, file, and other schemes are filtered by the regex layer alone.
|
// blob, file, and other schemes are filtered by the regex layer alone.
|
||||||
@@ -123,14 +160,40 @@ func httpLikeScheme(scheme string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// decideOutbound parses rawURL, runs the regex allow/deny lists against the
|
// DecideOutbound parses rawURL, runs the regex allow/deny lists against the
|
||||||
// normalized form, and (when no allow-list match) resolves the host and
|
// normalized form, and (when no allow-list match) resolves the host and
|
||||||
// rejects any non-public address. It returns the resulting
|
// rejects any non-public address. It returns the resulting
|
||||||
// [outboundDecision] which the caller can stash in a context for the dial.
|
// [OutboundDecision] so the caller can pin the dial to the IPs that were
|
||||||
func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) (outboundDecision, error) {
|
// resolved here and skip a second DNS lookup later. This closes the DNS
|
||||||
|
// rebinding window that affects callers that only receive an error from
|
||||||
|
// [FilterOutboundURL].
|
||||||
|
//
|
||||||
|
// The semantics match [FilterOutboundURL]:
|
||||||
|
//
|
||||||
|
// 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 pass [IsPublicIP]. An allow-list match
|
||||||
|
// bypasses the IP check 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].
|
||||||
|
//
|
||||||
|
// Options customize behavior. [WithAllowPrivateIPs] for example disables
|
||||||
|
// the non-public-address rejection while keeping DNS pinning.
|
||||||
|
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)
|
parsed, err := url.Parse(rawURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return outboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
|
||||||
}
|
}
|
||||||
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||||
parsed.Host = strings.ToLower(parsed.Host)
|
parsed.Host = strings.ToLower(parsed.Host)
|
||||||
@@ -145,9 +208,9 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
|||||||
ok, err := clone.MatchString(normalized)
|
ok, err := clone.MatchString(normalized)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
return outboundDecision{}, context.DeadlineExceeded
|
return OutboundDecision{}, context.DeadlineExceeded
|
||||||
}
|
}
|
||||||
return outboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
@@ -157,7 +220,7 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !allowMatched {
|
if !allowMatched {
|
||||||
return outboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("'%s' does not match any expression from the allowed list: %w", normalized, ErrFiltered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,38 +231,38 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
|||||||
ok, err := clone.MatchString(normalized)
|
ok, err := clone.MatchString(normalized)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
return outboundDecision{}, context.DeadlineExceeded
|
return OutboundDecision{}, context.DeadlineExceeded
|
||||||
}
|
}
|
||||||
return outboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
return OutboundDecision{}, fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), normalized, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok {
|
if ok {
|
||||||
return outboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("'%s' matches the expression from the denied list: %w", normalized, ErrFiltered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if allowMatched {
|
if allowMatched {
|
||||||
return outboundDecision{bypass: true}, nil
|
return OutboundDecision{Bypass: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !httpLikeScheme(parsed.Scheme) {
|
if !httpLikeScheme(parsed.Scheme) {
|
||||||
return outboundDecision{}, nil
|
return OutboundDecision{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
host := parsed.Hostname()
|
host := parsed.Hostname()
|
||||||
if host == "" {
|
if host == "" {
|
||||||
return outboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("URL %q has no host: %w", rawURL, ErrFiltered)
|
||||||
}
|
}
|
||||||
|
|
||||||
addrs, err := ResolveAndCheckPublic(ctx, host)
|
addrs, err := resolveHost(ctx, host, cfg.allowPrivateIPs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, ErrNonPublicIP) {
|
if errors.Is(err, ErrNonPublicIP) {
|
||||||
return outboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
return OutboundDecision{}, fmt.Errorf("'%s' targets a non-public address: %w", normalized, ErrFiltered)
|
||||||
}
|
}
|
||||||
return outboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
return OutboundDecision{}, fmt.Errorf("validate '%s' host: %w", normalized, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return outboundDecision{pinned: addrs}, nil
|
return OutboundDecision{Pinned: addrs}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilterOutboundURL validates that rawURL is acceptable for an outbound
|
// FilterOutboundURL validates that rawURL is acceptable for an outbound
|
||||||
@@ -224,13 +287,13 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r
|
|||||||
// An allow-list match bypasses the IP check, allowing operators to opt
|
// An allow-list match bypasses the IP check, allowing operators to opt
|
||||||
// into specific internal destinations via --*-allow-list flags. The
|
// into specific internal destinations via --*-allow-list flags. The
|
||||||
// deny-list always applies and cannot be bypassed by an allow-list match.
|
// 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) error {
|
func FilterOutboundURL(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time, opts ...DecideOption) error {
|
||||||
_, err := decideOutbound(ctx, rawURL, allowList, denyList, deadline)
|
_, err := DecideOutbound(ctx, rawURL, allowList, denyList, deadline, opts...)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// outboundRoundTripper is an [http.RoundTripper] that validates each request
|
// outboundRoundTripper is an [http.RoundTripper] that validates each request
|
||||||
// URL via [decideOutbound] and stashes the resulting [outboundDecision] in
|
// URL via [DecideOutbound] and stashes the resulting [OutboundDecision] in
|
||||||
// the request context so that [secureDialContext] can pin the dial or
|
// the request context so that [secureDialContext] can pin the dial or
|
||||||
// bypass the IP check as appropriate. Because the http.Client invokes
|
// bypass the IP check as appropriate. Because the http.Client invokes
|
||||||
// RoundTrip again for each redirect hop, this also re-validates redirect
|
// RoundTrip again for each redirect hop, this also re-validates redirect
|
||||||
@@ -248,7 +311,7 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
|||||||
deadline = time.Now().Add(30 * time.Second)
|
deadline = time.Now().Add(30 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
decision, err := decideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline)
|
decision, err := DecideOutbound(req.Context(), req.URL.String(), rt.allowList, rt.denyList, deadline)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -279,7 +342,7 @@ func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// secureDialContext consumes the [outboundDecision] stashed in ctx by
|
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
|
||||||
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
||||||
// match), it dials directly. When the decision contains pinned IPs, it
|
// match), it dials directly. When the decision contains pinned IPs, it
|
||||||
// dials each in turn until one connects. When no decision is present (the
|
// dials each in turn until one connects. When no decision is present (the
|
||||||
@@ -291,12 +354,12 @@ func secureDialContext(ctx context.Context, network, addr string) (net.Conn, err
|
|||||||
return nil, fmt.Errorf("split host:port %q: %w", addr, err)
|
return nil, fmt.Errorf("split host:port %q: %w", addr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if decision, ok := ctx.Value(outboundDecisionKey{}).(outboundDecision); ok {
|
if decision, ok := ctx.Value(outboundDecisionKey{}).(OutboundDecision); ok {
|
||||||
if decision.bypass {
|
if decision.Bypass {
|
||||||
return outboundDialer.DialContext(ctx, network, addr)
|
return outboundDialer.DialContext(ctx, network, addr)
|
||||||
}
|
}
|
||||||
if len(decision.pinned) > 0 {
|
if len(decision.Pinned) > 0 {
|
||||||
return dialPinned(ctx, network, decision.pinned, port)
|
return DialPinned(ctx, network, decision.Pinned, port)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,12 +367,15 @@ func secureDialContext(ctx context.Context, network, addr string) (net.Conn, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return dialPinned(ctx, network, addrs, port)
|
return DialPinned(ctx, network, addrs, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
// dialPinned dials each addr in turn until one connects, returning the
|
// DialPinned dials each addr in turn until one connects, returning the
|
||||||
// first successful connection or the last error.
|
// first successful connection or the last error. Callers pass the Pinned
|
||||||
func dialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) {
|
// slice from [OutboundDecision] so that the dial targets exactly the IPs
|
||||||
|
// that [DecideOutbound] resolved and validated, 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
|
var lastErr error
|
||||||
for _, a := range addrs {
|
for _, a := range addrs {
|
||||||
conn, err := outboundDialer.DialContext(ctx, network, net.JoinHostPort(a.String(), port))
|
conn, err := outboundDialer.DialContext(ctx, network, net.JoinHostPort(a.String(), port))
|
||||||
|
|||||||
@@ -306,3 +306,27 @@ func TestResolveAndCheckPublic_HostResolvesToPublic(t *testing.T) {
|
|||||||
t.Fatalf("expected [1.1.1.1], got: %v", addrs)
|
t.Fatalf("expected [1.1.1.1], got: %v", addrs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecideOutbound_AllowPrivateIPs_DenyListStillApplies(t *testing.T) {
|
||||||
|
withStubResolver(t, func(host string) ([]netip.Addr, error) {
|
||||||
|
t.Fatalf("unexpected DNS lookup for %q", host)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Denial must still win over WithAllowPrivateIPs(true). Gherkin
|
||||||
|
// coverage exercises flag on/off against the IP check but not the
|
||||||
|
// interaction with the regex deny-list; keep this primitive test for
|
||||||
|
// that specific combination.
|
||||||
|
deny := []*regexp2.Regexp{regexp2.MustCompile(`^http://evil\.`, 0)}
|
||||||
|
|
||||||
|
_, err := DecideOutbound(
|
||||||
|
context.Background(),
|
||||||
|
"http://evil.local/",
|
||||||
|
nil, deny,
|
||||||
|
time.Now().Add(5*time.Second),
|
||||||
|
WithAllowPrivateIPs(true),
|
||||||
|
)
|
||||||
|
if !errors.Is(err, ErrFiltered) {
|
||||||
|
t.Fatalf("deny-list must still win with WithAllowPrivateIPs(true), got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type browserArguments struct {
|
|||||||
// Tasks specific.
|
// Tasks specific.
|
||||||
allowList []*regexp2.Regexp
|
allowList []*regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList []*regexp2.Regexp
|
||||||
|
allowPrivateIPs bool
|
||||||
clearCache bool
|
clearCache bool
|
||||||
clearCookies bool
|
clearCookies bool
|
||||||
disableJavaScript bool
|
disableJavaScript bool
|
||||||
@@ -57,15 +58,17 @@ type chromiumBrowser struct {
|
|||||||
ctxMu sync.RWMutex
|
ctxMu sync.RWMutex
|
||||||
isStarted atomic.Bool
|
isStarted atomic.Bool
|
||||||
|
|
||||||
arguments browserArguments
|
arguments browserArguments
|
||||||
fs *gotenberg.FileSystem
|
fs *gotenberg.FileSystem
|
||||||
|
pinningProxy *pinningProxy
|
||||||
}
|
}
|
||||||
|
|
||||||
func newChromiumBrowser(arguments browserArguments) browser {
|
func newChromiumBrowser(arguments browserArguments) browser {
|
||||||
b := &chromiumBrowser{
|
b := &chromiumBrowser{
|
||||||
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.allowPrivateIPs),
|
||||||
}
|
}
|
||||||
b.isStarted.Store(false)
|
b.isStarted.Store(false)
|
||||||
|
|
||||||
@@ -136,6 +139,25 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error {
|
|||||||
opts = append(opts, chromedp.ProxyServer(b.arguments.proxyServer))
|
opts = append(opts, chromedp.ProxyServer(b.arguments.proxyServer))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default: route Chromium through the internal pinning proxy so that
|
||||||
|
// Chromium never performs its own DNS lookup for the navigation URL
|
||||||
|
// or any sub-resource. The proxy resolves and validates each URL
|
||||||
|
// once per request and dials the pinned IP, closing the DNS
|
||||||
|
// rebinding window between Gotenberg's validation and Chromium's
|
||||||
|
// connect.
|
||||||
|
//
|
||||||
|
// Skip when the operator has configured their own egress proxy or
|
||||||
|
// custom host-resolver mappings: those deployments take
|
||||||
|
// responsibility for outbound safety themselves and routing through
|
||||||
|
// an internal proxy would override their configuration.
|
||||||
|
if b.arguments.proxyServer == "" && b.arguments.hostResolverRules == "" {
|
||||||
|
err = b.pinningProxy.Start(logger)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("start pinning proxy: %w", err)
|
||||||
|
}
|
||||||
|
opts = append(opts, chromedp.ProxyServer(b.pinningProxy.URL()))
|
||||||
|
}
|
||||||
|
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/524.
|
// See https://github.com/gotenberg/gotenberg/issues/524.
|
||||||
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
|
opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout))
|
||||||
|
|
||||||
@@ -236,6 +258,15 @@ func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
|
|||||||
b.userProfileDirPath = ""
|
b.userProfileDirPath = ""
|
||||||
b.isStarted.Store(false)
|
b.isStarted.Store(false)
|
||||||
|
|
||||||
|
// Stop the pinning proxy after Chromium shutdown so that any
|
||||||
|
// in-flight requests Chromium issues during teardown complete. The
|
||||||
|
// Stop call is a no-op when the proxy was not started (operator
|
||||||
|
// configured --chromium-proxy-server or --chromium-host-resolver-rules).
|
||||||
|
err := b.pinningProxy.Stop(logger)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorContext(context.Background(), fmt.Sprintf("stop pinning proxy: %s", err))
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +369,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
|
|
||||||
// We validate the "main" URL against our allowed / deny lists, and
|
// We validate the "main" URL against our allowed / deny lists, and
|
||||||
// against the IP-based outbound URL guard. See [gotenberg.FilterOutboundURL].
|
// against the IP-based outbound URL guard. See [gotenberg.FilterOutboundURL].
|
||||||
err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline)
|
err := gotenberg.FilterOutboundURL(ctx, url, b.arguments.allowList, b.arguments.denyList, deadline, gotenberg.WithAllowPrivateIPs(b.arguments.allowPrivateIPs))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("filter URL: %w", err)
|
return fmt.Errorf("filter URL: %w", err)
|
||||||
}
|
}
|
||||||
@@ -359,6 +390,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url strin
|
|||||||
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
|
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
|
||||||
allowList: b.arguments.allowList,
|
allowList: b.arguments.allowList,
|
||||||
denyList: b.arguments.denyList,
|
denyList: b.arguments.denyList,
|
||||||
|
allowPrivateIPs: b.arguments.allowPrivateIPs,
|
||||||
allowedFilePrefixes: options.AllowedFilePrefixes,
|
allowedFilePrefixes: options.AllowedFilePrefixes,
|
||||||
extraHttpHeaders: options.ExtraHttpHeaders,
|
extraHttpHeaders: options.ExtraHttpHeaders,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
|
|||||||
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
|
||||||
fs.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-allow-private-ips", false, "Accept sub-resources that resolve to private, loopback, or link-local addresses. Intended for operators running Gotenberg inside a private network (Docker Compose, Kubernetes ClusterIP); the regex allow-list and deny-list still apply")
|
||||||
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
|
||||||
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
|
||||||
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
|
||||||
@@ -495,6 +496,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
|
|||||||
|
|
||||||
allowList: flags.MustRegexpSlice("chromium-allow-list"),
|
allowList: flags.MustRegexpSlice("chromium-allow-list"),
|
||||||
denyList: flags.MustRegexpSlice("chromium-deny-list"),
|
denyList: flags.MustRegexpSlice("chromium-deny-list"),
|
||||||
|
allowPrivateIPs: flags.MustBool("chromium-allow-private-ips"),
|
||||||
clearCache: flags.MustBool("chromium-clear-cache"),
|
clearCache: flags.MustBool("chromium-clear-cache"),
|
||||||
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
clearCookies: flags.MustBool("chromium-clear-cookies"),
|
||||||
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
|
|
||||||
type eventRequestPausedOptions struct {
|
type eventRequestPausedOptions struct {
|
||||||
allowList, denyList []*regexp2.Regexp
|
allowList, denyList []*regexp2.Regexp
|
||||||
|
allowPrivateIPs bool
|
||||||
allowedFilePrefixes []string
|
allowedFilePrefixes []string
|
||||||
extraHttpHeaders []ExtraHttpHeader
|
extraHttpHeaders []ExtraHttpHeader
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline)
|
err := gotenberg.FilterOutboundURL(ctx, e.Request.URL, options.allowList, options.denyList, deadline, gotenberg.WithAllowPrivateIPs(options.allowPrivateIPs))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnContext(ctx, err.Error())
|
logger.WarnContext(ctx, err.Error())
|
||||||
allow = false
|
allow = false
|
||||||
|
|||||||
324
pkg/modules/chromium/pinning_proxy.go
Normal file
324
pkg/modules/chromium/pinning_proxy.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pinningProxy is a loopback-bound HTTP/1.1 forward and CONNECT proxy
|
||||||
|
// placed between Chromium and the outbound network. It runs the same
|
||||||
|
// allow/deny/IP-public validation as [gotenberg.FilterOutboundURL] on
|
||||||
|
// every request and dials the destination using the IPs resolved at that
|
||||||
|
// moment. Routing Chromium through this proxy eliminates the Chromium-side
|
||||||
|
// DNS lookup that otherwise opens a DNS rebinding window between
|
||||||
|
// Gotenberg's validation and Chromium's TCP connect.
|
||||||
|
//
|
||||||
|
// The proxy is transparent to the caller. HTTPS sub-resources tunnel
|
||||||
|
// through CONNECT with Chromium performing its own TLS handshake using
|
||||||
|
// the original hostname, preserving SNI and certificate validation.
|
||||||
|
type pinningProxy struct {
|
||||||
|
allowList []*regexp2.Regexp
|
||||||
|
denyList []*regexp2.Regexp
|
||||||
|
|
||||||
|
// decide resolves and validates a URL. Tests may override it.
|
||||||
|
decide func(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error)
|
||||||
|
|
||||||
|
// dialPinned dials the pinned IPs for a decision. Tests may override
|
||||||
|
// it to connect to a stub upstream regardless of decision.
|
||||||
|
dialPinned func(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error)
|
||||||
|
|
||||||
|
// dialBypass dials the destination hostname directly (operator
|
||||||
|
// allow-list opt-in). Tests may override it.
|
||||||
|
dialBypass func(ctx context.Context, network, addr string) (net.Conn, error)
|
||||||
|
|
||||||
|
listener net.Listener
|
||||||
|
server *http.Server
|
||||||
|
wg sync.WaitGroup
|
||||||
|
|
||||||
|
logger *slog.Logger
|
||||||
|
started bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPinningProxy returns a pinning proxy configured with the given
|
||||||
|
// allow/deny lists. When allowPrivateIPs is true, the proxy skips the
|
||||||
|
// public-IP filter while still pinning resolved IPs to the dial. The
|
||||||
|
// returned proxy is not yet listening; call Start.
|
||||||
|
func newPinningProxy(allowList, denyList []*regexp2.Regexp, allowPrivateIPs bool) *pinningProxy {
|
||||||
|
return &pinningProxy{
|
||||||
|
allowList: allowList,
|
||||||
|
denyList: denyList,
|
||||||
|
decide: func(ctx context.Context, rawURL string, allow, deny []*regexp2.Regexp, deadline time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
|
return gotenberg.DecideOutbound(ctx, rawURL, allow, deny, deadline, gotenberg.WithAllowPrivateIPs(allowPrivateIPs))
|
||||||
|
},
|
||||||
|
dialPinned: gotenberg.DialPinned,
|
||||||
|
dialBypass: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||||
|
return dialer.DialContext(ctx, network, addr)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start binds the proxy to 127.0.0.1 on an ephemeral port and serves in a
|
||||||
|
// background goroutine. Bind failures return an error; the caller must
|
||||||
|
// not proceed to start Chromium with --proxy-server.
|
||||||
|
func (p *pinningProxy) Start(logger *slog.Logger) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if p.started {
|
||||||
|
return errors.New("pinning proxy already started")
|
||||||
|
}
|
||||||
|
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("bind pinning proxy: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.listener = l
|
||||||
|
p.logger = logger.With(slog.String("logger", "pinning-proxy"))
|
||||||
|
p.server = &http.Server{
|
||||||
|
Handler: http.HandlerFunc(p.serveHTTP),
|
||||||
|
// Guard against slow header attacks. Body reads are controlled
|
||||||
|
// per-handler.
|
||||||
|
ReadHeaderTimeout: 15 * time.Second,
|
||||||
|
ErrorLog: slog.NewLogLogger(p.logger.Handler(), slog.LevelWarn),
|
||||||
|
}
|
||||||
|
|
||||||
|
p.wg.Go(func() {
|
||||||
|
serveErr := p.server.Serve(l)
|
||||||
|
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||||
|
p.logger.ErrorContext(context.Background(), fmt.Sprintf("pinning proxy serve: %s", serveErr))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
p.started = true
|
||||||
|
p.logger.DebugContext(context.Background(), fmt.Sprintf("pinning proxy listening on %s", l.Addr()))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop shuts the proxy down and waits for in-flight handlers to complete.
|
||||||
|
// Safe to call on a non-started proxy.
|
||||||
|
func (p *pinningProxy) Stop(logger *slog.Logger) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
if !p.started {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
srv := p.server
|
||||||
|
p.started = false
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
shutdownErr := srv.Shutdown(ctx)
|
||||||
|
p.wg.Wait()
|
||||||
|
|
||||||
|
if shutdownErr != nil {
|
||||||
|
return fmt.Errorf("shutdown pinning proxy: %w", shutdownErr)
|
||||||
|
}
|
||||||
|
logger.DebugContext(context.Background(), "pinning proxy stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL returns the proxy URL suitable for Chromium's --proxy-server flag.
|
||||||
|
// Returns an empty string when the proxy is not listening.
|
||||||
|
func (p *pinningProxy) URL() string {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.listener == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "http://" + p.listener.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *pinningProxy) serveHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method == http.MethodConnect {
|
||||||
|
p.handleConnect(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.handleForward(w, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConnect handles HTTPS (and any other CONNECT) tunnels. Chromium
|
||||||
|
// issues CONNECT host:port; the proxy validates the host, dials the
|
||||||
|
// pinned IP, and splices the client socket with the upstream socket.
|
||||||
|
// Chromium then negotiates TLS end-to-end with the original hostname in
|
||||||
|
// SNI.
|
||||||
|
func (p *pinningProxy) handleConnect(w http.ResponseWriter, req *http.Request) {
|
||||||
|
_, port, err := net.SplitHostPort(req.Host)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "bad CONNECT target", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline, ok := req.Context().Deadline()
|
||||||
|
if !ok {
|
||||||
|
deadline = time.Now().Add(30 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The validation URL uses https:// so that http-like scheme checks
|
||||||
|
// apply in [gotenberg.DecideOutbound]. The scheme does not influence
|
||||||
|
// the CONNECT handling beyond filtering.
|
||||||
|
decision, err := p.decide(req.Context(), "https://"+req.Host, p.allowList, p.denyList, deadline)
|
||||||
|
if err != nil {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT blocked for '%s': %s", req.Host, err))
|
||||||
|
http.Error(w, "CONNECT blocked", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var upstream net.Conn
|
||||||
|
switch {
|
||||||
|
case decision.Bypass:
|
||||||
|
upstream, err = p.dialBypass(req.Context(), "tcp", req.Host)
|
||||||
|
case len(decision.Pinned) > 0:
|
||||||
|
upstream, err = p.dialPinned(req.Context(), "tcp", decision.Pinned, port)
|
||||||
|
default:
|
||||||
|
err = errors.New("no pinned addresses and not bypassed")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("CONNECT dial failed for '%s': %s", req.Host, err))
|
||||||
|
http.Error(w, "upstream dial failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
hj, ok := w.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "hijack unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
client, _, err := hj.Hijack()
|
||||||
|
if err != nil {
|
||||||
|
p.logger.ErrorContext(req.Context(), fmt.Sprintf("hijack CONNECT: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
_, err = client.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
|
||||||
|
if err != nil {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("write CONNECT ack: %s", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splice bytes in both directions until either side closes.
|
||||||
|
var splice sync.WaitGroup
|
||||||
|
splice.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer splice.Done()
|
||||||
|
_, _ = io.Copy(upstream, client)
|
||||||
|
if cw, ok := upstream.(interface{ CloseWrite() error }); ok {
|
||||||
|
_ = cw.CloseWrite()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer splice.Done()
|
||||||
|
_, _ = io.Copy(client, upstream)
|
||||||
|
if cw, ok := client.(interface{ CloseWrite() error }); ok {
|
||||||
|
_ = cw.CloseWrite()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
splice.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleForward handles plain HTTP requests sent to the proxy as absolute
|
||||||
|
// URIs (GET http://host/path). The proxy revalidates the URL, then
|
||||||
|
// forwards the request via a transport that dials the pinned IP.
|
||||||
|
func (p *pinningProxy) handleForward(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.URL == nil || req.URL.Scheme == "" || req.URL.Host == "" {
|
||||||
|
http.Error(w, "absolute URL required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline, ok := req.Context().Deadline()
|
||||||
|
if !ok {
|
||||||
|
deadline = time.Now().Add(30 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := p.decide(req.Context(), req.URL.String(), p.allowList, p.denyList, deadline)
|
||||||
|
if err != nil {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward blocked for '%s': %s", req.URL, err))
|
||||||
|
http.Error(w, "request blocked", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
outReq := req.Clone(req.Context())
|
||||||
|
outReq.RequestURI = ""
|
||||||
|
stripHopByHopHeaders(outReq.Header)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
_, port, splitErr := net.SplitHostPort(addr)
|
||||||
|
if splitErr != nil {
|
||||||
|
return nil, fmt.Errorf("split forward addr %q: %w", addr, splitErr)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case decision.Bypass:
|
||||||
|
return p.dialBypass(ctx, network, addr)
|
||||||
|
case len(decision.Pinned) > 0:
|
||||||
|
return p.dialPinned(ctx, network, decision.Pinned, port)
|
||||||
|
default:
|
||||||
|
return nil, errors.New("no pinned addresses and not bypassed")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
|
|
||||||
|
resp, err := transport.RoundTrip(outReq)
|
||||||
|
if err != nil {
|
||||||
|
p.logger.WarnContext(req.Context(), fmt.Sprintf("forward RoundTrip failed for '%s': %s", req.URL, err))
|
||||||
|
http.Error(w, "upstream error", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
copyHeaders(w.Header(), resp.Header)
|
||||||
|
stripHopByHopHeaders(w.Header())
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
_, _ = io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per RFC 7230 section 6.1.
|
||||||
|
var hopByHopHeaders = []string{
|
||||||
|
"Connection",
|
||||||
|
"Keep-Alive",
|
||||||
|
"Proxy-Authenticate",
|
||||||
|
"Proxy-Authorization",
|
||||||
|
"Proxy-Connection",
|
||||||
|
"Te",
|
||||||
|
"Trailer",
|
||||||
|
"Transfer-Encoding",
|
||||||
|
"Upgrade",
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripHopByHopHeaders(h http.Header) {
|
||||||
|
for _, name := range hopByHopHeaders {
|
||||||
|
h.Del(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyHeaders(dst, src http.Header) {
|
||||||
|
for k, vs := range src {
|
||||||
|
for _, v := range vs {
|
||||||
|
dst.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
485
pkg/modules/chromium/pinning_proxy_test.go
Normal file
485
pkg/modules/chromium/pinning_proxy_test.go
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseURL(t *testing.T, raw string) *url.URL {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse %q: %v", raw, err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRawTCPServer starts a TCP server on 127.0.0.1:0 that calls handle for
|
||||||
|
// every accepted connection. It returns the listener address and a cleanup
|
||||||
|
// function.
|
||||||
|
func newRawTCPServer(t *testing.T, handle func(net.Conn)) (string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go handle(conn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return l.Addr().String(), func() { _ = l.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// newProxyForTest returns a pinning proxy whose decide and dial functions
|
||||||
|
// are set to test stubs. The proxy is started on a loopback ephemeral
|
||||||
|
// port and stopped during test cleanup.
|
||||||
|
func newProxyForTest(t *testing.T, p *pinningProxy) string {
|
||||||
|
t.Helper()
|
||||||
|
err := p.Start(testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start pinning proxy: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = p.Stop(testLogger())
|
||||||
|
})
|
||||||
|
return p.URL()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinningProxy_Forward_Pinned_Success(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Host != "example.com" {
|
||||||
|
t.Errorf("upstream expected Host=example.com, got %q", r.Host)
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprint(w, "hello-from-upstream")
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
upstreamURL := mustParseURL(t, upstream.URL)
|
||||||
|
|
||||||
|
var decideCalls atomic.Int32
|
||||||
|
p := newPinningProxy(nil, nil, 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
|
||||||
|
}
|
||||||
|
p.dialPinned = func(ctx context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, upstreamURL.Host)
|
||||||
|
}
|
||||||
|
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 resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if string(body) != "hello-from-upstream" {
|
||||||
|
t.Fatalf("body = %q, want %q", body, "hello-from-upstream")
|
||||||
|
}
|
||||||
|
if got := decideCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("decide called %d times, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinningProxy_Forward_BlockedByDecide(t *testing.T) {
|
||||||
|
p := newPinningProxy(nil, nil, false)
|
||||||
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
|
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
||||||
|
}
|
||||||
|
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
t.Fatal("dialPinned must not be called when decide returns an error")
|
||||||
|
return nil, errors.New("unreachable")
|
||||||
|
}
|
||||||
|
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://blocked.example/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET via proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinningProxy_Forward_Bypass(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = fmt.Fprint(w, "bypassed")
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
upstreamURL := mustParseURL(t, upstream.URL)
|
||||||
|
|
||||||
|
var bypassCalls atomic.Int32
|
||||||
|
p := newPinningProxy(nil, nil, false)
|
||||||
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
|
return gotenberg.OutboundDecision{Bypass: true}, nil
|
||||||
|
}
|
||||||
|
p.dialBypass = func(_ context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
bypassCalls.Add(1)
|
||||||
|
return net.Dial(network, upstreamURL.Host)
|
||||||
|
}
|
||||||
|
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
t.Fatal("dialPinned must not be called on bypass")
|
||||||
|
return nil, errors.New("unreachable")
|
||||||
|
}
|
||||||
|
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://internal.example/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET via proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if got := bypassCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("dialBypass called %d times, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinningProxy_Forward_StripsHopByHopHeaders(t *testing.T) {
|
||||||
|
var upstreamSawProxyAuth bool
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Header.Get("Proxy-Authorization") != "" {
|
||||||
|
upstreamSawProxyAuth = true
|
||||||
|
}
|
||||||
|
w.Header().Set("Connection", "close")
|
||||||
|
w.Header().Set("Proxy-Connection", "close")
|
||||||
|
w.Header().Set("X-Downstream", "ok")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
upstreamURL := mustParseURL(t, upstream.URL)
|
||||||
|
|
||||||
|
p := newPinningProxy(nil, nil, 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
|
||||||
|
}
|
||||||
|
p.dialPinned = func(ctx context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, upstreamURL.Host)
|
||||||
|
}
|
||||||
|
proxyURL := newProxyForTest(t, p)
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new request: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Proxy-Authorization", "Basic Zm9vOmJhcg==")
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
Proxy: http.ProxyURL(mustParseURL(t, proxyURL)),
|
||||||
|
},
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET via proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if upstreamSawProxyAuth {
|
||||||
|
t.Fatalf("upstream received Proxy-Authorization, proxy did not strip it")
|
||||||
|
}
|
||||||
|
if resp.Header.Get("Proxy-Connection") != "" {
|
||||||
|
t.Fatalf("response retained Proxy-Connection, proxy did not strip it")
|
||||||
|
}
|
||||||
|
if resp.Header.Get("X-Downstream") != "ok" {
|
||||||
|
t.Fatalf("response missing X-Downstream header")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinningProxy_Forward_RejectsNonAbsoluteURL(t *testing.T) {
|
||||||
|
p := newPinningProxy(nil, nil, 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
|
||||||
|
}
|
||||||
|
proxyURL := newProxyForTest(t, p)
|
||||||
|
|
||||||
|
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Send a request with a path-only target, not an absolute URI, which
|
||||||
|
// the proxy should reject with 400.
|
||||||
|
_, err = fmt.Fprint(conn, "GET /path HTTP/1.1\r\nHost: example.com\r\n\r\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write request: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read response: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinningProxy_CONNECT_Pinned_Success(t *testing.T) {
|
||||||
|
upstreamAddr, stop := newRawTCPServer(t, func(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
_, _ = c.Write([]byte("HI"))
|
||||||
|
buf := make([]byte, 4)
|
||||||
|
n, _ := io.ReadFull(c, buf)
|
||||||
|
_, _ = c.Write(buf[:n])
|
||||||
|
})
|
||||||
|
t.Cleanup(stop)
|
||||||
|
|
||||||
|
var decideCalls atomic.Int32
|
||||||
|
p := newPinningProxy(nil, nil, 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
|
||||||
|
}
|
||||||
|
p.dialPinned = func(_ context.Context, network string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, upstreamAddr)
|
||||||
|
}
|
||||||
|
proxyURL := newProxyForTest(t, p)
|
||||||
|
|
||||||
|
// Connect to the proxy, send CONNECT, splice raw bytes.
|
||||||
|
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(conn, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write CONNECT: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
br := bufio.NewReader(conn)
|
||||||
|
statusLine, err := br.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read status: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(statusLine, " 200 ") {
|
||||||
|
t.Fatalf("CONNECT status = %q, want 200", statusLine)
|
||||||
|
}
|
||||||
|
// Consume the blank line after headers.
|
||||||
|
for {
|
||||||
|
line, err := br.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read headers: %v", err)
|
||||||
|
}
|
||||||
|
if line == "\r\n" || line == "\n" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hi := make([]byte, 2)
|
||||||
|
_, err = io.ReadFull(br, hi)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read greeting: %v", err)
|
||||||
|
}
|
||||||
|
if string(hi) != "HI" {
|
||||||
|
t.Fatalf("greeting = %q, want HI", hi)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = conn.Write([]byte("PONG"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write PONG: %v", err)
|
||||||
|
}
|
||||||
|
echo := make([]byte, 4)
|
||||||
|
_, err = io.ReadFull(br, echo)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read echo: %v", err)
|
||||||
|
}
|
||||||
|
if string(echo) != "PONG" {
|
||||||
|
t.Fatalf("echo = %q, want PONG", echo)
|
||||||
|
}
|
||||||
|
if got := decideCalls.Load(); got != 1 {
|
||||||
|
t.Fatalf("decide called %d times, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinningProxy_CONNECT_BlockedByDecide(t *testing.T) {
|
||||||
|
p := newPinningProxy(nil, nil, false)
|
||||||
|
p.decide = func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
|
return gotenberg.OutboundDecision{}, fmt.Errorf("nope: %w", gotenberg.ErrFiltered)
|
||||||
|
}
|
||||||
|
p.dialPinned = func(_ context.Context, _ string, _ []netip.Addr, _ string) (net.Conn, error) {
|
||||||
|
t.Fatal("dialPinned must not be called when decide returns an error")
|
||||||
|
return nil, errors.New("unreachable")
|
||||||
|
}
|
||||||
|
proxyURL := newProxyForTest(t, p)
|
||||||
|
|
||||||
|
conn, err := net.Dial("tcp", strings.TrimPrefix(proxyURL, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
|
||||||
|
_, err = fmt.Fprintf(conn, "CONNECT rebind.example:443 HTTP/1.1\r\nHost: rebind.example:443\r\n\r\n")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write CONNECT: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read response: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("CONNECT status = %d, want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPinningProxy_DNSRebind_SingleResolution is the regression test for
|
||||||
|
// the DNS rebinding window. It simulates a DNS authority that returns a
|
||||||
|
// public IP on the first lookup and a loopback IP on subsequent lookups.
|
||||||
|
// The proxy must resolve the host exactly once per request and dial the
|
||||||
|
// IP validated at that moment, so that a second resolution by any later
|
||||||
|
// layer cannot pivot the connection to an internal target.
|
||||||
|
func TestPinningProxy_DNSRebind_SingleResolution(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = fmt.Fprint(w, "public-upstream")
|
||||||
|
}))
|
||||||
|
t.Cleanup(upstream.Close)
|
||||||
|
upstreamURL := mustParseURL(t, upstream.URL)
|
||||||
|
|
||||||
|
var lookupCount atomic.Int32
|
||||||
|
stubDecide := func(_ context.Context, _ string, _, _ []*regexp2.Regexp, _ time.Time) (gotenberg.OutboundDecision, error) {
|
||||||
|
n := lookupCount.Add(1)
|
||||||
|
if n == 1 {
|
||||||
|
// First lookup: returns a public IP, validation passes, the
|
||||||
|
// proxy pins it for the dial.
|
||||||
|
return gotenberg.OutboundDecision{Pinned: []netip.Addr{netip.MustParseAddr("93.184.216.34")}}, nil
|
||||||
|
}
|
||||||
|
// Any subsequent lookup for the same host would return a
|
||||||
|
// loopback IP. This return value must not influence the dial
|
||||||
|
// because the proxy must not call decide again for this request.
|
||||||
|
return gotenberg.OutboundDecision{}, fmt.Errorf("rebind lookup: %w", gotenberg.ErrFiltered)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := newPinningProxy(nil, nil, 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" {
|
||||||
|
t.Errorf("dialPinned got addrs %v, want [93.184.216.34]", addrs)
|
||||||
|
}
|
||||||
|
return net.Dial(network, upstreamURL.Host)
|
||||||
|
}
|
||||||
|
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://rebind.example/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET via proxy: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if string(body) != "public-upstream" {
|
||||||
|
t.Fatalf("body = %q, want %q", body, "public-upstream")
|
||||||
|
}
|
||||||
|
if got := lookupCount.Load(); got != 1 {
|
||||||
|
t.Fatalf("decide called %d times, want exactly 1 (rebind protection)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinningProxy_StartTwice(t *testing.T) {
|
||||||
|
p := newPinningProxy(nil, nil, false)
|
||||||
|
err := p.Start(testLogger())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first Start: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = p.Stop(testLogger()) })
|
||||||
|
|
||||||
|
err = p.Start(testLogger())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("second Start: expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinningProxy_StopIdempotent(t *testing.T) {
|
||||||
|
p := newPinningProxy(nil, nil, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.Start(testLogger()); err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
if err := p.Stop(testLogger()); err != nil {
|
||||||
|
t.Fatalf("first Stop: %v", err)
|
||||||
|
}
|
||||||
|
if err := p.Stop(testLogger()); err != nil {
|
||||||
|
t.Fatalf("second Stop on stopped proxy: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -402,6 +403,34 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
|
|||||||
return form, screenshotOptions
|
return form, screenshotOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rejectFileScheme returns an HTTP 400 [api] error when rawURL uses the
|
||||||
|
// file:// scheme. /forms/chromium/convert/url and
|
||||||
|
// /forms/chromium/screenshot/url accept user-supplied URLs and are
|
||||||
|
// intended for navigating to remote HTTP(S) resources; allowing file://
|
||||||
|
// lets a caller reach Chromium's working directory through the default
|
||||||
|
// deny-list's /tmp/ allowance, which exists only to serve main-page
|
||||||
|
// HTML/Markdown that the other routes generate. Filter the scheme at the
|
||||||
|
// route layer where no request-scoped allowedFilePrefixes exists.
|
||||||
|
func rejectFileScheme(rawURL string) error {
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return api.WrapError(
|
||||||
|
fmt.Errorf("parse URL: %w", err),
|
||||||
|
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid URL: %s", err)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(parsed.Scheme, "file") {
|
||||||
|
return api.WrapError(
|
||||||
|
fmt.Errorf("file:// scheme not allowed on URL route"),
|
||||||
|
api.NewSentinelHttpError(
|
||||||
|
http.StatusBadRequest,
|
||||||
|
"file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
|
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
|
||||||
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
||||||
return api.Route{
|
return api.Route{
|
||||||
@@ -431,6 +460,11 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = rejectFileScheme(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reject URL scheme: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
|
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
|
||||||
watermark.Expression = watermarkFile
|
watermark.Expression = watermarkFile
|
||||||
}
|
}
|
||||||
@@ -467,6 +501,11 @@ func screenshotUrlRoute(chromium Api) api.Route {
|
|||||||
return fmt.Errorf("validate form data: %w", err)
|
return fmt.Errorf("validate form data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = rejectFileScheme(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reject URL scheme: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
err = screenshotUrl(ctx, chromium, url, options)
|
err = screenshotUrl(ctx, chromium, url, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("URL screenshot: %w", err)
|
return fmt.Errorf("URL screenshot: %w", err)
|
||||||
|
|||||||
@@ -478,6 +478,41 @@ Feature: /forms/chromium/convert/url
|
|||||||
# Modern browsers block file URIs from being loaded into iframes when the parent page is served over HTTP/HTTPS.
|
# Modern browsers block file URIs from being loaded into iframes when the parent page is served over HTTP/HTTPS.
|
||||||
| 'file:///etc/passwd' does not match any expression from the allowed list |
|
| 'file:///etc/passwd' does not match any expression from the allowed list |
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/convert/url (file:// scheme rejected at route layer)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | file:///tmp/foo/index.html | field |
|
||||||
|
Then the response status code should be 400
|
||||||
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
|
Then the response body should match string:
|
||||||
|
"""
|
||||||
|
file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML
|
||||||
|
"""
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, allow-private-ips off)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
Given I have a static server
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
|
||||||
|
Then the response status code should be 403
|
||||||
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
|
Then the response body should match string:
|
||||||
|
"""
|
||||||
|
Forbidden
|
||||||
|
"""
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/convert/url (Main URL resolves to a non-public IP, allow-private-ips on)
|
||||||
|
Given I have a Gotenberg container with the following environment variable(s):
|
||||||
|
| CHROMIUM_ALLOW_LIST | |
|
||||||
|
| CHROMIUM_ALLOW_PRIVATE_IPS | true |
|
||||||
|
Given I have a static server
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
|
||||||
|
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be 1 PDF(s) in the response
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/url (JavaScript Enabled)
|
Scenario: POST /forms/chromium/convert/url (JavaScript Enabled)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
Given I have a static server
|
Given I have a static server
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ Feature: /forms/chromium/screenshot/url
|
|||||||
Then the response status code should be 400
|
Then the response status code should be 400
|
||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/screenshot/url (file:// scheme rejected at route layer)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/screenshot/url" endpoint with the following form data and header(s):
|
||||||
|
| url | file:///tmp/foo/index.html | field |
|
||||||
|
Then the response status code should be 400
|
||||||
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
|
Then the response body should match string:
|
||||||
|
"""
|
||||||
|
file:// URLs are not accepted on this route. Use the /convert/html or /convert/markdown routes to render local HTML
|
||||||
|
"""
|
||||||
|
|
||||||
@webhook
|
@webhook
|
||||||
Scenario: POST /forms/chromium/screenshot/url (Webhook)
|
Scenario: POST /forms/chromium/screenshot/url (Webhook)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ Feature: /debug
|
|||||||
"chromium-allow-file-access-from-files": "false",
|
"chromium-allow-file-access-from-files": "false",
|
||||||
"chromium-allow-insecure-localhost": "false",
|
"chromium-allow-insecure-localhost": "false",
|
||||||
"chromium-allow-list": "[.+]",
|
"chromium-allow-list": "[.+]",
|
||||||
|
"chromium-allow-private-ips": "false",
|
||||||
"chromium-auto-start": "false",
|
"chromium-auto-start": "false",
|
||||||
"chromium-clear-cache": "false",
|
"chromium-clear-cache": "false",
|
||||||
"chromium-clear-cookies": "false",
|
"chromium-clear-cookies": "false",
|
||||||
@@ -210,6 +211,7 @@ Feature: /debug
|
|||||||
"chromium-allow-file-access-from-files": "false",
|
"chromium-allow-file-access-from-files": "false",
|
||||||
"chromium-allow-insecure-localhost": "false",
|
"chromium-allow-insecure-localhost": "false",
|
||||||
"chromium-allow-list": "[.+]",
|
"chromium-allow-list": "[.+]",
|
||||||
|
"chromium-allow-private-ips": "false",
|
||||||
"chromium-auto-start": "false",
|
"chromium-auto-start": "false",
|
||||||
"chromium-clear-cache": "false",
|
"chromium-clear-cache": "false",
|
||||||
"chromium-clear-cookies": "false",
|
"chromium-clear-cookies": "false",
|
||||||
|
|||||||
Reference in New Issue
Block a user