mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 00:22:14 +01:00
Compare commits
9 Commits
8b559eb699
...
9ec7be4687
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ec7be4687 | ||
|
|
de7f335791 | ||
|
|
815f586315 | ||
|
|
b71df026f6 | ||
|
|
8d29638b74 | ||
|
|
63c9a36599 | ||
|
|
31fa392db2 | ||
|
|
bb0b874d16 | ||
|
|
60f5a7b996 |
@@ -121,20 +121,28 @@ Enforced by `gci`: standard library, then third-party, then `github.com/gotenber
|
|||||||
Every exported type and function has a Godoc comment starting with its identifier name:
|
Every exported type and function has a Godoc comment starting with its identifier name:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Violation records a single rule violation with context.
|
// OutboundDecision is the result of validating an outbound URL via
|
||||||
type Violation struct { ... }
|
// [DecideOutbound]. ...
|
||||||
|
type OutboundDecision struct { ... }
|
||||||
|
|
||||||
// ValidatePDFA audits the document against a PDF/A profile.
|
// DialPinned dials each addr in turn until one connects, returning the
|
||||||
func ValidatePDFA(ctx context.Context, ...) ([]error, error)
|
// first successful connection or the last error. ...
|
||||||
|
func DialPinned(ctx context.Context, network string, addrs []netip.Addr, port string) (net.Conn, error)
|
||||||
```
|
```
|
||||||
|
|
||||||
Each package should have a `doc.go` with a `// Package foo ...` comment.
|
Each package should have a `doc.go` with a `// Package foo ...` comment:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Package api manages a LibreOffice instance via the UNO API.
|
||||||
|
package api
|
||||||
|
```
|
||||||
|
|
||||||
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
|
Reference identifiers with `[Name]` brackets for pkg.go.dev linking:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// ValidatePDFA returns violations as []error where each element
|
// Callers pass the Pinned slice from [OutboundDecision] so that the dial
|
||||||
// is a [Violation] value. See [Rule] for the structured fields.
|
// targets exactly the IPs that [DecideOutbound] resolved, preventing DNS
|
||||||
|
// rebinding between validation and connect.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Code comments
|
### Code comments
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ RUN apt-get update -qq \
|
|||||||
|
|
||||||
WORKDIR /downloads
|
WORKDIR /downloads
|
||||||
|
|
||||||
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.3.0/unoconv -o unoconverter \
|
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.4.0/unoconv -o unoconverter \
|
||||||
&& chmod +x unoconverter
|
&& chmod +x unoconverter
|
||||||
|
|
||||||
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" \
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module github.com/gotenberg/gotenberg/v8
|
module github.com/gotenberg/gotenberg/v8
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alexliesenfeld/health v0.8.1
|
github.com/alexliesenfeld/health v0.8.1
|
||||||
|
|||||||
@@ -17,13 +17,18 @@ import (
|
|||||||
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||||
"go.opentelemetry.io/otel/sdk/resource"
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
"go.opentelemetry.io/otel/sdk/trace"
|
"go.opentelemetry.io/otel/sdk/trace"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildResource assembles the OpenTelemetry resource shared by the tracer,
|
// buildResource assembles the OpenTelemetry resource shared by the tracer,
|
||||||
// meter, and logger providers. Detection is best-effort: a detector or merge
|
// meter, and logger providers. Detection is best-effort: a detector or merge
|
||||||
// failure is logged and the build proceeds with whatever was gathered, so a
|
// failure is logged and the build proceeds with whatever was gathered, so a
|
||||||
// flaky environment never prevents telemetry from starting.
|
// flaky environment never prevents telemetry from starting.
|
||||||
|
//
|
||||||
|
// The semconv version imported here must match the one the SDK resource
|
||||||
|
// detectors use (go.opentelemetry.io/otel/sdk/resource). Drift makes
|
||||||
|
// [resource.Merge] fail with [resource.ErrSchemaURLConflict] and strips the
|
||||||
|
// schema URL off every exported signal.
|
||||||
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
|
func buildResource(ctx context.Context, logger *slog.Logger, serviceName, serviceVersion string) *resource.Resource {
|
||||||
base := resource.NewWithAttributes(
|
base := resource.NewWithAttributes(
|
||||||
semconv.SchemaURL,
|
semconv.SchemaURL,
|
||||||
@@ -55,9 +60,14 @@ func buildResource(ctx context.Context, logger *slog.Logger, serviceName, servic
|
|||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A schema URL conflict still yields a resource holding every attribute, only
|
||||||
|
// without a schema URL. Keep it: falling back to base would drop the host,
|
||||||
|
// OS, container, process, and OTEL_RESOURCE_ATTRIBUTES data.
|
||||||
merged, err := resource.Merge(detected, base)
|
merged, err := resource.Merge(detected, base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
|
logger.WarnContext(ctx, fmt.Sprintf("merge OpenTelemetry resource: %s", err))
|
||||||
|
}
|
||||||
|
if merged == nil {
|
||||||
return base
|
return base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
"go.opentelemetry.io/otel/sdk/metric/exemplar"
|
||||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
semconv "go.opentelemetry.io/otel/semconv/v1.41.0"
|
semconv "go.opentelemetry.io/otel/semconv/v1.43.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildResource(t *testing.T) {
|
func TestBuildResource(t *testing.T) {
|
||||||
@@ -24,6 +24,13 @@ func TestBuildResource(t *testing.T) {
|
|||||||
values[string(kv.Key)] = kv.Value.AsString()
|
values[string(kv.Key)] = kv.Value.AsString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Guards the semconv version pinned in buildResource against the one the SDK
|
||||||
|
// resource detectors use. Drift makes resource.Merge conflict and drops the
|
||||||
|
// schema URL from every exported signal.
|
||||||
|
if res.SchemaURL() != semconv.SchemaURL {
|
||||||
|
t.Errorf("resource schema URL = %q, want %q", res.SchemaURL(), semconv.SchemaURL)
|
||||||
|
}
|
||||||
|
|
||||||
if values[string(semconv.ServiceNameKey)] != "gotenberg" {
|
if values[string(semconv.ServiceNameKey)] != "gotenberg" {
|
||||||
t.Errorf("service.name = %q, want %q", values[string(semconv.ServiceNameKey)], "gotenberg")
|
t.Errorf("service.name = %q, want %q", values[string(semconv.ServiceNameKey)], "gotenberg")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -187,6 +188,12 @@ type OutboundDecision struct {
|
|||||||
// is stored.
|
// is stored.
|
||||||
type outboundDecisionKey struct{}
|
type outboundDecisionKey struct{}
|
||||||
|
|
||||||
|
// outboundProxiedKey is the context key under which [outboundRoundTripper]
|
||||||
|
// records that the environment proxy will carry this request, so that the
|
||||||
|
// dialer knows the address it receives is the proxy's rather than the
|
||||||
|
// destination's.
|
||||||
|
type outboundProxiedKey struct{}
|
||||||
|
|
||||||
// decideConfig carries optional settings for [DecideOutbound] and
|
// decideConfig carries optional settings for [DecideOutbound] and
|
||||||
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
// [FilterOutboundURL]. See [DecideOption] for how callers configure it.
|
||||||
type decideConfig struct {
|
type decideConfig struct {
|
||||||
@@ -364,6 +371,10 @@ type outboundRoundTripper struct {
|
|||||||
allowList []*regexp2.Regexp
|
allowList []*regexp2.Regexp
|
||||||
denyList []*regexp2.Regexp
|
denyList []*regexp2.Regexp
|
||||||
opts []DecideOption
|
opts []DecideOption
|
||||||
|
|
||||||
|
// proxyFunc mirrors the transport's own proxy resolution. It is nil unless
|
||||||
|
// the environment proxy is enabled.
|
||||||
|
proxyFunc func(*url.URL) (*url.URL, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoundTrip validates req.URL and delegates to the base transport.
|
// RoundTrip validates req.URL and delegates to the base transport.
|
||||||
@@ -379,6 +390,18 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
|
ctx := context.WithValue(req.Context(), outboundDecisionKey{}, decision)
|
||||||
|
|
||||||
|
// A request the proxy will not carry is dialed directly, so it still gets
|
||||||
|
// pinned. Without this, enabling the environment proxy would silently drop
|
||||||
|
// DNS-rebinding protection for every NO_PROXY host, and for all traffic
|
||||||
|
// when no proxy variable is set at all.
|
||||||
|
if rt.proxyFunc != nil {
|
||||||
|
proxyURL, proxyErr := rt.proxyFunc(req.URL)
|
||||||
|
if proxyErr == nil && proxyURL != nil {
|
||||||
|
ctx = context.WithValue(ctx, outboundProxiedKey{}, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return rt.base.RoundTrip(req.WithContext(ctx))
|
return rt.base.RoundTrip(req.WithContext(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,24 +419,34 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
|||||||
//
|
//
|
||||||
// When enableEnvironmentProxy is true, the client routes through the proxy
|
// When enableEnvironmentProxy is true, the client routes through the proxy
|
||||||
// defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables,
|
// defined by the standard HTTP_PROXY, HTTPS_PROXY, and NO_PROXY variables,
|
||||||
// including any credentials embedded in those URLs. In that mode the proxy
|
// including any credentials embedded in those URLs. Dial pinning does not apply
|
||||||
// owns DNS and egress, so destination dial pinning does not apply; the URL
|
// to a hop the proxy carries, since the proxy owns DNS and egress there; a hop
|
||||||
// allow/deny and IP-class validation still runs. Callers gate this behind
|
// the proxy declines, such as a NO_PROXY host, is dialed directly and stays
|
||||||
// their module's opt-in flag. See
|
// pinned. The URL allow/deny and IP-class validation runs either way. Callers
|
||||||
|
// gate this behind their module's opt-in flag. See
|
||||||
// https://github.com/gotenberg/gotenberg/issues/1592.
|
// https://github.com/gotenberg/gotenberg/issues/1592.
|
||||||
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, enableEnvironmentProxy bool, opts ...DecideOption) *http.Client {
|
func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2.Regexp, enableEnvironmentProxy bool, opts ...DecideOption) *http.Client {
|
||||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
|
||||||
|
var proxyFunc func(*url.URL) (*url.URL, error)
|
||||||
|
|
||||||
if enableEnvironmentProxy {
|
if enableEnvironmentProxy {
|
||||||
// Route through the operator's proxy (standard env vars, credentials
|
// Route through the operator's proxy (standard env vars, credentials
|
||||||
// included). NO_PROXY hosts get a direct, unpinned dial.
|
// included). httpproxy.FromEnvironment reads the environment now rather
|
||||||
// httpproxy.FromEnvironment reads the environment now rather than
|
// than caching it process-wide like http.ProxyFromEnvironment.
|
||||||
// caching it process-wide like http.ProxyFromEnvironment.
|
proxyFunc = httpproxy.FromEnvironment().ProxyFunc()
|
||||||
proxyFunc := httpproxy.FromEnvironment().ProxyFunc()
|
|
||||||
base.Proxy = func(req *http.Request) (*url.URL, error) {
|
base.Proxy = func(req *http.Request) (*url.URL, error) {
|
||||||
return proxyFunc(req.URL)
|
return proxyFunc(req.URL)
|
||||||
}
|
}
|
||||||
base.DialContext = outboundDialer.DialContext
|
// Only a hop the proxy actually carries skips pinning: there the dial
|
||||||
|
// targets the proxy, not the destination, and the proxy owns DNS. A hop
|
||||||
|
// the proxy declines is dialed directly and stays pinned.
|
||||||
|
base.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||||
|
if proxied, _ := ctx.Value(outboundProxiedKey{}).(bool); proxied {
|
||||||
|
return outboundDialer.DialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
|
return secureDialContext(ctx, network, addr)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Default: ignore any proxy environment variables and pin the dial to
|
// Default: ignore any proxy environment variables and pin the dial to
|
||||||
// the IPs resolved during validation, closing the DNS-rebinding
|
// the IPs resolved during validation, closing the DNS-rebinding
|
||||||
@@ -431,10 +464,60 @@ func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2
|
|||||||
allowList: allowList,
|
allowList: allowList,
|
||||||
denyList: denyList,
|
denyList: denyList,
|
||||||
opts: opts,
|
opts: opts,
|
||||||
|
proxyFunc: proxyFunc,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// environmentProxyVariables are the variables golang.org/x/net/http/httpproxy
|
||||||
|
// reads, in the casing precedence it applies.
|
||||||
|
var environmentProxyVariables = []string{
|
||||||
|
"HTTP_PROXY", "http_proxy",
|
||||||
|
"HTTPS_PROXY", "https_proxy",
|
||||||
|
"ALL_PROXY", "all_proxy",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateEnvironmentProxyVariables checks that every proxy variable currently
|
||||||
|
// set can be parsed as a proxy URL.
|
||||||
|
//
|
||||||
|
// httpproxy discards a parse error and falls back to a direct connection, so an
|
||||||
|
// operator who mistypes a proxy URL would silently lose the egress path they
|
||||||
|
// meant to enforce. Modules exposing an environment proxy flag call this from
|
||||||
|
// their Validate so that startup fails loudly instead.
|
||||||
|
//
|
||||||
|
// Values are never included in the error: a proxy URL may carry credentials.
|
||||||
|
func ValidateEnvironmentProxyVariables() error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
if os.Getenv(name) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isUsableProxyURL(os.Getenv(name)) {
|
||||||
|
err = errors.Join(err, fmt.Errorf("environment variable %s is not a usable proxy URL; unset it, or set it to a value like 'http://user:password@host:3128'", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// isUsableProxyURL mirrors httpproxy's own parsing: a URL with a proxy scheme,
|
||||||
|
// or anything that becomes one once a scheme is prefixed.
|
||||||
|
func isUsableProxyURL(value string) bool {
|
||||||
|
proxyURL, err := url.Parse(value)
|
||||||
|
if err == nil {
|
||||||
|
switch proxyURL.Scheme {
|
||||||
|
case "http", "https", "socks5", "socks5h":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpproxy retries bare values such as "host:3128" with a scheme.
|
||||||
|
_, err = url.Parse("http://" + value)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
|
// secureDialContext consumes the [OutboundDecision] stashed in ctx by
|
||||||
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
// [outboundRoundTripper]. When the decision is to bypass (allow-list
|
||||||
// match), it dials directly. When the decision contains pinned IPs, it
|
// match), it dials directly. When the decision contains pinned IPs, it
|
||||||
@@ -580,3 +663,14 @@ type bufferedConn struct {
|
|||||||
func (c *bufferedConn) Read(b []byte) (int, error) {
|
func (c *bufferedConn) Read(b []byte) (int, error) {
|
||||||
return c.r.Read(b)
|
return c.r.Read(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CloseWrite half-closes the underlying connection. Embedding [net.Conn] hides
|
||||||
|
// the method, so a CONNECT splice over this connection could never signal EOF
|
||||||
|
// to the upstream and both sides waited for the other until a timeout.
|
||||||
|
func (c *bufferedConn) CloseWrite() error {
|
||||||
|
cw, ok := c.Conn.(interface{ CloseWrite() error })
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("underlying %T does not support half-close", c.Conn)
|
||||||
|
}
|
||||||
|
return cw.CloseWrite()
|
||||||
|
}
|
||||||
|
|||||||
155
pkg/gotenberg/outbound_envproxy_test.go
Normal file
155
pkg/gotenberg/outbound_envproxy_test.go
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
package gotenberg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateEnvironmentProxyVariables(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
env map[string]string
|
||||||
|
wantErr bool
|
||||||
|
// wantIn is a substring the error must name, so that an operator can
|
||||||
|
// find the offending variable.
|
||||||
|
wantIn string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nothing set",
|
||||||
|
env: map[string]string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "well formed URL",
|
||||||
|
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "credentials are accepted",
|
||||||
|
env: map[string]string{"HTTPS_PROXY": "http://user:password@proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare host and port is accepted, as httpproxy prefixes a scheme",
|
||||||
|
env: map[string]string{"HTTP_PROXY": "proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "socks5 is accepted",
|
||||||
|
env: map[string]string{"ALL_PROXY": "socks5://proxy.example.com:1080"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lowercase variables are checked too",
|
||||||
|
env: map[string]string{"http_proxy": "http://proxy.example.com:3128"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable URL",
|
||||||
|
env: map[string]string{"HTTP_PROXY": "http://proxy.example.com:3128/%zz"},
|
||||||
|
wantErr: true,
|
||||||
|
wantIn: "HTTP_PROXY",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the failing variable is named",
|
||||||
|
env: map[string]string{"HTTPS_PROXY": "://%zz"},
|
||||||
|
wantErr: true,
|
||||||
|
wantIn: "HTTPS_PROXY",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
for name, value := range tc.env {
|
||||||
|
t.Setenv(name, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := ValidateEnvironmentProxyVariables()
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if tc.wantIn != "" && !strings.Contains(err.Error(), tc.wantIn) {
|
||||||
|
t.Errorf("error %q does not name %q", err, tc.wantIn)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials pins that a
|
||||||
|
// proxy URL, which may embed a password, never reaches the error text.
|
||||||
|
func TestValidateEnvironmentProxyVariables_DoesNotLeakCredentials(t *testing.T) {
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
t.Setenv("HTTP_PROXY", "http://admin:hunter2@proxy.example.com:3128/%zz")
|
||||||
|
|
||||||
|
err := ValidateEnvironmentProxyVariables()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "hunter2") {
|
||||||
|
t.Errorf("error leaks the proxy password: %q", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "admin") {
|
||||||
|
t.Errorf("error leaks the proxy username: %q", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops is the regression
|
||||||
|
// test for the dial-pinning gap: with the environment proxy enabled but no
|
||||||
|
// proxy applicable to the request, the dial must still go through the pinning
|
||||||
|
// dialer rather than a plain one.
|
||||||
|
//
|
||||||
|
// The request targets a hostname that only the stub resolver knows, so a plain
|
||||||
|
// dial would hand that unresolvable name to the OS and fail. Only a pinned dial,
|
||||||
|
// which substitutes the address resolved during validation, can connect.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1592.
|
||||||
|
func TestNewOutboundHttpClient_EnvironmentProxyPinsDirectHops(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
_, port, err := net.SplitHostPort(strings.TrimPrefix(srv.URL, "http://"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("split server address: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = "pinned-only.invalid"
|
||||||
|
|
||||||
|
// NO_PROXY covers the destination, so httpproxy declines it and the
|
||||||
|
// transport dials directly. That direct dial is the hop that used to lose
|
||||||
|
// pinning.
|
||||||
|
for _, name := range environmentProxyVariables {
|
||||||
|
t.Setenv(name, "")
|
||||||
|
}
|
||||||
|
t.Setenv("HTTP_PROXY", "http://proxy.invalid:3128")
|
||||||
|
t.Setenv("NO_PROXY", host)
|
||||||
|
|
||||||
|
withStubResolver(t, func(string) ([]netip.Addr, error) {
|
||||||
|
return []netip.Addr{netip.MustParseAddr("127.0.0.1")}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
client := NewOutboundHttpClient(0, nil, nil, true)
|
||||||
|
rt, ok := client.Transport.(*outboundRoundTripper)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport is %T, want *outboundRoundTripper", client.Transport)
|
||||||
|
}
|
||||||
|
if rt.proxyFunc == nil {
|
||||||
|
t.Fatal("proxyFunc is nil, want the environment proxy to be resolved per request")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get("http://" + net.JoinHostPort(host, port))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GET failed, so the direct hop was not pinned: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -380,6 +380,13 @@ func (a *Api) Validate() error {
|
|||||||
err = errors.Join(err, errors.New("IP must be a valid IP address"))
|
err = errors.Join(err, errors.New("IP must be a valid IP address"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.downloadFromCfg.enableEnvironmentProxy {
|
||||||
|
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||||
|
if proxyErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("--api-download-from-enable-environment-proxy is set: %w", proxyErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
|
||||||
err = errors.Join(err,
|
err = errors.Join(err,
|
||||||
errors.New("both TLS certificate and key files must be set"),
|
errors.New("both TLS certificate and key files must be set"),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -150,9 +149,16 @@ func outputFilenameMiddleware() echo.MiddlewareFunc {
|
|||||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c echo.Context) error {
|
return func(c echo.Context) error {
|
||||||
filename := c.Request().Header.Get("Gotenberg-Output-Filename")
|
filename := c.Request().Header.Get("Gotenberg-Output-Filename")
|
||||||
|
// Keep only the last path segment, so that a caller cannot name an
|
||||||
|
// output file after a path.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/1227.
|
// See https://github.com/gotenberg/gotenberg/issues/1227.
|
||||||
|
//
|
||||||
|
// [filepath.Base] alone is not enough: on Linux it does not treat a
|
||||||
|
// backslash as a separator, and this value reaches archive entry
|
||||||
|
// names. Use the same sanitizer as the other caller-supplied
|
||||||
|
// filenames.
|
||||||
if filename != "" {
|
if filename != "" {
|
||||||
filename = filepath.Base(filename)
|
filename = sanitizeFilename(filename)
|
||||||
}
|
}
|
||||||
c.Set("outputFilename", filename)
|
c.Set("outputFilename", filename)
|
||||||
// Call the next middleware in the chain.
|
// Call the next middleware in the chain.
|
||||||
|
|||||||
@@ -10,6 +10,53 @@ import (
|
|||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestOutputFilenameMiddleware pins the sanitizing of the
|
||||||
|
// "Gotenberg-Output-Filename" header. The value reaches archive entry names and
|
||||||
|
// a Content-Disposition header, so a path separator must never survive it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1227 and
|
||||||
|
// GHSA-hwc4-gmrw-5222.
|
||||||
|
func TestOutputFilenameMiddleware(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no header", "", ""},
|
||||||
|
{"plain filename", "foo", "foo"},
|
||||||
|
{"POSIX path", "/tmp/foo", "foo"},
|
||||||
|
{"POSIX traversal", "../../../etc/passwd", "passwd"},
|
||||||
|
{"Windows traversal", `..\..\..\..\Windows\System32\evil`, "evil"},
|
||||||
|
{"rooted Windows path", `C:\Windows\Temp\evil`, "evil"},
|
||||||
|
{"mixed separators", `a/b\c`, "c"},
|
||||||
|
{"trailing separator", "/tmp/", ""},
|
||||||
|
{"bare dot dot", "..", ".."},
|
||||||
|
{"control characters", "fo\x01o\x7f", "foo"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
handler := outputFilenameMiddleware()(func(c echo.Context) error { return nil })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||||
|
if tc.header != "" {
|
||||||
|
req.Header.Set("Gotenberg-Output-Filename", tc.header)
|
||||||
|
}
|
||||||
|
c := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
|
||||||
|
err := handler(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := c.Get("outputFilename").(string)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("outputFilename is not set as a string")
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("outputFilename = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
func TestHardTimeoutMiddleware_MissingLoggerReturnsErrorInsteadOfPanicking(t *testing.T) {
|
||||||
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
mw := hardTimeoutMiddleware(100 * time.Millisecond)
|
||||||
handler := mw(func(c echo.Context) error { return nil })
|
handler := mw(func(c echo.Context) error { return nil })
|
||||||
|
|||||||
@@ -669,6 +669,13 @@ func (mod *Chromium) Validate() error {
|
|||||||
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
|
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if mod.args.enableEnvironmentProxy {
|
||||||
|
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||||
|
if proxyErr != nil {
|
||||||
|
return fmt.Errorf("--chromium-enable-environment-proxy is set: %w", proxyErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, err := os.Stat(mod.args.binPath)
|
_, err := os.Stat(mod.args.binPath)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
|
return fmt.Errorf("Chromium binary does not exist at %q; check the CHROMIUM_BIN_PATH environment variable: %w", mod.args.binPath, err)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/chromedp/cdproto/cdp"
|
"github.com/chromedp/cdproto/cdp"
|
||||||
"github.com/chromedp/cdproto/fetch"
|
"github.com/chromedp/cdproto/fetch"
|
||||||
@@ -62,6 +63,11 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
|
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared by every scope match of this conversion, across all paused
|
||||||
|
// requests. Its lifetime is the conversion, as this function is called once
|
||||||
|
// per conversion with that conversion's context.
|
||||||
|
budget := newScopeMatchBudget(scopeMatchBudgetPerConversion)
|
||||||
|
|
||||||
chromedp.ListenTarget(ctx, func(ev any) {
|
chromedp.ListenTarget(ctx, func(ev any) {
|
||||||
if e, ok := ev.(*fetch.EventRequestPaused); ok {
|
if e, ok := ev.(*fetch.EventRequestPaused); ok {
|
||||||
go func() {
|
go func() {
|
||||||
@@ -127,6 +133,14 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
// First, we have to check if at least one header has to be
|
// First, we have to check if at least one header has to be
|
||||||
// set for the current request.
|
// set for the current request.
|
||||||
for _, header := range options.extraHttpHeaders {
|
for _, header := range options.extraHttpHeaders {
|
||||||
|
// This goroutine outlives the response: nothing cancels an
|
||||||
|
// in-flight match, so stop as soon as the conversion is over.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
if header.Scope == nil {
|
if header.Scope == nil {
|
||||||
// Non-scoped header.
|
// Non-scoped header.
|
||||||
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
|
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
|
||||||
@@ -134,7 +148,18 @@ func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, optio
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !budget.tryAcquire() {
|
||||||
|
// Treat the remaining scoped headers as non-matching rather
|
||||||
|
// than spending more CPU on a request the client may already
|
||||||
|
// have given up on.
|
||||||
|
logger.WarnContext(ctx, fmt.Sprintf("scope matching budget of %s exhausted, extra HTTP header '%s' and any subsequent scoped header will not be set; simplify the 'scope' patterns or reduce the number of scoped headers", scopeMatchBudgetPerConversion, header.Name))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
matchStart := time.Now()
|
||||||
ok, err := header.Scope.MatchString(e.Request.URL)
|
ok, err := header.Scope.MatchString(e.Request.URL)
|
||||||
|
budget.consume(time.Since(matchStart))
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ import (
|
|||||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Bounds on the scoped extra HTTP headers feature. Chromium matches every
|
||||||
|
// scoped header against every paused sub-resource request, so the total
|
||||||
|
// matching work is the product of the header count and the sub-resource count.
|
||||||
|
// These caps bound the factors the client controls; [scopeMatchBudget] bounds
|
||||||
|
// the product. See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
const (
|
||||||
|
maxExtraHttpHeaders = 64
|
||||||
|
maxExtraHttpHeaderScopeLength = 1024
|
||||||
|
|
||||||
|
// A scope pattern matches against a URL, which takes microseconds for any
|
||||||
|
// reasonable pattern.
|
||||||
|
extraHttpHeaderScopeMatchTimeout = 250 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
var sameSiteRegexp = regexp2.MustCompile(
|
var sameSiteRegexp = regexp2.MustCompile(
|
||||||
`("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`,
|
`("sameSite"\s*:\s*")(?i:(lax|strict|none))(")`,
|
||||||
regexp2.None,
|
regexp2.None,
|
||||||
@@ -169,6 +183,10 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
|
return fmt.Errorf("unmarshal extraHttpHeaders: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(headers) > maxExtraHttpHeaders {
|
||||||
|
return fmt.Errorf("too many headers, got %d, expected at most %d", len(headers), maxExtraHttpHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
for k, v := range headers {
|
for k, v := range headers {
|
||||||
var scope string
|
var scope string
|
||||||
var valueTokens []string
|
var valueTokens []string
|
||||||
@@ -198,12 +216,17 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
|
|||||||
|
|
||||||
var scopeRegexp *regexp2.Regexp
|
var scopeRegexp *regexp2.Regexp
|
||||||
if len(scope) > 0 {
|
if len(scope) > 0 {
|
||||||
|
if len(scope) > maxExtraHttpHeaderScopeLength {
|
||||||
|
err = errors.Join(err, fmt.Errorf("scope regex pattern for header '%s' is too long, got %d characters, expected at most %d", k, len(scope), maxExtraHttpHeaderScopeLength))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
p, errCompile := regexp2.Compile(scope, regexp2.None)
|
p, errCompile := regexp2.Compile(scope, regexp2.None)
|
||||||
if errCompile != nil {
|
if errCompile != nil {
|
||||||
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p.MatchTimeout = 5 * time.Second
|
p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
|
||||||
scopeRegexp = p
|
scopeRegexp = p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
pkg/modules/chromium/scopebudget.go
Normal file
52
pkg/modules/chromium/scopebudget.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scopeMatchBudgetPerConversion caps the total time a single conversion may
|
||||||
|
// spend matching scoped extra HTTP header patterns.
|
||||||
|
//
|
||||||
|
// The per-pattern MatchTimeout bounds one match, not their number: Chromium
|
||||||
|
// pauses every sub-resource request, and each paused request is matched against
|
||||||
|
// every scoped header. Without a shared budget the total is the product of the
|
||||||
|
// two, both of which the client controls.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
const scopeMatchBudgetPerConversion = 5 * time.Second
|
||||||
|
|
||||||
|
// scopeMatchBudget is a time allowance shared by every scope match of a
|
||||||
|
// conversion. It is safe for concurrent use: paused requests are handled on
|
||||||
|
// their own goroutines.
|
||||||
|
type scopeMatchBudget struct {
|
||||||
|
remaining atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newScopeMatchBudget returns a [scopeMatchBudget] allowing d of matching.
|
||||||
|
func newScopeMatchBudget(d time.Duration) *scopeMatchBudget {
|
||||||
|
b := new(scopeMatchBudget)
|
||||||
|
b.remaining.Store(int64(d))
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryAcquire reports whether the budget still allows a match.
|
||||||
|
func (b *scopeMatchBudget) tryAcquire() bool {
|
||||||
|
return b.remaining.Load() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// consume subtracts the time a match took. It saturates at zero so that a long
|
||||||
|
// match cannot wrap the counter back into credit.
|
||||||
|
func (b *scopeMatchBudget) consume(d time.Duration) {
|
||||||
|
for {
|
||||||
|
current := b.remaining.Load()
|
||||||
|
if current <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next := max(current-int64(d), 0)
|
||||||
|
|
||||||
|
if b.remaining.CompareAndSwap(current, next) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
pkg/modules/chromium/scopebudget_test.go
Normal file
122
pkg/modules/chromium/scopebudget_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package chromium
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dlclark/regexp2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScopeMatchBudget(t *testing.T) {
|
||||||
|
t.Run("allows matching while credit remains", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
if !b.tryAcquire() {
|
||||||
|
t.Fatal("tryAcquire() = false on a fresh budget, want true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("denies matching once exhausted", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
b.consume(time.Second)
|
||||||
|
if b.tryAcquire() {
|
||||||
|
t.Error("tryAcquire() = true after the budget was spent, want false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("saturates at zero instead of wrapping into credit", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
b.consume(time.Hour)
|
||||||
|
if got := b.remaining.Load(); got != 0 {
|
||||||
|
t.Errorf("remaining = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if b.tryAcquire() {
|
||||||
|
t.Error("tryAcquire() = true after an overlong match, want false")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a spent budget stays spent", func(t *testing.T) {
|
||||||
|
b := newScopeMatchBudget(time.Second)
|
||||||
|
b.consume(time.Second)
|
||||||
|
b.consume(time.Millisecond)
|
||||||
|
if got := b.remaining.Load(); got != 0 {
|
||||||
|
t.Errorf("remaining = %d, want 0", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("is safe for concurrent use", func(t *testing.T) {
|
||||||
|
const goroutines = 64
|
||||||
|
// Each goroutine spends 1ms against a budget of half that many
|
||||||
|
// milliseconds, so the total spend overshoots it.
|
||||||
|
b := newScopeMatchBudget(time.Duration(goroutines/2) * time.Millisecond)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range goroutines {
|
||||||
|
wg.Go(func() {
|
||||||
|
b.tryAcquire()
|
||||||
|
b.consume(time.Millisecond)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if got := b.remaining.Load(); got != 0 {
|
||||||
|
t.Errorf("remaining = %d, want 0", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScopeMatchBudget_BoundsCatastrophicBacktracking is the regression test for
|
||||||
|
// the amplification: many scoped headers matched against a hostile URL must cost
|
||||||
|
// the budget, not a multiple of it.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
func TestScopeMatchBudget_BoundsCatastrophicBacktracking(t *testing.T) {
|
||||||
|
const (
|
||||||
|
headers = 16
|
||||||
|
budget = 200 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Nested quantifier with no possible match: classic catastrophic
|
||||||
|
// backtracking.
|
||||||
|
pattern := compileScopePattern(t, `(a+)+b`)
|
||||||
|
url := "http://example.com/" + strings.Repeat("a", 40)
|
||||||
|
|
||||||
|
b := newScopeMatchBudget(budget)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
var matched int
|
||||||
|
for range headers {
|
||||||
|
if !b.tryAcquire() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
matchStart := time.Now()
|
||||||
|
_, _ = pattern.MatchString(url)
|
||||||
|
b.consume(time.Since(matchStart))
|
||||||
|
matched++
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if matched == headers {
|
||||||
|
t.Errorf("all %d headers were matched, want the budget to stop matching early", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each match is separately capped at extraHttpHeaderScopeMatchTimeout, so
|
||||||
|
// the worst case is the budget plus one final match that started with the
|
||||||
|
// last of the credit. Generous slack keeps this stable on a loaded CI box.
|
||||||
|
ceiling := budget + extraHttpHeaderScopeMatchTimeout + time.Second
|
||||||
|
if elapsed > ceiling {
|
||||||
|
t.Errorf("matching took %s, want at most %s", elapsed, ceiling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileScopePattern(t *testing.T, pattern string) *regexp2.Regexp {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
p, err := regexp2.Compile(pattern, regexp2.None)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compile %q: %v", pattern, err)
|
||||||
|
}
|
||||||
|
p.MatchTimeout = extraHttpHeaderScopeMatchTimeout
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
@@ -33,12 +33,33 @@ var (
|
|||||||
// formats option.
|
// formats option.
|
||||||
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
|
ErrInvalidPdfFormats = errors.New("invalid PDF formats")
|
||||||
|
|
||||||
// ErrUnoException happens when unoconverter returns exit code 5.
|
// ErrUnoException happens when unoconverter returns exit code 5. That code
|
||||||
|
// is the residual bucket of unoconverter's catch-all UNO exception handler:
|
||||||
|
// it covers a malformed page range, a password supplied to a document that
|
||||||
|
// does not need one, a failure to open the document and a failure to write
|
||||||
|
// the output alike. It names the exception class that was caught, not a
|
||||||
|
// cause. See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
ErrUnoException = errors.New("uno exception")
|
ErrUnoException = errors.New("uno exception")
|
||||||
|
|
||||||
// ErrRuntimeException happens when unoconverter returns exit code 6.
|
// ErrRuntimeException happens when unoconverter returns exit code 6.
|
||||||
|
// unoconverter's own message for it reads "Office probably died", yet a
|
||||||
|
// wrong or missing password also surfaces there. Like [ErrUnoException], it
|
||||||
|
// does not establish who is at fault.
|
||||||
ErrRuntimeException = errors.New("runtime exception")
|
ErrRuntimeException = errors.New("runtime exception")
|
||||||
|
|
||||||
|
// ErrIoException happens when unoconverter returns exit code 3. LibreOffice
|
||||||
|
// could not read the source document.
|
||||||
|
ErrIoException = errors.New("io exception")
|
||||||
|
|
||||||
|
// ErrCannotConvertException happens when unoconverter returns exit code 4.
|
||||||
|
// LibreOffice read the document but could not convert it to PDF.
|
||||||
|
ErrCannotConvertException = errors.New("cannot convert exception")
|
||||||
|
|
||||||
|
// ErrIllegalArgumentException happens when unoconverter returns exit code
|
||||||
|
// 8. LibreOffice rejected the source document, usually because its contents
|
||||||
|
// do not match its extension.
|
||||||
|
ErrIllegalArgumentException = errors.New("illegal argument exception")
|
||||||
|
|
||||||
// ErrCoreDumped happens randomly; sometimes a conversion will work as
|
// ErrCoreDumped happens randomly; sometimes a conversion will work as
|
||||||
// expected, and some other time the same conversion will fail.
|
// expected, and some other time the same conversion will fail.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||||
@@ -501,6 +522,13 @@ func (a *Api) Validate() error {
|
|||||||
err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr))
|
err = errors.Join(err, fmt.Errorf("unoconverter binary does not exist at %q; check the UNOCONVERTER_BIN_PATH environment variable: %w", a.args.unoBinPath, statErr))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if a.args.proxyOptions.enableEnvironmentProxy {
|
||||||
|
proxyErr := gotenberg.ValidateEnvironmentProxyVariables()
|
||||||
|
if proxyErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("--libreoffice-enable-environment-proxy is set: %w", proxyErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -767,7 +795,10 @@ func conversionRequestAttributes(inputPath string, options Options) []attribute.
|
|||||||
// [gotenberg.ClassifyError].
|
// [gotenberg.ClassifyError].
|
||||||
func libreofficeErrorType(err error) string {
|
func libreofficeErrorType(err error) string {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrInvalidPdfFormats):
|
case errors.Is(err, ErrInvalidPdfFormats),
|
||||||
|
errors.Is(err, ErrIoException),
|
||||||
|
errors.Is(err, ErrCannotConvertException),
|
||||||
|
errors.Is(err, ErrIllegalArgumentException):
|
||||||
return gotenberg.ErrorTypeInvalidInput
|
return gotenberg.ErrorTypeInvalidInput
|
||||||
case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException):
|
case errors.Is(err, ErrUnoException), errors.Is(err, ErrRuntimeException):
|
||||||
return "libreoffice_exception"
|
return "libreoffice_exception"
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ func TestLibreofficeErrorType(t *testing.T) {
|
|||||||
{"deadline", context.DeadlineExceeded, "timeout"},
|
{"deadline", context.DeadlineExceeded, "timeout"},
|
||||||
{"canceled", context.Canceled, "context_cancelled"},
|
{"canceled", context.Canceled, "context_cancelled"},
|
||||||
{"invalid pdf formats", ErrInvalidPdfFormats, "invalid_input"},
|
{"invalid pdf formats", ErrInvalidPdfFormats, "invalid_input"},
|
||||||
|
{"io exception", ErrIoException, "invalid_input"},
|
||||||
|
{"cannot convert exception", ErrCannotConvertException, "invalid_input"},
|
||||||
|
{"illegal argument exception", ErrIllegalArgumentException, "invalid_input"},
|
||||||
{"uno exception", ErrUnoException, "libreoffice_exception"},
|
{"uno exception", ErrUnoException, "libreoffice_exception"},
|
||||||
{"runtime exception", ErrRuntimeException, "libreoffice_exception"},
|
{"runtime exception", ErrRuntimeException, "libreoffice_exception"},
|
||||||
{"queue size exceeded", gotenberg.ErrMaximumQueueSizeExceeded, "libreoffice_unavailable"},
|
{"queue size exceeded", gotenberg.ErrMaximumQueueSizeExceeded, "libreoffice_unavailable"},
|
||||||
|
|||||||
@@ -435,9 +435,11 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LibreOffice's errors are not explicit.
|
// LibreOffice's errors are not explicit: unoconverter derives its exit code
|
||||||
// For instance, exit code 5 may be explained by a malformed page range
|
// from the UNO exception class it caught, not from a diagnosis. Exit codes
|
||||||
// but also by a not required password.
|
// 5 and 6 are ambiguous in particular, so the route decides the HTTP status
|
||||||
|
// from the request and the document rather than from the code alone.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
|
||||||
// We may want to retry in case of a core-dumped event.
|
// We may want to retry in case of a core-dumped event.
|
||||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||||
@@ -445,13 +447,17 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, input
|
|||||||
return ErrCoreDumped
|
return ErrCoreDumped
|
||||||
}
|
}
|
||||||
|
|
||||||
if exitCode == 5 {
|
switch exitCode {
|
||||||
// Potentially malformed page ranges or password not required.
|
case 3:
|
||||||
|
return ErrIoException
|
||||||
|
case 4:
|
||||||
|
return ErrCannotConvertException
|
||||||
|
case 5:
|
||||||
return ErrUnoException
|
return ErrUnoException
|
||||||
}
|
case 6:
|
||||||
if exitCode == 6 {
|
|
||||||
// Password potentially required or invalid.
|
|
||||||
return ErrRuntimeException
|
return ErrRuntimeException
|
||||||
|
case 8:
|
||||||
|
return ErrIllegalArgumentException
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("convert to PDF: %w", err)
|
return fmt.Errorf("convert to PDF: %w", err)
|
||||||
|
|||||||
129
pkg/modules/libreoffice/api/protection.go
Normal file
129
pkg/modules/libreoffice/api/protection.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordProtection describes whether a document requires a password to open.
|
||||||
|
type PasswordProtection int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PasswordProtectionUnknown means the document's encryption state could not
|
||||||
|
// be determined.
|
||||||
|
PasswordProtectionUnknown PasswordProtection = iota
|
||||||
|
|
||||||
|
// PasswordProtectionNone means the document opens without a password.
|
||||||
|
PasswordProtectionNone
|
||||||
|
|
||||||
|
// PasswordProtectionRequired means the document is encrypted.
|
||||||
|
PasswordProtectionRequired
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Compound File Binary magic. An encrypted OOXML document is an
|
||||||
|
// MS-OFFCRYPTO container, which is a compound file. Per MS-CFB 2.2, the
|
||||||
|
// header signature is fixed.
|
||||||
|
ole2Magic = []byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1}
|
||||||
|
|
||||||
|
// Local file header signature. Per APPNOTE.TXT 4.3.7, every ZIP entry
|
||||||
|
// starts with it, so an intact package starts with it too.
|
||||||
|
zipMagic = []byte{0x50, 0x4b, 0x03, 0x04}
|
||||||
|
|
||||||
|
// An unencrypted OOXML document is always a ZIP package, so any of these
|
||||||
|
// extensions over a compound file means the payload is encrypted. Legacy
|
||||||
|
// binary formats (.doc, .xls, .ppt) are compound files either way and are
|
||||||
|
// deliberately absent.
|
||||||
|
ooxmlExtensions = map[string]struct{}{
|
||||||
|
".docx": {}, ".docm": {}, ".dotx": {}, ".dotm": {},
|
||||||
|
".xlsx": {}, ".xlsm": {}, ".xltx": {}, ".xltm": {},
|
||||||
|
".pptx": {}, ".pptm": {}, ".potx": {}, ".potm": {},
|
||||||
|
".ppsx": {}, ".ppsm": {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// odfManifestSizeLimit caps how much of an ODF manifest is read. The manifest
|
||||||
|
// is a few kilobytes in practice; the cap stops a crafted archive from
|
||||||
|
// exhausting memory through its decompressed size.
|
||||||
|
const odfManifestSizeLimit = 1 << 20
|
||||||
|
|
||||||
|
// DetectPasswordProtection reports whether the document at path is encrypted.
|
||||||
|
//
|
||||||
|
// Detection is advisory and never fails: an unreadable file, an unknown format
|
||||||
|
// or a malformed archive all yield [PasswordProtectionUnknown]. It exists to
|
||||||
|
// refine the diagnosis of a conversion that already failed, since LibreOffice's
|
||||||
|
// exit codes do not distinguish a missing password from a crash.
|
||||||
|
func DetectPasswordProtection(path string) PasswordProtection {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = f.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
magic := make([]byte, 8)
|
||||||
|
n, err := io.ReadFull(f, magic)
|
||||||
|
if err != nil && n < len(zipMagic) {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
magic = magic[:n]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case bytes.HasPrefix(magic, ole2Magic):
|
||||||
|
if _, ok := ooxmlExtensions[strings.ToLower(filepath.Ext(path))]; ok {
|
||||||
|
return PasswordProtectionRequired
|
||||||
|
}
|
||||||
|
// A legacy binary document is a compound file whether or not it is
|
||||||
|
// encrypted; its encryption lives in a stream this cannot cheaply read.
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
case bytes.HasPrefix(magic, zipMagic):
|
||||||
|
return detectZipPasswordProtection(f)
|
||||||
|
default:
|
||||||
|
// Flat XML (.fodt), RTF, CSV and everything else carry no encryption.
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectZipPasswordProtection inspects a ZIP package. ODF keeps META-INF/manifest.xml
|
||||||
|
// in cleartext even when encrypted, declaring each encrypted entry. An OOXML
|
||||||
|
// package has no manifest, and reaching this point already proves it is not an
|
||||||
|
// MS-OFFCRYPTO container, so it opens without a password.
|
||||||
|
func detectZipPasswordProtection(f *os.File) PasswordProtection {
|
||||||
|
size, err := f.Seek(0, io.SeekEnd)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := zip.NewReader(f, size)
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := r.Open("META-INF/manifest.xml")
|
||||||
|
if err != nil {
|
||||||
|
// No manifest: an OOXML package, or a ZIP that is not an office
|
||||||
|
// document at all. Neither is encrypted.
|
||||||
|
return PasswordProtectionNone
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = manifest.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
content, err := io.ReadAll(io.LimitReader(manifest, odfManifestSizeLimit))
|
||||||
|
if err != nil {
|
||||||
|
return PasswordProtectionUnknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per OpenDocument 1.3 part 3, section 4.16, an encrypted entry carries a
|
||||||
|
// <manifest:encryption-data> child.
|
||||||
|
if bytes.Contains(content, []byte("encryption-data")) {
|
||||||
|
return PasswordProtectionRequired
|
||||||
|
}
|
||||||
|
|
||||||
|
return PasswordProtectionNone
|
||||||
|
}
|
||||||
193
pkg/modules/libreoffice/api/protection_test.go
Normal file
193
pkg/modules/libreoffice/api/protection_test.go
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeFile writes content to a new file named name inside dir and returns its
|
||||||
|
// path.
|
||||||
|
func writeFile(t *testing.T, dir, name string, content []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
err := os.WriteFile(path, content, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeZip builds a ZIP archive from entries and returns its path.
|
||||||
|
func writeZip(t *testing.T, dir, name string, entries map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
w := zip.NewWriter(buf)
|
||||||
|
|
||||||
|
for entryName, content := range entries {
|
||||||
|
f, err := w.Create(entryName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create zip entry %s: %v", entryName, err)
|
||||||
|
}
|
||||||
|
_, err = f.Write([]byte(content))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write zip entry %s: %v", entryName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := w.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close zip writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeFile(t, dir, name, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectPasswordProtection(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
ole2 := func(name string) string {
|
||||||
|
return writeFile(t, dir, name, append(ole2Magic, bytes.Repeat([]byte{0x00}, 64)...))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want PasswordProtection
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "encrypted OOXML is a compound file",
|
||||||
|
path: ole2("encrypted.docx"),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "extension casing is ignored",
|
||||||
|
path: ole2("encrypted.DOCX"),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted spreadsheet",
|
||||||
|
path: ole2("encrypted.xlsx"),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy binary document is inconclusive",
|
||||||
|
path: ole2("legacy.doc"),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain OOXML package",
|
||||||
|
path: writeZip(t, dir, "plain.docx", map[string]string{
|
||||||
|
"[Content_Types].xml": "<Types/>",
|
||||||
|
"word/document.xml": "<w:document/>",
|
||||||
|
}),
|
||||||
|
want: PasswordProtectionNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted ODF declares encryption-data in its manifest",
|
||||||
|
path: writeZip(t, dir, "encrypted.odt", map[string]string{
|
||||||
|
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||||
|
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry><manifest:encryption-data manifest:checksum="x"/></manifest:file-entry></manifest:manifest>`,
|
||||||
|
"content.xml": "<office:document-content/>",
|
||||||
|
}),
|
||||||
|
want: PasswordProtectionRequired,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain ODF has a manifest without encryption-data",
|
||||||
|
path: writeZip(t, dir, "plain.odt", map[string]string{
|
||||||
|
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||||
|
"META-INF/manifest.xml": `<manifest:manifest><manifest:file-entry manifest:full-path="/"/></manifest:manifest>`,
|
||||||
|
"content.xml": "<office:document-content/>",
|
||||||
|
}),
|
||||||
|
want: PasswordProtectionNone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "flat XML carries no encryption",
|
||||||
|
path: writeFile(t, dir, "flat.fodt", []byte("<?xml version=\"1.0\"?><office:document/>")),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain text",
|
||||||
|
path: writeFile(t, dir, "notes.txt", []byte("hello")),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file shorter than any magic",
|
||||||
|
path: writeFile(t, dir, "tiny.docx", []byte{0x50}),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty file",
|
||||||
|
path: writeFile(t, dir, "empty.docx", nil),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "truncated archive",
|
||||||
|
path: writeFile(t, dir, "truncated.docx", append(zipMagic, bytes.Repeat([]byte{0x00}, 32)...)),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existent path",
|
||||||
|
path: filepath.Join(dir, "does-not-exist.docx"),
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "directory",
|
||||||
|
path: dir,
|
||||||
|
want: PasswordProtectionUnknown,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := DetectPasswordProtection(tc.path); got != tc.want {
|
||||||
|
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectPasswordProtection_Fixtures anchors detection to the same documents
|
||||||
|
// the integration scenarios upload, so a fixture swap cannot silently flip a
|
||||||
|
// status code.
|
||||||
|
func TestDetectPasswordProtection_Fixtures(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path string
|
||||||
|
want PasswordProtection
|
||||||
|
}{
|
||||||
|
{"../../../../test/integration/testdata/protected_page_1.docx", PasswordProtectionRequired},
|
||||||
|
{"../../../../test/integration/testdata/page_1.docx", PasswordProtectionNone},
|
||||||
|
} {
|
||||||
|
t.Run(filepath.Base(tc.path), func(t *testing.T) {
|
||||||
|
if _, err := os.Stat(tc.path); err != nil {
|
||||||
|
t.Skipf("fixture unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if got := DetectPasswordProtection(tc.path); got != tc.want {
|
||||||
|
t.Errorf("DetectPasswordProtection(%s) = %d, want %d", tc.path, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectPasswordProtection_OversizedManifest verifies that a manifest far
|
||||||
|
// larger than the cap still yields a verdict through a bounded read.
|
||||||
|
func TestDetectPasswordProtection_OversizedManifest(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// Well past odfManifestSizeLimit, and highly compressible, so the archive
|
||||||
|
// on disk stays small.
|
||||||
|
filler := strings.Repeat("<manifest:file-entry manifest:full-path=\"pad\"/>", 200_000)
|
||||||
|
|
||||||
|
path := writeZip(t, dir, "oversized.odt", map[string]string{
|
||||||
|
"mimetype": "application/vnd.oasis.opendocument.text",
|
||||||
|
"META-INF/manifest.xml": "<manifest:manifest>" + filler + "</manifest:manifest>",
|
||||||
|
})
|
||||||
|
|
||||||
|
if got := DetectPasswordProtection(path); got != PasswordProtectionNone {
|
||||||
|
t.Errorf("DetectPasswordProtection(oversized) = %d, want %d", got, PasswordProtectionNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,11 @@ import (
|
|||||||
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// unattributableFailureMessage is returned when LibreOffice fails and no
|
||||||
|
// client-supplied input is implicated. Its only format verb is the original
|
||||||
|
// filename.
|
||||||
|
const unattributableFailureMessage = "LibreOffice failed to convert the document '%s'. This is usually a resource issue: increase the container's memory and CPU, or reduce the document's size. The request is valid and may be retried."
|
||||||
|
|
||||||
// convertRoute returns an [api.Route] which can convert LibreOffice documents
|
// convertRoute returns an [api.Route] which can convert LibreOffice documents
|
||||||
// to PDF.
|
// to PDF.
|
||||||
func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
|
func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) api.Route {
|
||||||
@@ -405,20 +410,52 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, libreofficeapi.ErrUnoException) {
|
filename := ctx.OriginalFilename(inputPath)
|
||||||
|
|
||||||
|
if errors.Is(err, libreofficeapi.ErrIoException) || errors.Is(err, libreofficeapi.ErrIllegalArgumentException) {
|
||||||
return api.WrapError(
|
return api.WrapError(
|
||||||
fmt.Errorf("convert to PDF: %w", err),
|
fmt.Errorf("convert to PDF: %w", err),
|
||||||
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice failed to process a document: possible causes include malformed page ranges '%s' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.", options.PageRanges)),
|
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not read the document '%s'. Ensure the file is not corrupted and that its extension matches its actual format.", filename)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, libreofficeapi.ErrRuntimeException) {
|
if errors.Is(err, libreofficeapi.ErrCannotConvertException) {
|
||||||
return api.WrapError(
|
return api.WrapError(
|
||||||
fmt.Errorf("convert to PDF: %w", err),
|
fmt.Errorf("convert to PDF: %w", err),
|
||||||
api.NewSentinelHttpError(http.StatusBadRequest, "LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain."),
|
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice read the document '%s' but could not convert it to PDF. The document may be corrupted or rely on an unsupported feature.", filename)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exit codes 5 and 6 name the UNO exception class that was
|
||||||
|
// caught, not a cause: both cover a client mistake and a
|
||||||
|
// LibreOffice crash. Blame the client only when one of its
|
||||||
|
// inputs is actually implicated, since the server is the
|
||||||
|
// only remaining explanation otherwise. Password evidence
|
||||||
|
// outranks page ranges: a password failure aborts on import,
|
||||||
|
// before the export filter applies any page range.
|
||||||
|
// See https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
if errors.Is(err, libreofficeapi.ErrUnoException) || errors.Is(err, libreofficeapi.ErrRuntimeException) {
|
||||||
|
protection := libreofficeapi.DetectPasswordProtection(inputPath)
|
||||||
|
|
||||||
|
var sentinel api.SentinelHttpError
|
||||||
|
switch {
|
||||||
|
case protection == libreofficeapi.PasswordProtectionRequired && options.Password == "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is password-protected. Provide its password in the 'password' form field.", filename))
|
||||||
|
case protection == libreofficeapi.PasswordProtectionRequired:
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The password for the document '%s' is incorrect. Check the 'password' form field.", filename))
|
||||||
|
case protection == libreofficeapi.PasswordProtectionNone && options.Password != "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("The document '%s' is not password-protected. Remove the 'password' form field.", filename))
|
||||||
|
case options.Password != "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not open the document '%s' with the given password. Check the 'password' form field, and omit it if the document is not password-protected.", filename))
|
||||||
|
case errors.Is(err, libreofficeapi.ErrUnoException) && options.PageRanges != "":
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("LibreOffice could not apply the page ranges '%s' to the document '%s'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.", options.PageRanges, filename))
|
||||||
|
default:
|
||||||
|
sentinel = api.NewSentinelHttpError(http.StatusInternalServerError, fmt.Sprintf(unattributableFailureMessage, filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
return api.WrapError(fmt.Errorf("convert to PDF: %w", err), sentinel)
|
||||||
|
}
|
||||||
|
|
||||||
return fmt.Errorf("convert to PDF: %w", err)
|
return fmt.Errorf("convert to PDF: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
241
pkg/modules/libreoffice/routes_test.go
Normal file
241
pkg/modules/libreoffice/routes_test.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package libreoffice
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
||||||
|
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
|
||||||
|
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// compoundFile writes a document whose header marks it as a compound file. Over
|
||||||
|
// an OOXML extension, that means an encrypted payload.
|
||||||
|
func compoundFile(t *testing.T, dir, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
content := append(
|
||||||
|
[]byte{0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1},
|
||||||
|
bytes.Repeat([]byte{0x00}, 64)...,
|
||||||
|
)
|
||||||
|
|
||||||
|
return writeTestFile(t, dir, name, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// zipPackage writes a minimal, unencrypted OOXML package.
|
||||||
|
func zipPackage(t *testing.T, dir, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
w := zip.NewWriter(buf)
|
||||||
|
|
||||||
|
f, err := w.Create("[Content_Types].xml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create zip entry: %v", err)
|
||||||
|
}
|
||||||
|
_, err = f.Write([]byte("<Types/>"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write zip entry: %v", err)
|
||||||
|
}
|
||||||
|
err = w.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close zip writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return writeTestFile(t, dir, name, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeTestFile(t *testing.T, dir, name string, content []byte) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
err := os.WriteFile(path, content, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConvertRoute_FailureStatus pins the branch table that decides whether a
|
||||||
|
// LibreOffice failure is the client's fault. See
|
||||||
|
// https://github.com/gotenberg/gotenberg/issues/1588.
|
||||||
|
func TestConvertRoute_FailureStatus(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
var (
|
||||||
|
protected = compoundFile(t, dir, "protected_page_1.docx")
|
||||||
|
plain = zipPackage(t, dir, "page_1.docx")
|
||||||
|
legacy = compoundFile(t, dir, "legacy.doc")
|
||||||
|
corrupted = writeTestFile(t, dir, "corrupted.docx", []byte("not a document"))
|
||||||
|
unreachable = filepath.Join(dir, "vanished.docx")
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
inputPath string
|
||||||
|
values map[string][]string
|
||||||
|
err error
|
||||||
|
wantStatus int
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "encrypted document, no password",
|
||||||
|
inputPath: protected,
|
||||||
|
err: libreofficeapi.ErrRuntimeException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "encrypted document, wrong password",
|
||||||
|
inputPath: protected,
|
||||||
|
values: map[string][]string{"password": {"bar"}},
|
||||||
|
err: libreofficeapi.ErrRuntimeException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "The password for the document 'protected_page_1.docx' is incorrect. Check the 'password' form field.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unencrypted document, password supplied",
|
||||||
|
inputPath: plain,
|
||||||
|
values: map[string][]string{"password": {"foo"}},
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "The document 'page_1.docx' is not password-protected. Remove the 'password' form field.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inconclusive document, password supplied",
|
||||||
|
inputPath: legacy,
|
||||||
|
values: map[string][]string{"password": {"foo"}},
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "LibreOffice could not open the document 'legacy.doc' with the given password. Check the 'password' form field, and omit it if the document is not password-protected.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "malformed page ranges",
|
||||||
|
inputPath: plain,
|
||||||
|
values: map[string][]string{"nativePageRanges": {"foo"}},
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "LibreOffice could not apply the page ranges 'foo' to the document 'page_1.docx'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "password evidence outranks page ranges",
|
||||||
|
inputPath: protected,
|
||||||
|
values: map[string][]string{"nativePageRanges": {"1-2"}},
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "page ranges do not excuse a runtime exception",
|
||||||
|
inputPath: plain,
|
||||||
|
values: map[string][]string{"nativePageRanges": {"1-2"}},
|
||||||
|
err: libreofficeapi.ErrRuntimeException,
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nothing implicated, uno exception",
|
||||||
|
inputPath: plain,
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nothing implicated, runtime exception",
|
||||||
|
inputPath: plain,
|
||||||
|
err: libreofficeapi.ErrRuntimeException,
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: fmt.Sprintf(unattributableFailureMessage, "page_1.docx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "detection cannot read the document",
|
||||||
|
inputPath: unreachable,
|
||||||
|
err: libreofficeapi.ErrUnoException,
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: fmt.Sprintf(unattributableFailureMessage, "vanished.docx"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unreadable source",
|
||||||
|
inputPath: corrupted,
|
||||||
|
err: libreofficeapi.ErrIoException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "LibreOffice could not read the document 'corrupted.docx'. Ensure the file is not corrupted and that its extension matches its actual format.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "rejected source",
|
||||||
|
inputPath: corrupted,
|
||||||
|
err: libreofficeapi.ErrIllegalArgumentException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "LibreOffice could not read the document 'corrupted.docx'. Ensure the file is not corrupted and that its extension matches its actual format.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unconvertible document",
|
||||||
|
inputPath: corrupted,
|
||||||
|
err: libreofficeapi.ErrCannotConvertException,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "LibreOffice read the document 'corrupted.docx' but could not convert it to PDF. The document may be corrupted or rely on an unsupported feature.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "core dumped past the retry cap",
|
||||||
|
inputPath: plain,
|
||||||
|
err: libreofficeapi.ErrCoreDumped,
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: http.StatusText(http.StatusInternalServerError),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unmapped exit code",
|
||||||
|
inputPath: plain,
|
||||||
|
err: fmt.Errorf("convert to PDF: exit status 7"),
|
||||||
|
wantStatus: http.StatusInternalServerError,
|
||||||
|
wantBody: http.StatusText(http.StatusInternalServerError),
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
ctx := &api.ContextMock{Context: new(api.Context)}
|
||||||
|
ctx.SetDirPath(dir)
|
||||||
|
ctx.SetFiles(map[string]string{filepath.Base(tc.inputPath): tc.inputPath})
|
||||||
|
ctx.SetValues(tc.values)
|
||||||
|
ctx.SetLogger(slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
uno := &libreofficeapi.ApiMock{
|
||||||
|
ExtensionsMock: func() []string {
|
||||||
|
return []string{".docx", ".doc"}
|
||||||
|
},
|
||||||
|
PdfMock: func(_ context.Context, _ *slog.Logger, _, _ string, _ libreofficeapi.Options) error {
|
||||||
|
// Mirror the wrapping done by [libreofficeapi.Api.Pdf].
|
||||||
|
return fmt.Errorf("supervisor run task: %w", tc.err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c := echo.New().NewContext(
|
||||||
|
httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", nil),
|
||||||
|
httptest.NewRecorder(),
|
||||||
|
)
|
||||||
|
c.Set("context", ctx.Context)
|
||||||
|
|
||||||
|
err := convertRoute(uno, new(gotenberg.PdfEngineMock)).Handler(c)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
status, message := api.ParseError(err)
|
||||||
|
if status != tc.wantStatus {
|
||||||
|
t.Errorf("status = %d, want %d (message: %s)", status, tc.wantStatus, message)
|
||||||
|
}
|
||||||
|
if message != tc.wantBody {
|
||||||
|
t.Errorf("message =\n%s\nwant\n%s", message, tc.wantBody)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -308,7 +308,10 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, in
|
|||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
tmpPath := fmt.Sprintf("%s.read.json", inputPath)
|
tmpPath := fmt.Sprintf("%s.read.json", inputPath)
|
||||||
args := []string{"bookmarks", "export", inputPath, tmpPath}
|
// --force: without it, a leftover file from an interrupted run makes pdfcpu
|
||||||
|
// refuse, and the stale contents would then be read as this document's
|
||||||
|
// bookmarks.
|
||||||
|
args := []string{"bookmarks", "export", "--force", inputPath, tmpPath}
|
||||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("create command: %w", err)
|
err = fmt.Errorf("create command: %w", err)
|
||||||
@@ -456,7 +459,9 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, i
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
args := []string{"bookmarks", "import", "--replace", inputPath, tmpPath, inputPath}
|
// --force: the output path is the input path, and pdfcpu refuses to
|
||||||
|
// overwrite an existing file without it.
|
||||||
|
args := []string{"bookmarks", "import", "--replace", "--force", inputPath, tmpPath, inputPath}
|
||||||
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = fmt.Errorf("create command: %w", err)
|
err = fmt.Errorf("create command: %w", err)
|
||||||
@@ -559,8 +564,11 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPat
|
|||||||
perm = "none"
|
perm = "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
args := make([]string, 0, 11)
|
args := make([]string, 0, 12)
|
||||||
args = append(args, "encrypt")
|
args = append(args, "encrypt")
|
||||||
|
// --force: the output path is the input path, and pdfcpu refuses to
|
||||||
|
// overwrite an existing file without it.
|
||||||
|
args = append(args, "--force")
|
||||||
args = append(args, "--mode", "aes")
|
args = append(args, "--mode", "aes")
|
||||||
args = append(args, "--upw", opts.UserPassword)
|
args = append(args, "--upw", opts.UserPassword)
|
||||||
args = append(args, "--opw", ownerPassword)
|
args = append(args, "--opw", ownerPassword)
|
||||||
@@ -633,7 +641,9 @@ func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath
|
|||||||
)
|
)
|
||||||
defer span.End()
|
defer span.End()
|
||||||
|
|
||||||
args := []string{"rotate"}
|
// --force: the output path is the input path, and pdfcpu refuses to
|
||||||
|
// overwrite an existing file without it.
|
||||||
|
args := []string{"rotate", "--force"}
|
||||||
if pages != "" {
|
if pages != "" {
|
||||||
args = append(args, "--pages", pages)
|
args = append(args, "--pages", pages)
|
||||||
}
|
}
|
||||||
@@ -679,7 +689,9 @@ func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *slog.Lo
|
|||||||
}
|
}
|
||||||
description := strings.Join(descParts, ", ")
|
description := strings.Join(descParts, ", ")
|
||||||
|
|
||||||
args := []string{command, "add", "--mode", mode}
|
// --force: the output path is the input path, and pdfcpu refuses to
|
||||||
|
// overwrite an existing file without it.
|
||||||
|
args := []string{command, "add", "--mode", mode, "--force"}
|
||||||
|
|
||||||
if stamp.Pages != "" {
|
if stamp.Pages != "" {
|
||||||
args = append(args, "--pages", stamp.Pages)
|
args = append(args, "--pages", stamp.Pages)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package webhook
|
package webhook
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -107,10 +108,25 @@ func (w *Webhook) AsyncCount() int64 {
|
|||||||
return w.asyncCount.Load()
|
return w.asyncCount.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate checks the module's configuration.
|
||||||
|
func (w *Webhook) Validate() error {
|
||||||
|
if !w.enableEnvironmentProxy {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := gotenberg.ValidateEnvironmentProxyVariables()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("--webhook-enable-environment-proxy is set: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Interface guards.
|
// Interface guards.
|
||||||
var (
|
var (
|
||||||
_ gotenberg.Module = (*Webhook)(nil)
|
_ gotenberg.Module = (*Webhook)(nil)
|
||||||
_ gotenberg.Provisioner = (*Webhook)(nil)
|
_ gotenberg.Provisioner = (*Webhook)(nil)
|
||||||
|
_ gotenberg.Validator = (*Webhook)(nil)
|
||||||
_ api.MiddlewareProvider = (*Webhook)(nil)
|
_ api.MiddlewareProvider = (*Webhook)(nil)
|
||||||
_ api.AsynchronousCounter = (*Webhook)(nil)
|
_ api.AsynchronousCounter = (*Webhook)(nil)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -813,8 +813,40 @@ Feature: /forms/chromium/convert/html
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# See https://github.com/gotenberg/gotenberg/issues/1130.
|
# See https://github.com/gotenberg/gotenberg/issues/1130.
|
||||||
|
# A backslash is not a path separator on Linux, so filepath.Base leaves it in
|
||||||
|
# place and it reaches the archive entry names. See GHSA-hwc4-gmrw-5222.
|
||||||
@split
|
@split
|
||||||
@output-filename
|
@output-filename
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Split Windows Path As Output Filename)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/pages-3-html/index.html | file |
|
||||||
|
| splitMode | intervals | field |
|
||||||
|
| splitSpan | 2 | field |
|
||||||
|
| Gotenberg-Output-Filename | ..\\..\\..\\Windows\\System32\\foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/zip"
|
||||||
|
Then there should be 2 PDF(s) in the response
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.zip |
|
||||||
|
| foo_0.pdf |
|
||||||
|
| foo_1.pdf |
|
||||||
|
|
||||||
|
Scenario: POST /forms/chromium/convert/html (Split Rooted Windows Path As Output Filename)
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/pages-3-html/index.html | file |
|
||||||
|
| splitMode | intervals | field |
|
||||||
|
| splitSpan | 2 | field |
|
||||||
|
| Gotenberg-Output-Filename | C:\\Windows\\Temp\\foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/zip"
|
||||||
|
Then there should be 2 PDF(s) in the response
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.zip |
|
||||||
|
| foo_0.pdf |
|
||||||
|
| foo_1.pdf |
|
||||||
|
|
||||||
Scenario: POST /forms/chromium/convert/html (Split Output Filename)
|
Scenario: POST /forms/chromium/convert/html (Split Output Filename)
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain.
|
The document 'protected_page_1.docx' is password-protected. Provide its password in the 'password' form field.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
| files | testdata/protected_page_1.docx | file |
|
| files | testdata/protected_page_1.docx | file |
|
||||||
@@ -255,7 +255,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
LibreOffice failed to process a document: possible causes include malformed page ranges 'foo' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.
|
LibreOffice could not apply the page ranges 'foo' to the document 'page_1.docx'. Check the 'nativePageRanges' form field; valid values look like '1-4', '2' or '1,3,5-7'.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
| files | testdata/page_1.docx | file |
|
| files | testdata/page_1.docx | file |
|
||||||
@@ -264,7 +264,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
LibreOffice failed to process a document: possible causes include malformed page ranges '' (nativePageRanges), or, if a password has been provided, it may not be required. In any case, the exact cause is uncertain.
|
The document 'page_1.docx' is not password-protected. Remove the 'password' form field.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
| files | testdata/protected_page_1.docx | file |
|
| files | testdata/protected_page_1.docx | file |
|
||||||
@@ -273,7 +273,7 @@ Feature: /forms/libreoffice/convert
|
|||||||
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
|
||||||
Then the response body should match string:
|
Then the response body should match string:
|
||||||
"""
|
"""
|
||||||
LibreOffice failed to process a document: a password may be required, or, if one has been given, it is invalid. In any case, the exact cause is uncertain.
|
The password for the document 'protected_page_1.docx' is incorrect. Check the 'password' form field.
|
||||||
"""
|
"""
|
||||||
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
|
||||||
| files | testdata/page_1.docx | file |
|
| files | testdata/page_1.docx | file |
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ Feature: Output Filename
|
|||||||
Then there should be the following file(s) in the response:
|
Then there should be the following file(s) in the response:
|
||||||
| foo.zip |
|
| foo.zip |
|
||||||
|
|
||||||
|
# See GHSA-hwc4-gmrw-5222.
|
||||||
|
Scenario: Windows Path As Filename
|
||||||
|
Given I have a default Gotenberg container
|
||||||
|
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s):
|
||||||
|
| files | testdata/page_1.pdf | file |
|
||||||
|
| Gotenberg-Output-Filename | C:\\Windows\\Temp\\foo | header |
|
||||||
|
Then the response status code should be 200
|
||||||
|
Then the response header "Content-Type" should be "application/pdf"
|
||||||
|
Then there should be the following file(s) in the response:
|
||||||
|
| foo.pdf |
|
||||||
|
|
||||||
# See https://github.com/gotenberg/gotenberg/issues/1227.
|
# See https://github.com/gotenberg/gotenberg/issues/1227.
|
||||||
Scenario: Path As Filename
|
Scenario: Path As Filename
|
||||||
Given I have a default Gotenberg container
|
Given I have a default Gotenberg container
|
||||||
|
|||||||
Reference in New Issue
Block a user