diff --git a/Makefile b/Makefile index de1c9f5e..912a04b1 100644 --- a/Makefile +++ b/Makefile @@ -58,6 +58,8 @@ LIBREOFFICE_MAX_QUEUE_SIZE=0 LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT=0 LIBREOFFICE_AUTO_START=false LIBREOFFICE_START_TIMEOUT=20s +LIBREOFFICE_ALLOW_LIST= +LIBREOFFICE_DENY_LIST= LIBREOFFICE_DISABLE_ROUTES=false LOG_LEVEL=info LOG_FIELDS_PREFIX= diff --git a/compose.yaml b/compose.yaml index 4c1698fc..edb75280 100644 --- a/compose.yaml +++ b/compose.yaml @@ -59,6 +59,8 @@ services: - "--libreoffice-idle-shutdown-timeout=${LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT}" - "--libreoffice-auto-start=${LIBREOFFICE_AUTO_START}" - "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}" + - "--libreoffice-allow-list=${LIBREOFFICE_ALLOW_LIST}" + - "--libreoffice-deny-list=${LIBREOFFICE_DENY_LIST}" - "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}" - "--log-level=${LOG_LEVEL}" - "--log-fields-prefix=${LOG_FIELDS_PREFIX}" diff --git a/pkg/gotenberg/cmd.go b/pkg/gotenberg/cmd.go index bf63368d..3a0d2f27 100644 --- a/pkg/gotenberg/cmd.go +++ b/pkg/gotenberg/cmd.go @@ -55,6 +55,13 @@ func CommandContext(ctx context.Context, logger *slog.Logger, binPath string, ar }, nil } +// SetEnv replaces the environment variables passed to the underlying +// process. When SetEnv is not called, the process inherits the parent's +// environment. +func (cmd *Cmd) SetEnv(env []string) { + cmd.process.Env = env +} + // Start starts the command but does not wait for its completion. func (cmd *Cmd) Start() error { err := cmd.pipeOutput() diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index 7d27360a..5b0f7765 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -327,6 +327,10 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { fs.Duration("libreoffice-idle-shutdown-timeout", 0, "Shutdown LibreOffice after being idle for the given duration. Set to 0 to disable this feature") fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion") fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart") + fs.StringSlice("libreoffice-allow-list", []string{}, "Set the allowed URLs for LibreOffice outbound fetches (embedded images, linked content) using regular expressions - supports multiple values") + fs.StringSlice("libreoffice-deny-list", []string{}, "Set the denied URLs for LibreOffice outbound fetches using regular expressions - supports multiple values") + fs.Bool("libreoffice-deny-private-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a non-public IP address (loopback, RFC1918, link-local, unique-local). Enable on deployments that accept untrusted documents to mitigate SSRF against internal services") + fs.Bool("libreoffice-deny-public-ips", false, "Reject LibreOffice outbound URLs whose host resolves to a public IP address. Enable on air-gapped or data-governed deployments to prevent outbound traffic from leaving a private network") return fs }(), @@ -353,6 +357,12 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { binPath: libreOfficeBinPath, unoBinPath: unoBinPath, startTimeout: flags.MustDuration("libreoffice-start-timeout"), + proxyOptions: outboundProxyOptions{ + allowList: flags.MustRegexpSlice("libreoffice-allow-list"), + denyList: flags.MustRegexpSlice("libreoffice-deny-list"), + denyPrivateIPs: flags.MustBool("libreoffice-deny-private-ips"), + denyPublicIPs: flags.MustBool("libreoffice-deny-public-ips"), + }, } // Logger. diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go index 8b945899..bf679370 100644 --- a/pkg/modules/libreoffice/api/libreoffice.go +++ b/pkg/modules/libreoffice/api/libreoffice.go @@ -24,12 +24,14 @@ type libreOfficeArguments struct { binPath string unoBinPath string startTimeout time.Duration + proxyOptions outboundProxyOptions } type libreOfficeProcess struct { socketPort int userProfileDirPath string cmd *gotenberg.Cmd + proxy *libreOfficeProxy cfgMu sync.RWMutex isStarted atomic.Bool @@ -57,7 +59,24 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error { return fmt.Errorf("get free port: %w", err) } + proxy, err := newLibreOfficeProxy(logger, p.arguments.proxyOptions) + if err != nil { + return fmt.Errorf("create LibreOffice outbound proxy: %w", err) + } + proxy.Start() + userProfileDirPath := p.fs.NewDirPath() + + // LibreOffice fetches external content (OOXML images via + // TargetMode=External, RTF INCLUDEPICTURE, ODT linked images) inside + // its own libcurl. Route those fetches through the in-process proxy + // so the chromium/webhook SSRF filters apply. + if err := writeSofficeProxyConfig(userProfileDirPath, proxy.Addr()); err != nil { + _ = proxy.Stop(context.Background()) + return fmt.Errorf("write soffice proxy config: %w", err) + } + sofficeEnv := sofficeProxyEnv(os.Environ(), proxy.Addr()) + args := []string{ "--headless", "--invisible", @@ -75,13 +94,16 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error { cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.binPath, args...) if err != nil { + _ = proxy.Stop(context.Background()) return fmt.Errorf("create LibreOffice command: %w", err) } + cmd.SetEnv(sofficeEnv) // For whatever reason, LibreOffice requires a first start before being // able to run as a daemon. exitCode, err := cmd.Exec() if err != nil && exitCode != 81 { + _ = proxy.Stop(context.Background()) return fmt.Errorf("execute LibreOffice: %w", err) } @@ -89,6 +111,7 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error { // Second start (daemon). cmd = gotenberg.Command(logger, p.arguments.binPath, args...) + cmd.SetEnv(sofficeEnv) err = cmd.Start() if err != nil { @@ -139,11 +162,18 @@ func (p *libreOfficeProcess) Start(logger *slog.Logger) error { p.socketPort = port p.userProfileDirPath = userProfileDirPath p.cmd = cmd + p.proxy = proxy p.isStarted.Store(true) return } + // LibreOffice failed to start; tear the proxy down too. + stopErr := proxy.Stop(context.Background()) + if stopErr != nil { + logger.WarnContext(context.Background(), fmt.Sprintf("stop LibreOffice outbound proxy after failed start: %s", stopErr)) + } + // Let's make sure the process is killed. err = cmd.Kill() if err != nil { @@ -212,6 +242,16 @@ func (p *libreOfficeProcess) Stop(logger *slog.Logger) error { return fmt.Errorf("kill LibreOffice process: %w", err) } + if p.proxy != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + stopErr := p.proxy.Stop(shutdownCtx) + cancel() + if stopErr != nil { + logger.WarnContext(context.Background(), fmt.Sprintf("stop LibreOffice outbound proxy: %s", stopErr)) + } + p.proxy = nil + } + p.socketPort = 0 p.userProfileDirPath = "" p.cmd = nil diff --git a/pkg/modules/libreoffice/api/proxy.go b/pkg/modules/libreoffice/api/proxy.go new file mode 100644 index 00000000..e8b369cb --- /dev/null +++ b/pkg/modules/libreoffice/api/proxy.go @@ -0,0 +1,323 @@ +package api + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/dlclark/regexp2" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" +) + +// outboundProxyOptions configures a [libreOfficeProxy]. +type outboundProxyOptions struct { + allowList []*regexp2.Regexp + denyList []*regexp2.Regexp + denyPrivateIPs bool + denyPublicIPs bool +} + +// libreOfficeProxy is an HTTP/HTTPS forward proxy that LibreOffice routes +// outbound requests through. Every proxied request goes through +// [gotenberg.DecideOutbound] so the same allow/deny lists and IP-class +// filters that protect chromium and webhook fetches also apply to +// soffice's own libcurl-driven fetches. +// +// soffice triggers an outbound request whenever a document references +// external content (OOXML images via TargetMode="External", RTF +// INCLUDEPICTURE, ODT linked images). Without a filtering proxy in the +// path those fetches bypass every Go-side SSRF guard because they +// originate inside the soffice subprocess. +type libreOfficeProxy struct { + listener net.Listener + server *http.Server + client *http.Client + opts outboundProxyOptions + logger *slog.Logger + + stopOnce sync.Once +} + +// newLibreOfficeProxy binds a proxy listener to a free local port and +// applies opts to every proxied request. Callers must call [Start] +// before pointing soffice at the proxy and [Stop] on shutdown. +func newLibreOfficeProxy(logger *slog.Logger, opts outboundProxyOptions) (*libreOfficeProxy, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("bind LibreOffice proxy listener: %w", err) + } + + decideOpts := []gotenberg.DecideOption{ + gotenberg.WithDenyPrivateIPs(opts.denyPrivateIPs), + gotenberg.WithDenyPublicIPs(opts.denyPublicIPs), + } + + p := &libreOfficeProxy{ + listener: listener, + client: gotenberg.NewOutboundHttpClient(0, opts.allowList, opts.denyList, decideOpts...), + opts: opts, + logger: logger.With(slog.String("logger", "libreoffice-proxy")), + } + p.server = &http.Server{ + Handler: p, + ReadHeaderTimeout: 10 * time.Second, + } + return p, nil +} + +// Addr returns the host:port the proxy listens on. +func (p *libreOfficeProxy) Addr() string { + return p.listener.Addr().String() +} + +// Start serves proxy requests in a background goroutine until [Stop] is +// called. +func (p *libreOfficeProxy) Start() { + go func() { + err := p.server.Serve(p.listener) + if err != nil && !errors.Is(err, http.ErrServerClosed) { + p.logger.ErrorContext(context.Background(), fmt.Sprintf("LibreOffice proxy serve: %s", err)) + } + }() +} + +// Stop gracefully shuts the proxy down. Subsequent calls are no-ops. +func (p *libreOfficeProxy) Stop(ctx context.Context) error { + var err error + p.stopOnce.Do(func() { + err = p.server.Shutdown(ctx) + }) + if err != nil { + return fmt.Errorf("shutdown LibreOffice proxy: %w", err) + } + return nil +} + +// ServeHTTP dispatches between CONNECT (HTTPS tunnels) and the absolute +// URL form (HTTP forward). +func (p *libreOfficeProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + p.handleConnect(w, r) + return + } + p.handleHttp(w, r) +} + +// handleHttp forwards a plain HTTP request whose URL line is absolute +// (RFC 7230 5.3.2) through the outbound HTTP client, which validates +// the destination and pins the dial. +func (p *libreOfficeProxy) handleHttp(w http.ResponseWriter, r *http.Request) { + if r.URL == nil || !r.URL.IsAbs() { + http.Error(w, "proxy: expected absolute URI", http.StatusBadRequest) + return + } + + outReq := r.Clone(r.Context()) + outReq.RequestURI = "" + removeHopByHopHeaders(outReq.Header) + + // gosec G704: outReq.URL is exactly what the proxy is here to filter; the + // http.Client returned by NewOutboundHttpClient validates and pins it. + resp, err := p.client.Do(outReq) //nolint:gosec + if err != nil { + p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy rejected forward to '%s': %s", r.URL.String(), err)) + http.Error(w, "proxy: destination rejected", http.StatusForbidden) + return + } + defer func() { + closeErr := resp.Body.Close() + if closeErr != nil { + p.logger.DebugContext(r.Context(), fmt.Sprintf("close upstream response body: %s", closeErr)) + } + }() + + removeHopByHopHeaders(resp.Header) + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(resp.StatusCode) + _, copyErr := io.Copy(w, resp.Body) + if copyErr != nil { + p.logger.DebugContext(r.Context(), fmt.Sprintf("copy proxied response body: %s", copyErr)) + } +} + +// handleConnect implements an HTTPS tunnel. It validates the destination +// host through [gotenberg.DecideOutbound] (synthesizing an https URL), +// dials the pinned IPs returned by the decision, and splices bytes +// between client and server. +func (p *libreOfficeProxy) handleConnect(w http.ResponseWriter, r *http.Request) { + host, port, err := net.SplitHostPort(r.Host) + if err != nil { + http.Error(w, "proxy: invalid CONNECT target", http.StatusBadRequest) + return + } + + deadline, ok := r.Context().Deadline() + if !ok { + deadline = time.Now().Add(30 * time.Second) + } + + rawURL := (&url.URL{Scheme: "https", Host: net.JoinHostPort(host, port)}).String() + + decision, err := gotenberg.DecideOutbound(r.Context(), rawURL, p.opts.allowList, p.opts.denyList, deadline, + gotenberg.WithDenyPrivateIPs(p.opts.denyPrivateIPs), + gotenberg.WithDenyPublicIPs(p.opts.denyPublicIPs), + ) + if err != nil { + p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy rejected CONNECT to '%s': %s", rawURL, err)) + http.Error(w, "proxy: destination rejected", http.StatusForbidden) + return + } + + var dest net.Conn + switch { + case len(decision.Pinned) > 0: + dest, err = gotenberg.DialPinned(r.Context(), "tcp", decision.Pinned, port) + default: + // Bypass (allow-list match) or non-http-like scheme: dial directly. + // gosec G704: host:port has cleared DecideOutbound above. + dest, err = net.DialTimeout("tcp", net.JoinHostPort(host, port), 10*time.Second) //nolint:gosec + } + if err != nil { + p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy CONNECT dial to '%s' failed: %s", rawURL, err)) + http.Error(w, "proxy: dial failed", http.StatusBadGateway) + return + } + + hijacker, ok := w.(http.Hijacker) + if !ok { + _ = dest.Close() + http.Error(w, "proxy: hijack unsupported", http.StatusInternalServerError) + return + } + client, _, err := hijacker.Hijack() + if err != nil { + _ = dest.Close() + p.logger.WarnContext(r.Context(), fmt.Sprintf("LibreOffice proxy hijack failed: %s", err)) + return + } + + _, writeErr := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + if writeErr != nil { + _ = client.Close() + _ = dest.Close() + return + } + + go pipeAndClose(client, dest) + go pipeAndClose(dest, client) +} + +// pipeAndClose copies bytes from src to dst and closes both ends when +// the copy finishes. +func pipeAndClose(dst, src net.Conn) { + defer func() { + _ = dst.Close() + _ = src.Close() + }() + _, _ = io.Copy(dst, src) +} + +// hopByHopHeaders is the set of hop-by-hop headers from RFC 7230 6.1 +// plus the ones soffice adds when acting as a forward-proxy client. +var hopByHopHeaders = []string{ + "Connection", + "Proxy-Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "Te", + "Trailer", + "Transfer-Encoding", + "Upgrade", +} + +// sofficeProxyConfigTmpl is the registrymodifications.xcu fragment that +// tells soffice's UCB layer to route every HTTP and HTTPS fetch through +// proxyHost:proxyPort. The %s placeholders accept the proxy host and +// port respectively (host first, port second, repeated for HTTP and +// HTTPS). +const sofficeProxyConfigTmpl = ` + + 1 + %s + %s + %s + %s + + +` + +// writeSofficeProxyConfig drops a registrymodifications.xcu file into +// userProfileDirPath/user/ that points soffice's UCB layer at proxyAddr +// for both HTTP and HTTPS. proxyAddr must be a host:port pair. +func writeSofficeProxyConfig(userProfileDirPath, proxyAddr string) error { + host, port, err := net.SplitHostPort(proxyAddr) + if err != nil { + return fmt.Errorf("split proxy address %q: %w", proxyAddr, err) + } + + userDir := userProfileDirPath + "/user" + err = os.MkdirAll(userDir, 0o755) + if err != nil { + return fmt.Errorf("create soffice user profile directory: %w", err) + } + + body := fmt.Sprintf(sofficeProxyConfigTmpl, host, port, host, port) + err = os.WriteFile(userDir+"/registrymodifications.xcu", []byte(body), 0o600) + if err != nil { + return fmt.Errorf("write registrymodifications.xcu: %w", err) + } + + return nil +} + +// sofficeProxyEnv overlays http_proxy/https_proxy on env so soffice's +// libcurl path also routes through proxyAddr. The environment variables +// supplement the registrymodifications.xcu config so coverage stays +// intact if soffice upgrades and one of the two paths regresses. +func sofficeProxyEnv(env []string, proxyAddr string) []string { + proxyURL := "http://" + proxyAddr + + filtered := env[:0:0] + for _, kv := range env { + switch strings.ToLower(strings.SplitN(kv, "=", 2)[0]) { + case "http_proxy", "https_proxy", "no_proxy": + continue + } + filtered = append(filtered, kv) + } + + return append(filtered, + "http_proxy="+proxyURL, + "https_proxy="+proxyURL, + "HTTP_PROXY="+proxyURL, + "HTTPS_PROXY="+proxyURL, + "no_proxy=", + "NO_PROXY=", + ) +} + +func removeHopByHopHeaders(h http.Header) { + if connection := h.Get("Connection"); connection != "" { + for name := range strings.SplitSeq(connection, ",") { + h.Del(strings.TrimSpace(name)) + } + } + for _, name := range hopByHopHeaders { + h.Del(name) + } +} diff --git a/pkg/modules/libreoffice/api/proxy_test.go b/pkg/modules/libreoffice/api/proxy_test.go new file mode 100644 index 00000000..18773e39 --- /dev/null +++ b/pkg/modules/libreoffice/api/proxy_test.go @@ -0,0 +1,369 @@ +package api + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/dlclark/regexp2" +) + +func compileRegexes(t *testing.T, patterns ...string) []*regexp2.Regexp { + t.Helper() + out := make([]*regexp2.Regexp, 0, len(patterns)) + for _, p := range patterns { + r, err := regexp2.Compile(p, 0) + if err != nil { + t.Fatalf("compile %q: %v", p, err) + } + out = append(out, r) + } + return out +} + +func startProxy(t *testing.T, opts outboundProxyOptions) *libreOfficeProxy { + t.Helper() + p, err := newLibreOfficeProxy(slog.New(slog.DiscardHandler), opts) + if err != nil { + t.Fatalf("new proxy: %v", err) + } + p.Start() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = p.Stop(ctx) + }) + return p +} + +func TestLibreOfficeProxy_HttpForwardAllowed(t *testing.T) { + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte("hello")) + })) + defer origin.Close() + + p := startProxy(t, outboundProxyOptions{}) + + proxyURL, _ := url.Parse("http://" + p.Addr()) + client := &http.Client{ + Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}, + Timeout: 5 * time.Second, + } + + resp, err := client.Get(origin.URL + "/foo") + if err != nil { + t.Fatalf("client.Get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusTeapot { + t.Fatalf("status: got %d, want %d", resp.StatusCode, http.StatusTeapot) + } + body, _ := io.ReadAll(resp.Body) + if string(body) != "hello" { + t.Fatalf("body: got %q, want %q", body, "hello") + } +} + +func TestLibreOfficeProxy_HttpForwardDenyListRejects(t *testing.T) { + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("origin must not be reached") + })) + defer origin.Close() + + p := startProxy(t, outboundProxyOptions{ + denyList: compileRegexes(t, `.*`), + }) + + proxyURL, _ := url.Parse("http://" + p.Addr()) + client := &http.Client{ + Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}, + Timeout: 5 * time.Second, + } + + resp, err := client.Get(origin.URL + "/foo") + if err != nil { + t.Fatalf("client.Get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status: got %d, want %d", resp.StatusCode, http.StatusForbidden) + } +} + +func TestLibreOfficeProxy_HttpForwardDenyPrivateIPsRejects(t *testing.T) { + // httptest binds on 127.0.0.1 (a private IP), so denyPrivateIPs + // must reject the forward. + origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("origin must not be reached") + })) + defer origin.Close() + + p := startProxy(t, outboundProxyOptions{denyPrivateIPs: true}) + + proxyURL, _ := url.Parse("http://" + p.Addr()) + client := &http.Client{ + Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}, + Timeout: 5 * time.Second, + } + + resp, err := client.Get(origin.URL + "/foo") + if err != nil { + t.Fatalf("client.Get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("status: got %d, want %d", resp.StatusCode, http.StatusForbidden) + } +} + +func TestLibreOfficeProxy_ConnectTunnelHappyPath(t *testing.T) { + // Bring up a tiny TCP echo server. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen echo: %v", err) + } + defer listener.Close() + + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer conn.Close() + _, _ = io.Copy(conn, conn) + }() + + p := startProxy(t, outboundProxyOptions{}) + + conn, err := net.DialTimeout("tcp", p.Addr(), 2*time.Second) + if err != nil { + t.Fatalf("dial proxy: %v", err) + } + defer conn.Close() + + target := listener.Addr().String() + _, err = fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target) + if err != nil { + t.Fatalf("write CONNECT: %v", err) + } + + reader := bufio.NewReader(conn) + statusLine, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read CONNECT response: %v", err) + } + if !strings.Contains(statusLine, "200") { + t.Fatalf("CONNECT status: got %q, want 200", statusLine) + } + // Drain remaining headers. + for { + line, readErr := reader.ReadString('\n') + if readErr != nil { + t.Fatalf("read CONNECT headers: %v", readErr) + } + if line == "\r\n" || line == "\n" { + break + } + } + + // Tunnel established. Round-trip a payload through the echo server. + want := "ping" + _, err = conn.Write([]byte(want)) + if err != nil { + t.Fatalf("write payload: %v", err) + } + + got := make([]byte, len(want)) + _, err = io.ReadFull(reader, got) + if err != nil { + t.Fatalf("read echo: %v", err) + } + if string(got) != want { + t.Fatalf("echo: got %q, want %q", got, want) + } +} + +func TestLibreOfficeProxy_ConnectDenyListRejects(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + + p := startProxy(t, outboundProxyOptions{denyList: compileRegexes(t, `.*`)}) + + conn, err := net.DialTimeout("tcp", p.Addr(), 2*time.Second) + if err != nil { + t.Fatalf("dial proxy: %v", err) + } + defer conn.Close() + + target := listener.Addr().String() + _, err = fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target) + if err != nil { + t.Fatalf("write CONNECT: %v", err) + } + + reader := bufio.NewReader(conn) + statusLine, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read response: %v", err) + } + if !strings.Contains(statusLine, "403") { + t.Fatalf("CONNECT status: got %q, want 403", statusLine) + } +} + +func TestLibreOfficeProxy_ConnectDenyPrivateIPsRejects(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + + p := startProxy(t, outboundProxyOptions{denyPrivateIPs: true}) + + conn, err := net.DialTimeout("tcp", p.Addr(), 2*time.Second) + if err != nil { + t.Fatalf("dial proxy: %v", err) + } + defer conn.Close() + + // 127.0.0.1 is a private IP under denyPrivateIPs. + target := listener.Addr().String() + _, err = fmt.Fprintf(conn, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target) + if err != nil { + t.Fatalf("write CONNECT: %v", err) + } + + reader := bufio.NewReader(conn) + statusLine, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read response: %v", err) + } + if !strings.Contains(statusLine, "403") { + t.Fatalf("CONNECT status: got %q, want 403", statusLine) + } +} + +func TestLibreOfficeProxy_StopIsIdempotent(t *testing.T) { + p, err := newLibreOfficeProxy(slog.New(slog.DiscardHandler), outboundProxyOptions{}) + if err != nil { + t.Fatalf("new proxy: %v", err) + } + p.Start() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + if err := p.Stop(ctx); err != nil { + t.Fatalf("first Stop: %v", err) + } + if err := p.Stop(ctx); err != nil { + t.Fatalf("second Stop: %v", err) + } +} + +func TestWriteSofficeProxyConfig(t *testing.T) { + dir := t.TempDir() + + if err := writeSofficeProxyConfig(dir, "127.0.0.1:9876"); err != nil { + t.Fatalf("writeSofficeProxyConfig: %v", err) + } + + body, err := os.ReadFile(filepath.Join(dir, "user", "registrymodifications.xcu")) + if err != nil { + t.Fatalf("read xcu: %v", err) + } + + for _, want := range []string{ + `ooInetProxyType`, `1`, + `ooInetHTTPProxyName`, `127.0.0.1`, + `ooInetHTTPProxyPort`, `9876`, + `ooInetHTTPSProxyName`, `ooInetHTTPSProxyPort`, + } { + if !strings.Contains(string(body), want) { + t.Errorf("xcu missing %q\nfull body:\n%s", want, body) + } + } +} + +func TestWriteSofficeProxyConfig_InvalidAddr(t *testing.T) { + err := writeSofficeProxyConfig(t.TempDir(), "not-a-host-port") + if err == nil { + t.Fatal("expected error for malformed proxy address") + } + if !errors.Is(err, errors.Unwrap(err)) { + // Only checking that an error was returned; underlying error type is + // implementation detail. + _ = err + } +} + +func TestSofficeProxyEnv_OverridesExisting(t *testing.T) { + in := []string{ + "PATH=/usr/bin", + "http_proxy=http://attacker:1", + "HTTPS_PROXY=http://attacker:1", + "NO_PROXY=internal", + "USER=gotenberg", + } + out := sofficeProxyEnv(in, "127.0.0.1:9876") + + want := map[string]string{ + "http_proxy": "http://127.0.0.1:9876", + "https_proxy": "http://127.0.0.1:9876", + "HTTP_PROXY": "http://127.0.0.1:9876", + "HTTPS_PROXY": "http://127.0.0.1:9876", + "no_proxy": "", + "NO_PROXY": "", + } + + got := map[string]string{} + for _, kv := range out { + parts := strings.SplitN(kv, "=", 2) + got[parts[0]] = parts[1] + } + + for key, value := range want { + if got[key] != value { + t.Errorf("env[%s]: got %q, want %q", key, got[key], value) + } + } + + // Pre-existing unrelated keys must survive. + if got["PATH"] != "/usr/bin" { + t.Errorf("env[PATH]: got %q, want /usr/bin", got["PATH"]) + } + if got["USER"] != "gotenberg" { + t.Errorf("env[USER]: got %q, want gotenberg", got["USER"]) + } + + // Old proxy values must be gone, not duplicated. Count exact-case keys. + counts := map[string]int{} + for _, kv := range out { + key := strings.SplitN(kv, "=", 2)[0] + counts[key]++ + } + for _, key := range []string{"http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY", "no_proxy", "NO_PROXY"} { + if counts[key] != 1 { + t.Errorf("env[%s] count: got %d, want 1", key, counts[key]) + } + } +}