mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-07 21:13:18 +01:00
fix(api): bound downloadFrom requests by the request deadline
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/dlclark/regexp2"
|
"github.com/dlclark/regexp2"
|
||||||
|
"github.com/hashicorp/go-retryablehttp"
|
||||||
"golang.org/x/net/http/httpproxy"
|
"golang.org/x/net/http/httpproxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -450,6 +451,16 @@ func (rt *outboundRoundTripper) RoundTrip(req *http.Request) (*http.Response, er
|
|||||||
// gate this behind their module's opt-in flag. See
|
// 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 {
|
||||||
|
// A negative timeout means the caller's budget is already spent, which
|
||||||
|
// happens when it derives one from a deadline that has passed. [http.Client]
|
||||||
|
// treats any non-positive Timeout as no deadline at all, so passing it
|
||||||
|
// through would silently produce an unbounded client. Fail closed instead.
|
||||||
|
// Zero keeps meaning unbounded: callers that own the connection lifetime
|
||||||
|
// themselves pass it deliberately.
|
||||||
|
if timeout < 0 {
|
||||||
|
timeout = time.Nanosecond
|
||||||
|
}
|
||||||
|
|
||||||
base := http.DefaultTransport.(*http.Transport).Clone()
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
|
||||||
var proxyFunc func(*url.URL) (*url.URL, error)
|
var proxyFunc func(*url.URL) (*url.URL, error)
|
||||||
@@ -493,6 +504,25 @@ func NewOutboundHttpClient(timeout time.Duration, allowList, denyList []*regexp2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClampedBackoff is a [retryablehttp.Backoff] that honors max on every path.
|
||||||
|
//
|
||||||
|
// [retryablehttp.DefaultBackoff] returns a Retry-After header from the remote
|
||||||
|
// verbatim for 429 and 503, and returns it before applying its own max clamp.
|
||||||
|
// A hostile origin therefore decides how long Gotenberg waits, and the wait is
|
||||||
|
// not interruptible. Retry-After is still respected here, just never beyond
|
||||||
|
// the ceiling the caller set.
|
||||||
|
func ClampedBackoff(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
|
||||||
|
wait := retryablehttp.DefaultBackoff(min, max, attemptNum, resp)
|
||||||
|
if wait > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
if wait < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return wait
|
||||||
|
}
|
||||||
|
|
||||||
// environmentProxyVariables are the variables golang.org/x/net/http/httpproxy
|
// environmentProxyVariables are the variables golang.org/x/net/http/httpproxy
|
||||||
// reads, in the casing precedence it applies.
|
// reads, in the casing precedence it applies.
|
||||||
var environmentProxyVariables = []string{
|
var environmentProxyVariables = []string{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gotenberg
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -640,3 +641,59 @@ func TestDecideOutbound_LegitimateCredentialsStillReachTheHost(t *testing.T) {
|
|||||||
t.Fatalf("decision.Bypass = false, want true")
|
t.Fatalf("decision.Bypass = false, want true")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClampedBackoff(t *testing.T) {
|
||||||
|
const (
|
||||||
|
min = 1 * time.Second
|
||||||
|
max = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
retryAfter := func(status int, seconds string) *http.Response {
|
||||||
|
return &http.Response{StatusCode: status, Header: http.Header{"Retry-After": []string{seconds}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
scenario string
|
||||||
|
resp *http.Response
|
||||||
|
want time.Duration
|
||||||
|
}{
|
||||||
|
{"429 with an hour is clamped", retryAfter(http.StatusTooManyRequests, "3600"), max},
|
||||||
|
{"429 with a day is clamped", retryAfter(http.StatusTooManyRequests, "86400"), max},
|
||||||
|
{"503 with an hour is clamped", retryAfter(http.StatusServiceUnavailable, "3600"), max},
|
||||||
|
{"429 under the ceiling is honored", retryAfter(http.StatusTooManyRequests, "5"), 5 * time.Second},
|
||||||
|
{"no response falls back to exponential", nil, min},
|
||||||
|
} {
|
||||||
|
t.Run(tc.scenario, func(t *testing.T) {
|
||||||
|
got := ClampedBackoff(min, max, 0, tc.resp)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Fatalf("ClampedBackoff = %s, want %s", got, tc.want)
|
||||||
|
}
|
||||||
|
if got > max {
|
||||||
|
t.Fatalf("ClampedBackoff = %s, which exceeds max %s", got, max)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A negative max means the caller's budget is spent. The backoff must not
|
||||||
|
// return a negative duration, which would make the retry loop spin.
|
||||||
|
func TestClampedBackoff_NegativeMaxIsNotNegative(t *testing.T) {
|
||||||
|
got := ClampedBackoff(1*time.Second, -5*time.Second, 0, nil)
|
||||||
|
if got < 0 {
|
||||||
|
t.Fatalf("ClampedBackoff = %s, want a non-negative duration", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewOutboundHttpClient_NonPositiveTimeout(t *testing.T) {
|
||||||
|
// Zero stays unbounded: the LibreOffice proxy owns its own lifetime and
|
||||||
|
// passes it deliberately.
|
||||||
|
if got := NewOutboundHttpClient(0, nil, nil, false).Timeout; got != 0 {
|
||||||
|
t.Fatalf("timeout for 0 = %s, want 0", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negative means an expired budget. http.Client reads any non-positive
|
||||||
|
// Timeout as no deadline at all, so it must not be passed through.
|
||||||
|
if got := NewOutboundHttpClient(-5*time.Second, nil, nil, false).Timeout; got <= 0 {
|
||||||
|
t.Fatalf("timeout for a negative budget = %s, want a positive value so the client fails closed", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -280,7 +280,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
|
|
||||||
logger.DebugContext(dlCtx, fmt.Sprintf("download file from '%s'", dl.Url))
|
logger.DebugContext(dlCtx, fmt.Sprintf("download file from '%s'", dl.Url))
|
||||||
|
|
||||||
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
|
// The request must carry dlCtx: retryablehttp.NewRequest builds
|
||||||
|
// on context.Background(), and its wait between attempts is a
|
||||||
|
// select on the request context, so a contextless request cannot
|
||||||
|
// be interrupted by --api-timeout (env API_TIMEOUT) or by the
|
||||||
|
// caller going away.
|
||||||
|
req, err := retryablehttp.NewRequestWithContext(dlCtx, http.MethodGet, dl.Url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dlSpan.RecordError(err)
|
dlSpan.RecordError(err)
|
||||||
dlSpan.SetStatus(codes.Error, err.Error())
|
dlSpan.SetStatus(codes.Error, err.Error())
|
||||||
@@ -303,14 +308,28 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Entries are serialized by the concurrency limit above, so a
|
||||||
|
// late one can start after the deadline has already passed.
|
||||||
|
// Fail closed rather than derive a non-positive timeout, which
|
||||||
|
// [http.Client] reads as no deadline at all.
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 {
|
||||||
|
dlSpan.RecordError(context.DeadlineExceeded)
|
||||||
|
dlSpan.SetStatus(codes.Error, context.DeadlineExceeded.Error())
|
||||||
|
dlSpan.End()
|
||||||
|
return fmt.Errorf("download file from '%s': %w", dl.Url, context.DeadlineExceeded)
|
||||||
|
}
|
||||||
|
|
||||||
client := &retryablehttp.Client{
|
client := &retryablehttp.Client{
|
||||||
HTTPClient: gotenberg.NewOutboundHttpClient(time.Until(deadline), downloadFromCfg.allowList, downloadFromCfg.denyList, downloadFromCfg.enableEnvironmentProxy, ipOpts...),
|
HTTPClient: gotenberg.NewOutboundHttpClient(remaining, downloadFromCfg.allowList, downloadFromCfg.denyList, downloadFromCfg.enableEnvironmentProxy, ipOpts...),
|
||||||
RetryMax: downloadFromCfg.maxRetry,
|
RetryMax: downloadFromCfg.maxRetry,
|
||||||
RetryWaitMin: time.Duration(1) * time.Second,
|
RetryWaitMin: time.Duration(1) * time.Second,
|
||||||
RetryWaitMax: time.Until(deadline),
|
RetryWaitMax: remaining,
|
||||||
Logger: gotenberg.NewLeveledLogger(logger),
|
Logger: gotenberg.NewLeveledLogger(logger),
|
||||||
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
CheckRetry: retryablehttp.DefaultRetryPolicy,
|
||||||
Backoff: retryablehttp.DefaultBackoff,
|
// Not DefaultBackoff: it hands a hostile origin control of
|
||||||
|
// the wait via Retry-After.
|
||||||
|
Backoff: gotenberg.ClampedBackoff,
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
|||||||
@@ -414,3 +414,117 @@ func TestContext_FileCount(t *testing.T) {
|
|||||||
t.Errorf("expected 3 files, got %d", got)
|
t.Errorf("expected 3 files, got %d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A hostile origin must not choose how long Gotenberg waits.
|
||||||
|
// [retryablehttp.DefaultBackoff] returns a Retry-After header verbatim for 429
|
||||||
|
// and 503, and the wait between attempts is a select on the request context.
|
||||||
|
// Building the request without a context therefore pinned the goroutine, its
|
||||||
|
// connection, and its working directory for the attacker's chosen duration,
|
||||||
|
// well past --api-timeout (env API_TIMEOUT).
|
||||||
|
func TestNewContext_DownloadFromHostileRetryAfterIsBounded(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Retry-After", "3600")
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
payload, err := json.Marshal([]downloadFrom{{Url: server.URL + "/file"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal downloadFrom payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
err = writer.WriteField("downloadFrom", string(payload))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write downloadFrom field: %v", err)
|
||||||
|
}
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
|
||||||
|
const timeout = 500 * time.Millisecond
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, cancel, err := newContext(echoCtx, logger, fs, timeout, 0, downloadFromConfig{maxRetry: 2})
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if cancel != nil {
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected newContext to fail against an origin that only answers 429")
|
||||||
|
}
|
||||||
|
// Generous: the deadline is 500ms and Retry-After asks for an hour. Any
|
||||||
|
// value in seconds means the remote is still in control.
|
||||||
|
if elapsed > 10*time.Second {
|
||||||
|
t.Fatalf("newContext took %s with Retry-After 3600; --api-timeout must bound it", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An entry that starts after the deadline has passed must fail closed. It used
|
||||||
|
// to derive a negative client timeout, which [http.Client] reads as no
|
||||||
|
// deadline at all, leaving the download unbounded.
|
||||||
|
func TestNewContext_DownloadFromExpiredBudgetFailsClosed(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
<-r.Context().Done()
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Two entries, serialized by the concurrency limit, so the second one
|
||||||
|
// starts once the first has burned the whole budget.
|
||||||
|
payload, err := json.Marshal([]downloadFrom{
|
||||||
|
{Url: server.URL + "/first"},
|
||||||
|
{Url: server.URL + "/second"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal downloadFrom payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := new(bytes.Buffer)
|
||||||
|
writer := multipart.NewWriter(body)
|
||||||
|
err = writer.WriteField("downloadFrom", string(payload))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("write downloadFrom field: %v", err)
|
||||||
|
}
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("close multipart writer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/forms/libreoffice/convert", body)
|
||||||
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
|
||||||
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
||||||
|
logger := slog.New(slog.DiscardHandler)
|
||||||
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, cancel, err := newContext(echoCtx, logger, fs, 400*time.Millisecond, 0, downloadFromConfig{
|
||||||
|
maxRetry: 0,
|
||||||
|
maxConcurrency: 1,
|
||||||
|
})
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected newContext to fail against a stalling origin")
|
||||||
|
}
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
t.Fatal("newContext never returned: an entry starting past the deadline built an unbounded client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user