From c204cadfc5d62df37728344fa5828b59f372f946 Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Tue, 21 Apr 2026 20:11:31 +0200 Subject: [PATCH] fix(pdfengines): require uploaded stamp/watermark file for image or pdf source --- pkg/modules/chromium/routes.go | 30 +++--- pkg/modules/libreoffice/routes.go | 10 +- pkg/modules/pdfengines/routes.go | 92 +++++++++++++------ .../features/chromium_convert_html.feature | 24 +++++ .../chromium_convert_markdown.feature | 26 ++++++ .../features/chromium_convert_url.feature | 26 ++++++ .../features/libreoffice_convert.feature | 24 +++++ .../features/pdfengines_merge.feature | 26 ++++++ .../features/pdfengines_split.feature | 28 ++++++ 9 files changed, 240 insertions(+), 46 deletions(-) diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index a6cf0731..33d1e78d 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -465,11 +465,13 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("reject URL scheme: %w", err) } - if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" { - watermark.Expression = watermarkFile + err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile) + if err != nil { + return fmt.Errorf("validate watermark: %w", err) } - if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" { - stamp.Expression = stampFile + err = pdfengines.EnsureStampFile(&stamp, stampFile) + if err != nil { + return fmt.Errorf("validate stamp: %w", err) } err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, watermark, stamp, rotateAngle, rotatePages) @@ -546,11 +548,13 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" { - watermark.Expression = watermarkFile + err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile) + if err != nil { + return fmt.Errorf("validate watermark: %w", err) } - if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" { - stamp.Expression = stampFile + err = pdfengines.EnsureStampFile(&stamp, stampFile) + if err != nil { + return fmt.Errorf("validate stamp: %w", err) } url := fmt.Sprintf("file://%s", inputPath) @@ -631,11 +635,13 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" { - watermark.Expression = watermarkFile + err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile) + if err != nil { + return fmt.Errorf("validate watermark: %w", err) } - if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" { - stamp.Expression = stampFile + err = pdfengines.EnsureStampFile(&stamp, stampFile) + if err != nil { + return fmt.Errorf("validate stamp: %w", err) } url, err := markdownToHtml(ctx, inputPath, markdownPaths) diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index 896f2a3b..a192f772 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -304,11 +304,13 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap return fmt.Errorf("validate form data: %w", err) } - if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" { - watermark.Expression = watermarkFile + err = pdfengines.EnsureWatermarkFile(&watermark, watermarkFile) + if err != nil { + return fmt.Errorf("validate watermark: %w", err) } - if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" { - stamp.Expression = stampFile + err = pdfengines.EnsureStampFile(&stamp, stampFile) + if err != nil { + return fmt.Errorf("validate stamp: %w", err) } err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths) diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 4365ef71..9af8e3da 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -608,6 +608,50 @@ func FormDataPdfStampFile(form *api.FormData) string { return path } +// EnsureStampFile validates that, when stamp.Source is image or pdf, an +// uploaded stamp file was supplied, and replaces stamp.Expression with +// uploadedFile in that case. Returning an [api] HTTP 400 error prevents +// an anonymous caller from passing an arbitrary filesystem path via +// stampExpression and having pdfcpu read it. Source values of text or +// empty are passed through unchanged. +func EnsureStampFile(stamp *gotenberg.Stamp, uploadedFile string) error { + if stamp.Source != gotenberg.StampSourceImage && stamp.Source != gotenberg.StampSourcePDF { + return nil + } + if uploadedFile == "" { + return api.WrapError( + errors.New("no stamp file provided for image or pdf source"), + api.NewSentinelHttpError( + http.StatusBadRequest, + "Invalid form data: a stamp file is required for image or pdf source", + ), + ) + } + stamp.Expression = uploadedFile + return nil +} + +// EnsureWatermarkFile mirrors [EnsureStampFile] for a watermark. The +// shape is identical: image or pdf sources must be accompanied by an +// uploaded file, and the file path replaces watermark.Expression to +// prevent pdfcpu from reading an attacker-controlled path. +func EnsureWatermarkFile(watermark *gotenberg.Stamp, uploadedFile string) error { + if watermark.Source != gotenberg.StampSourceImage && watermark.Source != gotenberg.StampSourcePDF { + return nil + } + if uploadedFile == "" { + return api.WrapError( + errors.New("no watermark file provided for image or pdf source"), + api.NewSentinelHttpError( + http.StatusBadRequest, + "Invalid form data: a watermark file is required for image or pdf source", + ), + ) + } + watermark.Expression = uploadedFile + return nil +} + // WatermarkStub applies a watermark to a list of PDF files. If the stamp has // no source, it does nothing. func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error { @@ -676,11 +720,13 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" { - watermark.Expression = watermarkFile + err = EnsureWatermarkFile(&watermark, watermarkFile) + if err != nil { + return fmt.Errorf("validate watermark: %w", err) } - if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" { - stamp.Expression = stampFile + err = EnsureStampFile(&stamp, stampFile) + if err != nil { + return fmt.Errorf("validate stamp: %w", err) } err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths) @@ -831,11 +877,13 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" { - watermark.Expression = watermarkFile + err = EnsureWatermarkFile(&watermark, watermarkFile) + if err != nil { + return fmt.Errorf("validate watermark: %w", err) } - if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" { - stamp.Expression = stampFile + err = EnsureStampFile(&stamp, stampFile) + if err != nil { + return fmt.Errorf("validate stamp: %w", err) } err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths) @@ -1268,17 +1316,9 @@ func watermarkRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF { - if watermarkFile == "" { - return api.WrapError( - errors.New("no watermark file provided"), - api.NewSentinelHttpError( - http.StatusBadRequest, - "Invalid form data: a watermark file is required for image or pdf source", - ), - ) - } - stamp.Expression = watermarkFile + err = EnsureWatermarkFile(&stamp, watermarkFile) + if err != nil { + return fmt.Errorf("validate watermark: %w", err) } err = WatermarkStub(ctx, engine, stamp, inputPaths) @@ -1319,17 +1359,9 @@ func stampRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF { - if stampFile == "" { - return api.WrapError( - errors.New("no stamp file provided"), - api.NewSentinelHttpError( - http.StatusBadRequest, - "Invalid form data: a stamp file is required for image or pdf source", - ), - ) - } - stamp.Expression = stampFile + err = EnsureStampFile(&stamp, stampFile) + if err != nil { + return fmt.Errorf("validate stamp: %w", err) } err = StampStub(ctx, engine, stamp, inputPaths) diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature index a4567f8a..e8b14149 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -1188,6 +1188,30 @@ Feature: /forms/chromium/convert/html Then the "foo.pdf" PDF should have 1 page(s) Then the "foo.pdf" PDF should have 1 image(s) + Scenario: POST /forms/chromium/convert/html (stampSource=pdf without uploaded stamp file => 400) + 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/page-1-html/index.html | file | + | stampSource | pdf | field | + | stampExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a stamp file is required for image or pdf source + """ + + Scenario: POST /forms/chromium/convert/html (watermarkSource=pdf without uploaded watermark file => 400) + 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/page-1-html/index.html | file | + | watermarkSource | pdf | field | + | watermarkExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a watermark file is required for image or pdf source + """ + # See: https://github.com/gotenberg/gotenberg/issues/1500. Scenario: POST /forms/chromium/convert/html (Long Filename) Given I have a default Gotenberg container diff --git a/test/integration/features/chromium_convert_markdown.feature b/test/integration/features/chromium_convert_markdown.feature index 52fe1690..26e2cb41 100644 --- a/test/integration/features/chromium_convert_markdown.feature +++ b/test/integration/features/chromium_convert_markdown.feature @@ -1153,6 +1153,32 @@ Feature: /forms/chromium/convert/markdown Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" + Scenario: POST /forms/chromium/convert/markdown (stampSource=pdf without uploaded stamp file => 400) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | stampSource | pdf | field | + | stampExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a stamp file is required for image or pdf source + """ + + Scenario: POST /forms/chromium/convert/markdown (watermarkSource=pdf without uploaded watermark file => 400) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | watermarkSource | pdf | field | + | watermarkExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a watermark file is required for image or pdf source + """ + # See: https://github.com/gotenberg/gotenberg/issues/1500. Scenario: POST /forms/chromium/convert/markdown (Long Filename) Given I have a default Gotenberg container diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature index c6540b95..b55a187c 100644 --- a/test/integration/features/chromium_convert_url.feature +++ b/test/integration/features/chromium_convert_url.feature @@ -1269,6 +1269,32 @@ Feature: /forms/chromium/convert/url Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" + Scenario: POST /forms/chromium/convert/url (stampSource=pdf without uploaded stamp file => 400) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | stampSource | pdf | field | + | stampExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a stamp file is required for image or pdf source + """ + + Scenario: POST /forms/chromium/convert/url (watermarkSource=pdf without uploaded watermark file => 400) + Given I have a default Gotenberg container + Given I have a static server + When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | watermarkSource | pdf | field | + | watermarkExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a watermark file is required for image or pdf source + """ + # See: https://github.com/gotenberg/gotenberg/issues/1500. Scenario: POST /forms/chromium/convert/url (Long Filename) Given I have a default Gotenberg container diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature index d70bab66..48c818e8 100644 --- a/test/integration/features/libreoffice_convert.feature +++ b/test/integration/features/libreoffice_convert.feature @@ -817,6 +817,30 @@ Feature: /forms/libreoffice/convert Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" + Scenario: POST /forms/libreoffice/convert (stampSource=pdf without uploaded stamp file => 400) + Given I have a default Gotenberg container + 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 | + | stampSource | pdf | field | + | stampExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a stamp file is required for image or pdf source + """ + + Scenario: POST /forms/libreoffice/convert (watermarkSource=pdf without uploaded watermark file => 400) + Given I have a default Gotenberg container + 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 | + | watermarkSource | pdf | field | + | watermarkExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a watermark file is required for image or pdf source + """ + # See: https://github.com/gotenberg/gotenberg/issues/1500. Scenario: POST /forms/libreoffice/convert (Long Filename) Given I have a default Gotenberg container diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature index 56930468..ac584bf1 100644 --- a/test/integration/features/pdfengines_merge.feature +++ b/test/integration/features/pdfengines_merge.feature @@ -665,6 +665,32 @@ Feature: /forms/pdfengines/merge | embeds | testdata/embed_1.xml | file | Then the response status code should be 200 + Scenario: POST /forms/pdfengines/merge (stampSource=pdf without uploaded stamp file => 400) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | stampSource | pdf | field | + | stampExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a stamp file is required for image or pdf source + """ + + Scenario: POST /forms/pdfengines/merge (watermarkSource=pdf without uploaded watermark file => 400) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | watermarkSource | pdf | field | + | watermarkExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a watermark file is required for image or pdf source + """ + # See: https://github.com/gotenberg/gotenberg/issues/1500. Scenario: POST /forms/pdfengines/merge (Long Filename) Given I have a default Gotenberg container diff --git a/test/integration/features/pdfengines_split.feature b/test/integration/features/pdfengines_split.feature index f4cd59e1..fb34040f 100644 --- a/test/integration/features/pdfengines_split.feature +++ b/test/integration/features/pdfengines_split.feature @@ -767,6 +767,34 @@ Feature: /forms/pdfengines/split | embeds | testdata/embed_1.xml | file | Then the response status code should be 200 + Scenario: POST /forms/pdfengines/split (stampSource=pdf without uploaded stamp file => 400) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | stampSource | pdf | field | + | stampExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a stamp file is required for image or pdf source + """ + + Scenario: POST /forms/pdfengines/split (watermarkSource=pdf without uploaded watermark file => 400) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | watermarkSource | pdf | field | + | watermarkExpression | /etc/hostname | field | + Then the response status code should be 400 + Then the response body should match string: + """ + Invalid form data: a watermark file is required for image or pdf source + """ + # See: https://github.com/gotenberg/gotenberg/issues/1500. Scenario: POST /forms/pdfengines/split (Long Filename) Given I have a default Gotenberg container