mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-08 05:23:18 +01:00
905 lines
28 KiB
Go
905 lines
28 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/dlclark/regexp2/v2"
|
|
"github.com/labstack/echo/v5"
|
|
|
|
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
|
|
)
|
|
|
|
// Propagate HTTP request context cancellation to processing modules to save resources
|
|
// https://github.com/gotenberg/gotenberg/issues/1455
|
|
func TestNewContext_Cancellation(t *testing.T) {
|
|
e := echo.New()
|
|
|
|
body := new(bytes.Buffer)
|
|
writer := multipart.NewWriter(body)
|
|
err := writer.Close()
|
|
if err != nil {
|
|
t.Fatalf("failed to close multipart writer: %v", err)
|
|
}
|
|
|
|
// Create a request with a cancellable context.
|
|
reqCtx, cancelReq := context.WithCancel(context.Background())
|
|
req := httptest.NewRequest(http.MethodPost, "/", body).WithContext(reqCtx)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
|
|
rec := httptest.NewRecorder()
|
|
c := e.NewContext(req, rec)
|
|
|
|
logger := slog.New(slog.DiscardHandler)
|
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
|
timeout := time.Duration(10) * time.Second
|
|
downloadFromCfg := downloadFromConfig{
|
|
disable: true,
|
|
}
|
|
|
|
ctx, cancel, err := newContext(c, logger, fs, timeout, 0, downloadFromCfg)
|
|
if err != nil {
|
|
t.Fatalf("expected no error from newContext, got: %v", err)
|
|
}
|
|
defer cancel()
|
|
|
|
// Verify initial state: context SHOULD NOT be done yet.
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatal("context should not be done immediately")
|
|
default:
|
|
}
|
|
|
|
// Simulate Client Disconnect
|
|
cancelReq()
|
|
|
|
// Verify Propagation
|
|
select {
|
|
case <-ctx.Done():
|
|
// Success! The context was cancelled.
|
|
if ctx.Err() != context.Canceled {
|
|
t.Errorf("expected context error to be 'context.Canceled', got: %v", ctx.Err())
|
|
}
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("expected context to be cancelled after request context cancellation, but it timed out")
|
|
}
|
|
}
|
|
|
|
func TestNewContext_RemovesMultipartTemporaryFiles(t *testing.T) {
|
|
body := new(bytes.Buffer)
|
|
writer := multipart.NewWriter(body)
|
|
part, err := writer.CreateFormFile("files", "input.odt")
|
|
if err != nil {
|
|
t.Fatalf("create multipart file: %v", err)
|
|
}
|
|
_, err = part.Write(bytes.Repeat([]byte("x"), 1024))
|
|
if err != nil {
|
|
t.Fatalf("write multipart file: %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())
|
|
err = req.ParseMultipartForm(1)
|
|
if err != nil {
|
|
t.Fatalf("parse multipart form: %v", err)
|
|
}
|
|
defer func() {
|
|
_ = req.MultipartForm.RemoveAll()
|
|
}()
|
|
|
|
upload, err := req.MultipartForm.File["files"][0].Open()
|
|
if err != nil {
|
|
t.Fatalf("open disk-backed multipart file: %v", err)
|
|
}
|
|
temporaryFile, ok := upload.(*os.File)
|
|
if !ok {
|
|
_ = upload.Close()
|
|
t.Fatal("multipart upload is not disk-backed")
|
|
}
|
|
temporaryPath := temporaryFile.Name()
|
|
err = temporaryFile.Close()
|
|
if err != nil {
|
|
t.Fatalf("close disk-backed multipart file: %v", err)
|
|
}
|
|
|
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
|
logger := slog.New(slog.DiscardHandler)
|
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
|
downloadFromCfg := downloadFromConfig{disable: true}
|
|
|
|
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
|
if err != nil {
|
|
t.Fatalf("newContext returned error: %v", err)
|
|
}
|
|
defer cancel()
|
|
|
|
_, err = os.Stat(temporaryPath)
|
|
if !os.IsNotExist(err) {
|
|
t.Fatalf("multipart temporary file still exists: %s", temporaryPath)
|
|
}
|
|
}
|
|
|
|
// Concurrent downloadFrom entries must not race on the shared maps
|
|
// (ctx.files, ctx.diskToOriginal, ctx.filesByField). Run under -race
|
|
// to catch the data race; without -race a sufficient number of entries
|
|
// still surfaces "fatal error: concurrent map writes".
|
|
func TestNewContext_DownloadFromConcurrentMapWrites(t *testing.T) {
|
|
const downloads = 64
|
|
|
|
var ready sync.WaitGroup
|
|
ready.Add(downloads)
|
|
release := make(chan struct{})
|
|
var releaseOnce sync.Once
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ready.Done()
|
|
go func() {
|
|
ready.Wait()
|
|
releaseOnce.Do(func() { close(release) })
|
|
}()
|
|
<-release
|
|
|
|
filename := fmt.Sprintf("download-%s.txt", r.URL.Query().Get("i"))
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
|
_, _ = w.Write([]byte("downloaded"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
dls := make([]downloadFrom, downloads)
|
|
for i := range dls {
|
|
dls[i] = downloadFrom{
|
|
Url: fmt.Sprintf("%s/file?i=%d", server.URL, i),
|
|
Field: "embedded",
|
|
}
|
|
}
|
|
|
|
payload, err := json.Marshal(dls)
|
|
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))
|
|
downloadFromCfg := downloadFromConfig{
|
|
maxRetry: 0,
|
|
}
|
|
|
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
|
if err != nil {
|
|
t.Fatalf("newContext returned error: %v", err)
|
|
}
|
|
defer cancel()
|
|
|
|
if got := len(ctx.files); got != downloads {
|
|
t.Fatalf("downloaded files = %d, want %d", got, downloads)
|
|
}
|
|
if got := len(ctx.diskToOriginal); got != downloads {
|
|
t.Fatalf("diskToOriginal entries = %d, want %d", got, downloads)
|
|
}
|
|
if got := len(ctx.filesByField[EmbedsFormField]); got != downloads {
|
|
t.Fatalf("filesByField[%q] entries = %d, want %d", EmbedsFormField, got, downloads)
|
|
}
|
|
}
|
|
|
|
// An oversized downloadFrom array must be rejected at the trust boundary with
|
|
// a 400, before any download goroutine is spawned.
|
|
// https://github.com/gotenberg/gotenberg/security/advisories/GHSA-6vqw-2jgm-4x88
|
|
func TestNewContext_DownloadFromMaxEntries(t *testing.T) {
|
|
var hits atomic.Int64
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
w.Header().Set("Content-Disposition", `attachment; filename="download.txt"`)
|
|
_, _ = w.Write([]byte("downloaded"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
dls := make([]downloadFrom, 3)
|
|
for i := range dls {
|
|
dls[i] = downloadFrom{Url: fmt.Sprintf("%s/file?i=%d", server.URL, i)}
|
|
}
|
|
|
|
payload, err := json.Marshal(dls)
|
|
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))
|
|
downloadFromCfg := downloadFromConfig{maxEntries: 2}
|
|
|
|
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
|
if cancel != nil {
|
|
defer cancel()
|
|
}
|
|
if err == nil {
|
|
t.Fatal("newContext returned no error, want a 400 for too many entries")
|
|
}
|
|
|
|
var httpErr HttpError
|
|
if !errors.As(err, &httpErr) {
|
|
t.Fatalf("error %v is not an HttpError", err)
|
|
}
|
|
if status, _ := httpErr.HttpError(); status != http.StatusBadRequest {
|
|
t.Fatalf("HTTP status = %d, want %d", status, http.StatusBadRequest)
|
|
}
|
|
if got := hits.Load(); got != 0 {
|
|
t.Fatalf("server hits = %d, want 0 (rejected before any download)", got)
|
|
}
|
|
}
|
|
|
|
// The number of in-flight downloadFrom fetches must never exceed the
|
|
// configured concurrency limit, regardless of array length.
|
|
// https://github.com/gotenberg/gotenberg/security/advisories/GHSA-6vqw-2jgm-4x88
|
|
func TestNewContext_DownloadFromMaxConcurrency(t *testing.T) {
|
|
const (
|
|
downloads = 8
|
|
maxConcurrency = 2
|
|
)
|
|
|
|
var current, peak atomic.Int64
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
inFlight := current.Add(1)
|
|
for {
|
|
observed := peak.Load()
|
|
if inFlight <= observed || peak.CompareAndSwap(observed, inFlight) {
|
|
break
|
|
}
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
current.Add(-1)
|
|
|
|
filename := fmt.Sprintf("download-%s.txt", r.URL.Query().Get("i"))
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
|
_, _ = w.Write([]byte("downloaded"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
dls := make([]downloadFrom, downloads)
|
|
for i := range dls {
|
|
dls[i] = downloadFrom{Url: fmt.Sprintf("%s/file?i=%d", server.URL, i)}
|
|
}
|
|
|
|
payload, err := json.Marshal(dls)
|
|
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))
|
|
downloadFromCfg := downloadFromConfig{maxConcurrency: maxConcurrency}
|
|
|
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromCfg)
|
|
if err != nil {
|
|
t.Fatalf("newContext returned error: %v", err)
|
|
}
|
|
defer cancel()
|
|
|
|
if got := len(ctx.files); got != downloads {
|
|
t.Fatalf("downloaded files = %d, want %d", got, downloads)
|
|
}
|
|
if got := peak.Load(); got > maxConcurrency {
|
|
t.Fatalf("peak concurrency = %d, want <= %d", got, maxConcurrency)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeFilename(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
scenario string
|
|
input string
|
|
expect string
|
|
}{
|
|
{
|
|
scenario: "plain filename is unchanged",
|
|
input: "report.pdf",
|
|
expect: "report.pdf",
|
|
},
|
|
{
|
|
scenario: "POSIX traversal is stripped",
|
|
input: "../../etc/passwd",
|
|
expect: "passwd",
|
|
},
|
|
{
|
|
scenario: "Windows traversal with backslashes is stripped",
|
|
input: `..\..\..\..\Windows\System32\evil.pdf`,
|
|
expect: "evil.pdf",
|
|
},
|
|
{
|
|
scenario: "mixed separators take the last segment",
|
|
input: `foo/bar\baz.pdf`,
|
|
expect: "baz.pdf",
|
|
},
|
|
{
|
|
scenario: "control characters are dropped",
|
|
input: "evil\x00\x07\x1f\x7f.pdf",
|
|
expect: "evil.pdf",
|
|
},
|
|
{
|
|
scenario: "NFC normalization collapses decomposed sequences",
|
|
// "e" + combining acute accent -> precomposed "é".
|
|
input: "café.pdf",
|
|
expect: "café.pdf",
|
|
},
|
|
{
|
|
scenario: "trailing backslash yields empty name",
|
|
input: `foo\`,
|
|
expect: "",
|
|
},
|
|
{
|
|
scenario: "empty input yields empty name",
|
|
input: "",
|
|
expect: "",
|
|
},
|
|
} {
|
|
t.Run(tc.scenario, func(t *testing.T) {
|
|
got := sanitizeFilename(tc.input)
|
|
if got != tc.expect {
|
|
t.Errorf("sanitizeFilename(%q) = %q, want %q", tc.input, got, tc.expect)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestContext_FileCount(t *testing.T) {
|
|
ctx := &Context{}
|
|
if got := ctx.FileCount(); got != 0 {
|
|
t.Errorf("expected 0 files, got %d", got)
|
|
}
|
|
|
|
ctx.files = map[string]string{
|
|
"index.html": "/work/index.html",
|
|
"header.html": "/work/header.html",
|
|
"styles.css": "/work/styles.css",
|
|
}
|
|
if got := ctx.FileCount(); got != 3 {
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestDecodeDownloadFrom(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
scenario string
|
|
raw string
|
|
maxEntries int
|
|
expectErr error
|
|
expectLen int
|
|
}{
|
|
{"empty array", `[]`, 10, nil, 0},
|
|
{"under the limit", `[{"url":"http://a"},{"url":"http://b"}]`, 10, nil, 2},
|
|
{"exactly the limit", `[{"url":"http://a"},{"url":"http://b"}]`, 2, nil, 2},
|
|
{"over the limit", `[{"url":"http://a"},{"url":"http://b"}]`, 1, errTooManyDownloadFromEntries, 0},
|
|
{"no limit", `[{"url":"http://a"},{"url":"http://b"}]`, 0, nil, 2},
|
|
{"not an array", `{"url":"http://a"}`, 10, nil, 0},
|
|
{"malformed", `[{"url":`, 10, nil, 0},
|
|
{"not json", `nope`, 10, nil, 0},
|
|
} {
|
|
t.Run(tc.scenario, func(t *testing.T) {
|
|
dls, err := decodeDownloadFrom(tc.raw, tc.maxEntries)
|
|
|
|
if tc.expectErr != nil {
|
|
if !errors.Is(err, tc.expectErr) {
|
|
t.Fatalf("error = %v, want %v", err, tc.expectErr)
|
|
}
|
|
return
|
|
}
|
|
if tc.scenario == "not an array" || tc.scenario == "malformed" || tc.scenario == "not json" {
|
|
if err == nil {
|
|
t.Fatalf("expected an error for %q", tc.raw)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(dls) != tc.expectLen {
|
|
t.Fatalf("decoded %d entries, want %d", len(dls), tc.expectLen)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A compact array costs three bytes per entry on the wire and expands by
|
|
// roughly seventy times once unmarshalled. Decoding must stop at the limit
|
|
// rather than materialize the whole array and count afterwards.
|
|
func TestDecodeDownloadFrom_StopsBeforeMaterializingTheArray(t *testing.T) {
|
|
const entries = 2_000_000
|
|
|
|
raw := "[" + strings.Repeat("{},", entries) + "{}]"
|
|
|
|
var before, after runtime.MemStats
|
|
runtime.GC()
|
|
runtime.ReadMemStats(&before)
|
|
|
|
_, err := decodeDownloadFrom(raw, 1000)
|
|
|
|
runtime.ReadMemStats(&after)
|
|
|
|
if !errors.Is(err, errTooManyDownloadFromEntries) {
|
|
t.Fatalf("error = %v, want errTooManyDownloadFromEntries", err)
|
|
}
|
|
|
|
// json.Unmarshal on the same input allocates hundreds of MiB. Bounded
|
|
// decoding should stay in the low single-digit MiB, so this threshold is
|
|
// deliberately loose and still fails loudly on a regression.
|
|
allocated := after.TotalAlloc - before.TotalAlloc
|
|
if allocated > 32<<20 {
|
|
t.Fatalf("decoding allocated %d MiB for a %d-entry array, want the limit to bound it", allocated>>20, entries)
|
|
}
|
|
t.Logf("allocated %d KiB decoding a %d-entry array with a limit of 1000", allocated>>10, entries)
|
|
}
|
|
|
|
// An asynchronous conversion outlives the [echo.Context]. Echo returns that
|
|
// context to a sync.Pool as soon as the handler returns, and
|
|
// outputFilenameMiddleware runs in srv.Pre on every request, including
|
|
// /health, so a later request overwrites the store. Reading the output
|
|
// filename from it after the fact returned another caller's value.
|
|
func TestContext_OutputFilename_SurvivesEchoContextRecycling(t *testing.T) {
|
|
body := new(bytes.Buffer)
|
|
writer := multipart.NewWriter(body)
|
|
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())
|
|
// What outputFilenameMiddleware does for this request.
|
|
echoCtx.Set("outputFilename", "victim")
|
|
|
|
logger := slog.New(slog.DiscardHandler)
|
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
|
|
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
|
if err != nil {
|
|
t.Fatalf("newContext returned error: %v", err)
|
|
}
|
|
defer cancel()
|
|
|
|
// Echo recycles the context and another request claims the store.
|
|
echoCtx.Set("outputFilename", "attacker-controlled")
|
|
|
|
if got := ctx.OutputFilename("/tmp/out.pdf"); got != "victim.pdf" {
|
|
t.Fatalf("OutputFilename = %q, want %q", got, "victim.pdf")
|
|
}
|
|
}
|
|
|
|
// A recycled context has a nil store, so the previous unguarded type assertion
|
|
// could panic. The snapshot must tolerate an absent value.
|
|
func TestContext_OutputFilename_NoHeader(t *testing.T) {
|
|
body := new(bytes.Buffer)
|
|
writer := multipart.NewWriter(body)
|
|
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())
|
|
|
|
// No Set call at all: the store holds nothing for "outputFilename".
|
|
echoCtx := echo.New().NewContext(req, httptest.NewRecorder())
|
|
|
|
logger := slog.New(slog.DiscardHandler)
|
|
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
|
|
|
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
|
if err != nil {
|
|
t.Fatalf("newContext returned error: %v", err)
|
|
}
|
|
defer cancel()
|
|
|
|
if got := ctx.OutputFilename("/tmp/out.pdf"); got != "out.pdf" {
|
|
t.Fatalf("OutputFilename = %q, want the original filename %q", got, "out.pdf")
|
|
}
|
|
}
|
|
|
|
func TestSafeExt(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
scenario string
|
|
filename string
|
|
want string
|
|
}{
|
|
{"ordinary extension", "report.pdf", ".pdf"},
|
|
{"no extension", "report", ""},
|
|
{"at the limit", "a." + strings.Repeat("x", maxDiskExtLength-1), "." + strings.Repeat("x", maxDiskExtLength-1)},
|
|
{"over the limit is dropped", "a." + strings.Repeat("x", 300), ""},
|
|
} {
|
|
t.Run(tc.scenario, func(t *testing.T) {
|
|
got := safeExt(tc.filename)
|
|
if got != tc.want {
|
|
t.Fatalf("safeExt(%q) = %q, want %q", tc.filename, got, tc.want)
|
|
}
|
|
// A UUID stem is 36 characters. The whole disk name must stay
|
|
// under NAME_MAX.
|
|
if len(got)+36 > 255 {
|
|
t.Fatalf("disk name would be %d characters, over NAME_MAX", len(got)+36)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// An upload whose extension exceeds NAME_MAX used to fail os.Create and return
|
|
// a bare 500. The extension is bounded, and the original name survives in
|
|
// diskToOriginal.
|
|
func TestNewContext_LongExtensionIsAccepted(t *testing.T) {
|
|
filename := "invoice." + strings.Repeat("x", 300)
|
|
|
|
body := new(bytes.Buffer)
|
|
writer := multipart.NewWriter(body)
|
|
part, err := writer.CreateFormFile("files", filename)
|
|
if err != nil {
|
|
t.Fatalf("create multipart file: %v", err)
|
|
}
|
|
_, err = part.Write([]byte("%PDF-1.4"))
|
|
if err != nil {
|
|
t.Fatalf("write multipart file: %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))
|
|
|
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
|
if cancel != nil {
|
|
defer cancel()
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("newContext returned error for a long extension: %v", err)
|
|
}
|
|
if got := len(ctx.files); got != 1 {
|
|
t.Fatalf("files = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
// A filename that cannot become a symlink (too long, "..", "/") must not fail
|
|
// the request. The symlink loop is best-effort, but its error escaped through
|
|
// the shared err variable, and ctx.files iterates randomly, so byte-identical
|
|
// requests gave different HTTP outcomes.
|
|
func TestNewContext_UnsymlinkableFilenameStillSucceeds(t *testing.T) {
|
|
for _, filename := range []string{
|
|
strings.Repeat("a", 300) + ".txt",
|
|
"..",
|
|
"/",
|
|
} {
|
|
t.Run(filename[:min(len(filename), 12)], func(t *testing.T) {
|
|
body := new(bytes.Buffer)
|
|
writer := multipart.NewWriter(body)
|
|
part, err := writer.CreateFormFile("files", filename)
|
|
if err != nil {
|
|
t.Fatalf("create multipart file: %v", err)
|
|
}
|
|
_, err = part.Write([]byte("%PDF-1.4"))
|
|
if err != nil {
|
|
t.Fatalf("write multipart file: %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))
|
|
|
|
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
|
if cancel != nil {
|
|
defer cancel()
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("newContext failed on a best-effort symlink for %q: %v", filename, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Two uploads sharing a filename must both reach the conversion. The second
|
|
// used to overwrite the first in ctx.files, so one file was silently dropped
|
|
// while both stayed on disk and counted against the body limit.
|
|
func TestNewContext_DuplicateFilenamesAreBothKept(t *testing.T) {
|
|
body := new(bytes.Buffer)
|
|
writer := multipart.NewWriter(body)
|
|
for _, content := range []string{"FIRST", "SECOND"} {
|
|
part, err := writer.CreateFormFile("files", "doc.pdf")
|
|
if err != nil {
|
|
t.Fatalf("create multipart file: %v", err)
|
|
}
|
|
_, err = part.Write([]byte(content))
|
|
if err != nil {
|
|
t.Fatalf("write multipart file: %v", err)
|
|
}
|
|
}
|
|
err := writer.Close()
|
|
if err != nil {
|
|
t.Fatalf("close multipart writer: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/forms/pdfengines/merge", 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))
|
|
|
|
ctx, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{disable: true})
|
|
if err != nil {
|
|
t.Fatalf("newContext returned error: %v", err)
|
|
}
|
|
defer cancel()
|
|
|
|
if got := len(ctx.files); got != 2 {
|
|
t.Fatalf("ctx.files = %d entries, want 2: a duplicate filename dropped a file", got)
|
|
}
|
|
if got := len(ctx.filesByField["files"]); got != 2 {
|
|
t.Fatalf("filesByField[files] = %d entries, want 2", got)
|
|
}
|
|
|
|
// The two maps must agree, and both files must be distinct on disk.
|
|
seen := make(map[string]struct{})
|
|
for _, path := range ctx.files {
|
|
if _, ok := ctx.diskToOriginal[path]; !ok {
|
|
t.Fatalf("path %q has no diskToOriginal entry", path)
|
|
}
|
|
seen[path] = struct{}{}
|
|
}
|
|
if len(seen) != 2 {
|
|
t.Fatalf("distinct disk paths = %d, want 2", len(seen))
|
|
}
|
|
}
|
|
|
|
// A redirect target is filtered inside the HTTP client, so the policy verdict
|
|
// surfaces from client.Do rather than from the pre-flight check. It used to be
|
|
// interpolated into the response body, so a redirect described the allow-list,
|
|
// the deny-list or the IP policy where the first hop returns a generic 403.
|
|
func TestNewContext_DownloadFromRedirectVerdictStaysGeneric(t *testing.T) {
|
|
private := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Disposition", `attachment; filename="secret.txt"`)
|
|
_, _ = w.Write([]byte("internal"))
|
|
}))
|
|
defer private.Close()
|
|
|
|
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, private.URL+"/secret", http.StatusFound)
|
|
}))
|
|
defer redirector.Close()
|
|
|
|
payload, err := json.Marshal([]downloadFrom{{Url: redirector.URL + "/start"}})
|
|
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))
|
|
|
|
// The first hop is allowed, the redirect target is denied by the deny-list.
|
|
denyList := []*regexp2.Regexp{regexp2.MustCompile("^"+regexp.QuoteMeta(private.URL), regexp2.None)}
|
|
|
|
_, cancel, err := newContext(echoCtx, logger, fs, 10*time.Second, 0, downloadFromConfig{
|
|
denyList: denyList,
|
|
maxRetry: 0,
|
|
})
|
|
if cancel != nil {
|
|
defer cancel()
|
|
}
|
|
if err == nil {
|
|
t.Fatal("expected the redirect to a denied host to fail")
|
|
}
|
|
|
|
status, message := ParseError(err)
|
|
if status != http.StatusForbidden {
|
|
t.Fatalf("status = %d, want %d: a filtered redirect must answer like a filtered first hop", status, http.StatusForbidden)
|
|
}
|
|
if message != http.StatusText(http.StatusForbidden) {
|
|
t.Fatalf("message = %q, want the generic %q", message, http.StatusText(http.StatusForbidden))
|
|
}
|
|
// The response must not name the policy, the pattern, or the blocked host.
|
|
for _, leak := range []string{"denied list", "allowed list", "non-public", "expression", private.URL} {
|
|
if strings.Contains(message, leak) {
|
|
t.Fatalf("response message %q leaks %q", message, leak)
|
|
}
|
|
}
|
|
}
|