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:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
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"
|
||||
"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)
|
||||
|
||||
Reference in New Issue
Block a user