mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
feat(libreoffice): SSRF guard for embedded external content
This commit is contained in:
2
Makefile
2
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=
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
323
pkg/modules/libreoffice/api/proxy.go
Normal file
323
pkg/modules/libreoffice/api/proxy.go
Normal file
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<oor:items xmlns:oor="http://openoffice.org/2001/registry" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetProxyType" oor:op="fuse"><value>1</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyName" oor:op="fuse"><value>%s</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPProxyPort" oor:op="fuse"><value>%s</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPSProxyName" oor:op="fuse"><value>%s</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetHTTPSProxyPort" oor:op="fuse"><value>%s</value></prop></item>
|
||||
<item oor:path="/org.openoffice.Inet/Settings"><prop oor:name="ooInetNoProxy" oor:op="fuse"><value></value></prop></item>
|
||||
</oor:items>
|
||||
`
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
369
pkg/modules/libreoffice/api/proxy_test.go
Normal file
369
pkg/modules/libreoffice/api/proxy_test.go
Normal file
@@ -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`, `<value>1</value>`,
|
||||
`ooInetHTTPProxyName`, `<value>127.0.0.1</value>`,
|
||||
`ooInetHTTPProxyPort`, `<value>9876</value>`,
|
||||
`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])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user