diff --git a/pkg/gotenberg/outbound.go b/pkg/gotenberg/outbound.go index 7d9707e9..01329306 100644 --- a/pkg/gotenberg/outbound.go +++ b/pkg/gotenberg/outbound.go @@ -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 // and prevent DNS rebinding between this validation and the connect. 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 == "" { return nil, errors.New("empty host") } if addr, err := netip.ParseAddr(host); err == nil { - if !IsPublicIP(addr) { + if !allowPrivate && !IsPublicIP(addr) { return nil, fmt.Errorf("%q: %w", addr, ErrNonPublicIP) } return []netip.Addr{addr}, nil @@ -84,34 +93,62 @@ func ResolveAndCheckPublic(ctx context.Context, host string) ([]netip.Addr, erro if len(addrs) == 0 { return nil, fmt.Errorf("resolve %q: no addresses returned", host) } - for _, a := range addrs { - if !IsPublicIP(a) { - return nil, fmt.Errorf("%q resolves to non-public address %q: %w", host, a, ErrNonPublicIP) + if !allowPrivate { + for _, a := range addrs { + if !IsPublicIP(a) { + return nil, fmt.Errorf("%q resolves to non-public address %q: %w", host, a, ErrNonPublicIP) + } } } return addrs, nil } -// outboundDecision is the result of validating an outbound URL. It is -// stashed in the request context by [outboundRoundTripper] so that -// [secureDialContext] can either bypass the IP check (allow-list match) or -// pin the dial to the IPs that were resolved at validation time. -type outboundDecision struct { - // bypass is true when an allow-list pattern matched the URL. In that - // case the operator has explicitly opted into the destination and the - // dial should proceed without an IP check. - bypass bool +// 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 by [ResolveAndCheckPublic] for the URL - // host. The dial should be pinned to one of these to prevent DNS - // rebinding between validation and connect. - pinned []netip.Addr + // Pinned holds the IPs resolved by [ResolveAndCheckPublic] 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] +// outboundDecisionKey is the context key under which an [OutboundDecision] // is stored. 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. // Only these schemes go through the IP-based public-address check; data, // blob, file, and other schemes are filtered by the regex layer alone. @@ -123,14 +160,40 @@ func httpLikeScheme(scheme string) bool { 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 // rejects any non-public address. It returns the resulting -// [outboundDecision] which the caller can stash in a context for the dial. -func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*regexp2.Regexp, deadline time.Time) (outboundDecision, error) { +// [OutboundDecision] so the caller can pin the dial to the IPs that were +// 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) 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.Host = strings.ToLower(parsed.Host) @@ -145,9 +208,9 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r ok, err := clone.MatchString(normalized) if err != nil { 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 { @@ -157,7 +220,7 @@ func decideOutbound(ctx context.Context, rawURL string, allowList, denyList []*r } 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) if err != nil { 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 { - 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 { - return outboundDecision{bypass: true}, nil + return OutboundDecision{Bypass: true}, nil } if !httpLikeScheme(parsed.Scheme) { - return outboundDecision{}, nil + return OutboundDecision{}, nil } host := parsed.Hostname() 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 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 @@ -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 // into specific internal destinations via --*-allow-list flags. 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) error { - _, err := decideOutbound(ctx, rawURL, allowList, denyList, deadline) +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 +// 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 @@ -248,7 +311,7 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er 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 { 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 // match), it dials directly. When the decision contains pinned IPs, it // 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) } - if decision, ok := ctx.Value(outboundDecisionKey{}).(outboundDecision); ok { - if decision.bypass { + 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) + if len(decision.Pinned) > 0 { + 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 { 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 -// first successful connection or the last error. -func dialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error) { +// 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 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 for _, a := range addrs { conn, err := outboundDialer.DialContext(ctx, network, net.JoinHostPort(a.String(), port)) diff --git a/pkg/gotenberg/outbound_test.go b/pkg/gotenberg/outbound_test.go index efc24849..8252835a 100644 --- a/pkg/gotenberg/outbound_test.go +++ b/pkg/gotenberg/outbound_test.go @@ -306,3 +306,27 @@ func TestResolveAndCheckPublic_HostResolvesToPublic(t *testing.T) { 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) + } +} diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index 93d46ac0..ccbf9597 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -44,6 +44,7 @@ type browserArguments struct { // Tasks specific. allowList []*regexp2.Regexp denyList []*regexp2.Regexp + allowPrivateIPs bool clearCache bool clearCookies bool disableJavaScript bool @@ -57,15 +58,17 @@ type chromiumBrowser struct { ctxMu sync.RWMutex isStarted atomic.Bool - arguments browserArguments - fs *gotenberg.FileSystem + arguments browserArguments + fs *gotenberg.FileSystem + pinningProxy *pinningProxy } func newChromiumBrowser(arguments browserArguments) browser { b := &chromiumBrowser{ - initialCtx: context.Background(), - arguments: arguments, - fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), + initialCtx: context.Background(), + arguments: arguments, + fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), + pinningProxy: newPinningProxy(arguments.allowList, arguments.denyList, arguments.allowPrivateIPs), } b.isStarted.Store(false) @@ -136,6 +139,25 @@ func (b *chromiumBrowser) Start(logger *slog.Logger) error { 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. opts = append(opts, chromedp.WSURLReadTimeout(b.arguments.wsUrlReadTimeout)) @@ -236,6 +258,15 @@ func (b *chromiumBrowser) Stop(logger *slog.Logger) error { b.userProfileDirPath = "" 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 } @@ -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 // 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 { 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{ allowList: b.arguments.allowList, denyList: b.arguments.denyList, + allowPrivateIPs: b.arguments.allowPrivateIPs, allowedFilePrefixes: options.AllowedFilePrefixes, extraHttpHeaders: options.ExtraHttpHeaders, }) diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 95f1157c..1573613b 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -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.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values") fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values") + fs.Bool("chromium-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-cookies", false, "Clear Chromium cookies between each conversion") 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"), denyList: flags.MustRegexpSlice("chromium-deny-list"), + allowPrivateIPs: flags.MustBool("chromium-allow-private-ips"), clearCache: flags.MustBool("chromium-clear-cache"), clearCookies: flags.MustBool("chromium-clear-cookies"), disableJavaScript: flags.MustBool("chromium-disable-javascript"), diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go index 356e5af7..4854e9d1 100644 --- a/pkg/modules/chromium/events.go +++ b/pkg/modules/chromium/events.go @@ -25,6 +25,7 @@ import ( type eventRequestPausedOptions struct { allowList, denyList []*regexp2.Regexp + allowPrivateIPs bool allowedFilePrefixes []string extraHttpHeaders []ExtraHttpHeader } @@ -52,7 +53,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio 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 { logger.WarnContext(ctx, err.Error()) allow = false diff --git a/pkg/modules/chromium/pinning_proxy.go b/pkg/modules/chromium/pinning_proxy.go new file mode 100644 index 00000000..6bc92066 --- /dev/null +++ b/pkg/modules/chromium/pinning_proxy.go @@ -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) + } + } +} diff --git a/pkg/modules/chromium/pinning_proxy_test.go b/pkg/modules/chromium/pinning_proxy_test.go new file mode 100644 index 00000000..7371e0ea --- /dev/null +++ b/pkg/modules/chromium/pinning_proxy_test.go @@ -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) + } +} diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index 3a035684..a6cf0731 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -7,6 +7,7 @@ import ( "fmt" "html/template" "net/http" + "net/url" "os" "path/filepath" "strconv" @@ -402,6 +403,34 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens 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. func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) 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) } + err = rejectFileScheme(url) + if err != nil { + return fmt.Errorf("reject URL scheme: %w", err) + } + if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" { watermark.Expression = watermarkFile } @@ -467,6 +501,11 @@ func screenshotUrlRoute(chromium Api) api.Route { 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) if err != nil { return fmt.Errorf("URL screenshot: %w", err) diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature index a4149849..c6540b95 100644 --- a/test/integration/features/chromium_convert_url.feature +++ b/test/integration/features/chromium_convert_url.feature @@ -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. | '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) Given I have a default Gotenberg container Given I have a static server diff --git a/test/integration/features/chromium_screenshot_url.feature b/test/integration/features/chromium_screenshot_url.feature index 998e9f59..7399195f 100644 --- a/test/integration/features/chromium_screenshot_url.feature +++ b/test/integration/features/chromium_screenshot_url.feature @@ -44,6 +44,17 @@ Feature: /forms/chromium/screenshot/url Then the response status code should be 400 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 Scenario: POST /forms/chromium/screenshot/url (Webhook) Given I have a default Gotenberg container diff --git a/test/integration/features/debug.feature b/test/integration/features/debug.feature index 92b030e5..f6edca33 100644 --- a/test/integration/features/debug.feature +++ b/test/integration/features/debug.feature @@ -78,6 +78,7 @@ Feature: /debug "chromium-allow-file-access-from-files": "false", "chromium-allow-insecure-localhost": "false", "chromium-allow-list": "[.+]", + "chromium-allow-private-ips": "false", "chromium-auto-start": "false", "chromium-clear-cache": "false", "chromium-clear-cookies": "false", @@ -210,6 +211,7 @@ Feature: /debug "chromium-allow-file-access-from-files": "false", "chromium-allow-insecure-localhost": "false", "chromium-allow-list": "[.+]", + "chromium-allow-private-ips": "false", "chromium-auto-start": "false", "chromium-clear-cache": "false", "chromium-clear-cookies": "false",