From 3b1e4cbac4961b2bfc7b40fdd666ed0f508a570f Mon Sep 17 00:00:00 2001 From: Julien Neuhart Date: Sat, 6 Jun 2026 14:03:58 +0200 Subject: [PATCH] feat(pdfengines): support owner-only encryption and document permissions --- .bruno/Chromium/Convert/HTML to PDF.bru | 6 ++ .bruno/Chromium/Convert/Markdown to PDF.bru | 6 ++ .bruno/Chromium/Convert/URL to PDF.bru | 6 ++ .bruno/LibreOffice/Convert to PDF.bru | 6 ++ .bruno/PDF Engines/Encrypt/Encrypt PDF.bru | 6 ++ .bruno/PDF Engines/Merge/Merge PDFs.bru | 6 ++ .bruno/PDF Engines/Split/Split PDF.bru | 6 ++ pkg/gotenberg/mocks.go | 6 +- pkg/gotenberg/pdfengine.go | 60 ++++++++++++-- pkg/modules/chromium/routes.go | 23 +++--- pkg/modules/exiftool/exiftool.go | 2 +- .../libreoffice/pdfengine/pdfengine.go | 2 +- pkg/modules/libreoffice/routes.go | 11 ++- pkg/modules/pdfcpu/pdfcpu.go | 22 ++++-- pkg/modules/pdfengines/multi.go | 4 +- pkg/modules/pdfengines/routes.go | 79 ++++++++++++++----- pkg/modules/pdftk/pdftk.go | 12 +-- pkg/modules/qpdf/encrypt_test.go | 76 ++++++++++++++++++ pkg/modules/qpdf/qpdf.go | 52 +++++++++--- test/integration/README.md | 1 + .../features/pdfengines_encrypt.feature | 24 +++++- test/integration/scenario/scenario.go | 62 +++++++++++++++ 22 files changed, 411 insertions(+), 67 deletions(-) create mode 100644 pkg/modules/qpdf/encrypt_test.go diff --git a/.bruno/Chromium/Convert/HTML to PDF.bru b/.bruno/Chromium/Convert/HTML to PDF.bru index d340368..c0cb4a0 100644 --- a/.bruno/Chromium/Convert/HTML to PDF.bru +++ b/.bruno/Chromium/Convert/HTML to PDF.bru @@ -50,6 +50,12 @@ body:multipart-form { ~metadata: {"Author":"Bruno","Title":"Test"} ~userPassword: ~ownerPassword: + ~allowPrinting: false + ~allowCopying: false + ~allowModifying: false + ~allowAnnotating: false + ~allowFillingForms: false + ~allowAssembling: false ~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml) ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} diff --git a/.bruno/Chromium/Convert/Markdown to PDF.bru b/.bruno/Chromium/Convert/Markdown to PDF.bru index ca5e90a..65e0ff6 100644 --- a/.bruno/Chromium/Convert/Markdown to PDF.bru +++ b/.bruno/Chromium/Convert/Markdown to PDF.bru @@ -51,6 +51,12 @@ body:multipart-form { ~metadata: {"Author":"Bruno","Title":"Test"} ~userPassword: ~ownerPassword: + ~allowPrinting: false + ~allowCopying: false + ~allowModifying: false + ~allowAnnotating: false + ~allowFillingForms: false + ~allowAssembling: false ~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml) ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} diff --git a/.bruno/Chromium/Convert/URL to PDF.bru b/.bruno/Chromium/Convert/URL to PDF.bru index 7dd5d5a..8283c24 100644 --- a/.bruno/Chromium/Convert/URL to PDF.bru +++ b/.bruno/Chromium/Convert/URL to PDF.bru @@ -50,6 +50,12 @@ body:multipart-form { ~metadata: {"Author":"Bruno","Title":"Test"} ~userPassword: ~ownerPassword: + ~allowPrinting: false + ~allowCopying: false + ~allowModifying: false + ~allowAnnotating: false + ~allowFillingForms: false + ~allowAssembling: false ~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml) ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} diff --git a/.bruno/LibreOffice/Convert to PDF.bru b/.bruno/LibreOffice/Convert to PDF.bru index 5816480..1e82364 100644 --- a/.bruno/LibreOffice/Convert to PDF.bru +++ b/.bruno/LibreOffice/Convert to PDF.bru @@ -67,6 +67,12 @@ body:multipart-form { ~metadata: {"Author":"Bruno","Title":"Test"} ~userPassword: ~ownerPassword: + ~allowPrinting: false + ~allowCopying: false + ~allowModifying: false + ~allowAnnotating: false + ~allowFillingForms: false + ~allowAssembling: false ~embeds: @file(../test/integration/testdata/embed_1.xml) ~embeds: @file(../test/integration/testdata/embed_2.xml) ~embedsMetadata: {"embed_1.xml":{"mimeType":"text/xml","relationship":"Data"}, "embed_2.xml":{"mimeType":"text/xml","relationship":"Data"}} diff --git a/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru b/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru index 7ede2a7..3e36835 100644 --- a/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru +++ b/.bruno/PDF Engines/Encrypt/Encrypt PDF.bru @@ -14,6 +14,12 @@ body:multipart-form { files: @file(../../test/integration/testdata/page_1.pdf) userPassword: secret123 ~ownerPassword: owner456 + ~allowPrinting: false + ~allowCopying: false + ~allowModifying: false + ~allowAnnotating: false + ~allowFillingForms: false + ~allowAssembling: false } headers { diff --git a/.bruno/PDF Engines/Merge/Merge PDFs.bru b/.bruno/PDF Engines/Merge/Merge PDFs.bru index f01d18a..3923e15 100644 --- a/.bruno/PDF Engines/Merge/Merge PDFs.bru +++ b/.bruno/PDF Engines/Merge/Merge PDFs.bru @@ -21,6 +21,12 @@ body:multipart-form { ~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}] ~userPassword: ~ownerPassword: + ~allowPrinting: false + ~allowCopying: false + ~allowModifying: false + ~allowAnnotating: false + ~allowFillingForms: false + ~allowAssembling: false ~watermarkSource: text ~watermarkExpression: CONFIDENTIAL ~watermarkPages: diff --git a/.bruno/PDF Engines/Split/Split PDF.bru b/.bruno/PDF Engines/Split/Split PDF.bru index d83a239..6806b45 100644 --- a/.bruno/PDF Engines/Split/Split PDF.bru +++ b/.bruno/PDF Engines/Split/Split PDF.bru @@ -21,6 +21,12 @@ body:multipart-form { ~metadata: {"Author":"Bruno","Title":"Test"} ~userPassword: ~ownerPassword: + ~allowPrinting: false + ~allowCopying: false + ~allowModifying: false + ~allowAnnotating: false + ~allowFillingForms: false + ~allowAssembling: false ~watermarkSource: text ~watermarkExpression: CONFIDENTIAL ~watermarkPages: diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index 024f504..c84780f 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -53,7 +53,7 @@ type PdfEngineMock struct { PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error) - EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error + EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error EmbedFilesMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error @@ -96,8 +96,8 @@ func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Log return engine.ReadBookmarksMock(ctx, logger, inputPath) } -func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { - return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword) +func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error { + return engine.EncryptMock(ctx, logger, inputPath, opts) } func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index 23cf436..ef9a142 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -147,6 +147,56 @@ type PdfFormats struct { PdfUa bool } +// PdfPermissions gathers the document permissions enforced when a PDF is +// encrypted. Each field defaults to true (the action is allowed); set a field +// to false to restrict it. Restrictions are advisory: viewers honor them, but +// they are not cryptographically enforced once the document opens. +type PdfPermissions struct { + // AllowPrinting permits printing the document. + AllowPrinting bool + + // AllowCopying permits extracting text and graphics. + AllowCopying bool + + // AllowModifying permits changing the document content. + AllowModifying bool + + // AllowAnnotating permits adding or modifying annotations. + AllowAnnotating bool + + // AllowFillingForms permits filling in form fields. + AllowFillingForms bool + + // AllowAssembling permits inserting, deleting, and rotating pages. + AllowAssembling bool +} + +// Restricted reports whether at least one permission is denied. +func (p PdfPermissions) Restricted() bool { + return !p.AllowPrinting || + !p.AllowCopying || + !p.AllowModifying || + !p.AllowAnnotating || + !p.AllowFillingForms || + !p.AllowAssembling +} + +// EncryptOptions gathers the parameters for encrypting a PDF. An empty +// UserPassword with a set OwnerPassword produces an owner-only document: it +// opens without a password but enforces the [PdfPermissions]. +type EncryptOptions struct { + // UserPassword is required to open the document. Empty means no open + // password. + UserPassword string + + // OwnerPassword grants full access (lifts the permission restrictions). + // When empty, it defaults to UserPassword. + OwnerPassword string + + // Permissions are the actions allowed when opened with the user password. + Permissions PdfPermissions +} + // Bookmark represents a node in the PDF document's outline // (table of contents). type Bookmark struct { @@ -247,11 +297,11 @@ type PdfEngine interface { // The bookmarks parameter represents the hierarchical tree of the outline. WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error - // Encrypt adds password protection to a PDF file. - // The userPassword is required to open the document. - // The ownerPassword provides full access to the document. - // If the ownerPassword is empty, it defaults to the userPassword. - Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error + // Encrypt adds password protection and permission restrictions to a PDF + // file, as described by [EncryptOptions]. An empty user password with a set + // owner password yields an owner-only document (opens without a password, + // permissions enforced). + Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts EncryptOptions) error // EmbedFiles embeds files into a PDF. All files are embedded as file attachments // without modifying the main PDF content. diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index 1198e43..01ca707 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -446,7 +446,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { mode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) metadata := pdfengines.FormDataPdfMetadata(form, false) - userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) + encrypt := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) watermark := pdfengines.FormDataPdfWatermark(form, false) watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) @@ -478,7 +478,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate stamp: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages) if err != nil { return fmt.Errorf("convert URL to PDF: %w", err) } @@ -535,7 +535,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { mode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) metadata := pdfengines.FormDataPdfMetadata(form, false) - userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) + encrypt := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) watermark := pdfengines.FormDataPdfWatermark(form, false) watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) @@ -564,7 +564,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { url := fmt.Sprintf("file://%s", inputPath) options.AllowedFilePrefixes = []string{ctx.DirPath()} - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages) if err != nil { return fmt.Errorf("convert HTML to PDF: %w", err) } @@ -618,7 +618,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { mode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) metadata := pdfengines.FormDataPdfMetadata(form, false) - userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) + encrypt := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) watermark := pdfengines.FormDataPdfWatermark(form, false) watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) @@ -656,7 +656,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } options.AllowedFilePrefixes = []string{ctx.DirPath()} - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, encrypt, embedPaths, embedsMetadata, facturX, facturxXmlPath, watermark, stamp, rotateAngle, rotatePages) if err != nil { return fmt.Errorf("convert markdown to PDF: %w", err) } @@ -781,7 +781,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) return fmt.Sprintf("file://%s", inputPath), nil } -func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error { +func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, encrypt gotenberg.EncryptOptions, embedPaths []string, embedsMetadata map[string]map[string]string, facturX gotenberg.FacturX, facturxXmlPath string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error { outputPath := ctx.GeneratePath(".pdf") // See https://github.com/gotenberg/gotenberg/issues/1130. filename := ctx.OutputFilename(outputPath) @@ -843,7 +843,12 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url return fmt.Errorf("convert to PDF: %w", err) } - err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths) + err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths) + if err != nil { + return err + } + + err = pdfengines.ValidatePdfEncryptCompat(encrypt) if err != nil { return err } @@ -902,7 +907,7 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url return fmt.Errorf("apply Factur-X: %w", err) } - err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) + err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths) if err != nil { return fmt.Errorf("encrypt PDFs: %w", err) } diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index a5bab08..e639291 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -449,7 +449,7 @@ func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, } // Encrypt is not available in this implementation. -func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { +func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error { _, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt", trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes(semconv.ServerAddress(engine.binPath)), diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index 5d26aea..eeffb63 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -106,7 +106,7 @@ func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *s } // Encrypt is not available in this implementation. -func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { +func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error { return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index 4edd12f..f706a22 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -30,7 +30,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap splitMode := pdfengines.FormDataPdfSplitMode(form, false) pdfFormats := pdfengines.FormDataPdfFormats(form) metadata := pdfengines.FormDataPdfMetadata(form, false) - userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) + encrypt := pdfengines.FormDataPdfEncrypt(form) embedPaths := pdfengines.FormDataPdfEmbeds(form) watermark := pdfengines.FormDataPdfWatermark(form, false) watermarkFile := pdfengines.FormDataPdfWatermarkFile(form) @@ -314,7 +314,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap return fmt.Errorf("validate stamp: %w", err) } - err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths) + err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths) + if err != nil { + return err + } + + err = pdfengines.ValidatePdfEncryptCompat(encrypt) if err != nil { return err } @@ -518,7 +523,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap return fmt.Errorf("apply Factur-X: %w", err) } - err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) + err = pdfengines.EncryptPdfStub(ctx, engine, encrypt, outputPaths) if err != nil { return fmt.Errorf("encrypt PDFs: %w", err) } diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index 50d696a..68e27ae 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -503,30 +503,38 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, fileP } // Encrypt adds password protection to a PDF file using pdfcpu. -func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { +func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Encrypt", trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes(semconv.ServerAddress(engine.binPath)), ) defer span.End() - if userPassword == "" { - err := errors.New("user password cannot be empty") + ownerPassword := opts.OwnerPassword + if ownerPassword == "" { + ownerPassword = opts.UserPassword + } + + // An empty user password is allowed: it produces an owner-only document. + if opts.UserPassword == "" && ownerPassword == "" { + err := errors.New("at least a user or owner password is required") span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return err } - if ownerPassword == "" { - ownerPassword = userPassword + // pdfcpu only supports coarse permissions: all actions or none. + perm := "all" + if opts.Permissions.Restricted() { + perm = "none" } args := make([]string, 0, 11) args = append(args, "encrypt") args = append(args, "--mode", "aes") - args = append(args, "--upw", userPassword) + args = append(args, "--upw", opts.UserPassword) args = append(args, "--opw", ownerPassword) - args = append(args, "--perm", "all") + args = append(args, "--perm", perm) args = append(args, inputPath, inputPath) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index d0e34a1..7962619 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -259,10 +259,10 @@ func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *slog.L // Encrypt adds password protection to a PDF file using the first available // engine that supports password protection. -func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { +func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error { return runWithFallbackVoid(ctx, "pdfengines.Encrypt", multi.passwordEngines, func(ctx context.Context, engine gotenberg.PdfEngine) error { - return engine.Encrypt(ctx, logger, inputPath, userPassword, ownerPassword) + return engine.Encrypt(ctx, logger, inputPath, opts) }, func(err error) error { return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) }, ) diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index 14f5f4b..165d0b0 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -667,21 +667,44 @@ func InjectFacturXXMPStub(ctx *api.Context, engine gotenberg.PdfEngine, facturX return nil } -// FormDataPdfEncrypt extracts encryption parameters from form data. -func FormDataPdfEncrypt(form *api.FormData) (userPassword, ownerPassword string) { - form.String("userPassword", &userPassword, "") - form.String("ownerPassword", &ownerPassword, "") - return userPassword, ownerPassword +// FormDataPdfEncrypt extracts the encryption parameters and permissions from +// form data. Permissions default to allowed. +func FormDataPdfEncrypt(form *api.FormData) gotenberg.EncryptOptions { + var opts gotenberg.EncryptOptions + form. + String("userPassword", &opts.UserPassword, ""). + String("ownerPassword", &opts.OwnerPassword, ""). + Bool("allowPrinting", &opts.Permissions.AllowPrinting, true). + Bool("allowCopying", &opts.Permissions.AllowCopying, true). + Bool("allowModifying", &opts.Permissions.AllowModifying, true). + Bool("allowAnnotating", &opts.Permissions.AllowAnnotating, true). + Bool("allowFillingForms", &opts.Permissions.AllowFillingForms, true). + Bool("allowAssembling", &opts.Permissions.AllowAssembling, true) + return opts } -// EncryptPdfStub adds password protection to PDF files. -func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, ownerPassword string, inputPaths []string) error { - if userPassword == "" { +// ValidatePdfEncryptCompat returns a 400 error when permission restrictions are +// requested without a password to anchor them. +func ValidatePdfEncryptCompat(opts gotenberg.EncryptOptions) error { + if opts.Permissions.Restricted() && opts.UserPassword == "" && opts.OwnerPassword == "" { + return api.WrapError( + errors.New("permission restrictions require a password"), + api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: permission restrictions require a 'userPassword' or 'ownerPassword'"), + ) + } + + return nil +} + +// EncryptPdfStub adds password protection and permission restrictions to PDF +// files. It does nothing when no password is provided. +func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, opts gotenberg.EncryptOptions, inputPaths []string) error { + if opts.UserPassword == "" && opts.OwnerPassword == "" { return nil } for _, inputPath := range inputPaths { - err := engine.Encrypt(ctx, ctx.Log(), inputPath, userPassword, ownerPassword) + err := engine.Encrypt(ctx, ctx.Log(), inputPath, opts) if err != nil { return fmt.Errorf("encrypt PDF '%s': %w", inputPath, err) } @@ -899,7 +922,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { pdfFormats := FormDataPdfFormats(form) metadata := FormDataPdfMetadata(form, false) bookmarks := FormDataPdfBookmarks(form, false) - userPassword, ownerPassword := FormDataPdfEncrypt(form) + encrypt := FormDataPdfEncrypt(form) embedPaths := FormDataPdfEmbeds(form) watermark := FormDataPdfWatermark(form, false) watermarkFile := FormDataPdfWatermarkFile(form) @@ -930,7 +953,12 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate stamp: %w", err) } - err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths) + err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths) + if err != nil { + return err + } + + err = ValidatePdfEncryptCompat(encrypt) if err != nil { return err } @@ -1043,7 +1071,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("apply Factur-X: %w", err) } - err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) + err = EncryptPdfStub(ctx, engine, encrypt, outputPaths) if err != nil { return fmt.Errorf("encrypt PDFs: %w", err) } @@ -1071,7 +1099,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { mode := FormDataPdfSplitMode(form, true) pdfFormats := FormDataPdfFormats(form) metadata := FormDataPdfMetadata(form, false) - userPassword, ownerPassword := FormDataPdfEncrypt(form) + encrypt := FormDataPdfEncrypt(form) embedPaths := FormDataPdfEmbeds(form) watermark := FormDataPdfWatermark(form, false) watermarkFile := FormDataPdfWatermarkFile(form) @@ -1100,7 +1128,12 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate stamp: %w", err) } - err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths) + err = ValidatePdfFormatsCompat(pdfFormats, encrypt.UserPassword, embedPaths) + if err != nil { + return err + } + + err = ValidatePdfEncryptCompat(encrypt) if err != nil { return err } @@ -1166,7 +1199,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("apply Factur-X: %w", err) } - err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) + err = EncryptPdfStub(ctx, engine, encrypt, convertOutputPaths) if err != nil { return fmt.Errorf("encrypt PDFs: %w", err) } @@ -1450,20 +1483,26 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route { ctx := c.Get("context").(*api.Context) form := ctx.FormData() + encrypt := FormDataPdfEncrypt(form) var inputPaths []string - var userPassword string - var ownerPassword string err := form. MandatoryPaths([]string{".pdf"}, &inputPaths). - MandatoryString("userPassword", &userPassword). - String("ownerPassword", &ownerPassword, ""). Validate() if err != nil { return fmt.Errorf("validate form data: %w", err) } - err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, inputPaths) + // At least one password is required; an empty user password with an + // owner password yields an owner-only document. + if encrypt.UserPassword == "" && encrypt.OwnerPassword == "" { + return api.WrapError( + errors.New("no password provided"), + api.NewSentinelHttpError(http.StatusBadRequest, "Invalid form data: a 'userPassword' or 'ownerPassword' is required"), + ) + } + + err = EncryptPdfStub(ctx, engine, encrypt, inputPaths) if err != nil { return fmt.Errorf("encrypt PDFs: %w", err) } diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index dcc5767..244351d 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -259,21 +259,21 @@ func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inp } // Encrypt adds password protection to a PDF file using PDFtk. -func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { +func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Encrypt", trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes(semconv.ServerAddress(engine.binPath)), ) defer span.End() - if userPassword == "" { - err := errors.New("user password cannot be empty") + if opts.UserPassword == "" || opts.Permissions.Restricted() { + err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "owner-only encryption and permission restrictions are not supported; consider switching to another PDF engine (e.g. qpdf)") span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return err } - if ownerPassword == userPassword || ownerPassword == "" { + if opts.OwnerPassword == opts.UserPassword || opts.OwnerPassword == "" { err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow") span.RecordError(err) span.SetStatus(codes.Error, err.Error()) @@ -287,8 +287,8 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath args = append(args, inputPath) args = append(args, "output", tmpPath) args = append(args, "encrypt_128bit") - args = append(args, "user_pw", userPassword) - args = append(args, "owner_pw", ownerPassword) + args = append(args, "user_pw", opts.UserPassword) + args = append(args, "owner_pw", opts.OwnerPassword) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { diff --git a/pkg/modules/qpdf/encrypt_test.go b/pkg/modules/qpdf/encrypt_test.go new file mode 100644 index 0000000..42bf0e9 --- /dev/null +++ b/pkg/modules/qpdf/encrypt_test.go @@ -0,0 +1,76 @@ +package qpdf + +import ( + "reflect" + "testing" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" +) + +func TestQpdfPermissionArgs(t *testing.T) { + allAllowed := gotenberg.PdfPermissions{ + AllowPrinting: true, + AllowCopying: true, + AllowModifying: true, + AllowAnnotating: true, + AllowFillingForms: true, + AllowAssembling: true, + } + + for _, tc := range []struct { + scenario string + perms gotenberg.PdfPermissions + expect []string + }{ + { + scenario: "all allowed yields no flags", + perms: allAllowed, + expect: nil, + }, + { + scenario: "printing denied", + perms: gotenberg.PdfPermissions{ + AllowPrinting: false, + AllowCopying: true, + AllowModifying: true, + AllowAnnotating: true, + AllowFillingForms: true, + AllowAssembling: true, + }, + expect: []string{ + "--print=none", + "--extract=y", + "--modify-other=y", + "--annotate=y", + "--form=y", + "--assemble=y", + }, + }, + { + scenario: "copying denied", + perms: gotenberg.PdfPermissions{ + AllowPrinting: true, + AllowCopying: false, + AllowModifying: true, + AllowAnnotating: true, + AllowFillingForms: true, + AllowAssembling: true, + }, + expect: []string{ + "--print=full", + "--extract=n", + "--modify-other=y", + "--annotate=y", + "--form=y", + "--assemble=y", + }, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + got := qpdfPermissionArgs(tc.perms) + if !reflect.DeepEqual(got, tc.expect) { + t.Errorf("expected %v but got %v", tc.expect, got) + } + }) + } +} diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 753dac3..88e7168 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -295,29 +295,63 @@ func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inpu } // Encrypt adds password protection to a PDF file using QPDF. -func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { +// qpdfPermissionArgs maps PDF permissions to QPDF --encrypt restriction flags. +// It returns nil when every permission is allowed, matching QPDF's default +// (all actions permitted). +func qpdfPermissionArgs(p gotenberg.PdfPermissions) []string { + if !p.Restricted() { + return nil + } + + yn := func(allowed bool) string { + if allowed { + return "y" + } + return "n" + } + + print := "full" + if !p.AllowPrinting { + print = "none" + } + + return []string{ + "--print=" + print, + "--extract=" + yn(p.AllowCopying), + "--modify-other=" + yn(p.AllowModifying), + "--annotate=" + yn(p.AllowAnnotating), + "--form=" + yn(p.AllowFillingForms), + "--assemble=" + yn(p.AllowAssembling), + } +} + +func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error { ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Encrypt", trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes(semconv.ServerAddress(engine.binPath)), ) defer span.End() - if userPassword == "" { - err := errors.New("user password cannot be empty") + ownerPassword := opts.OwnerPassword + if ownerPassword == "" { + ownerPassword = opts.UserPassword + } + + // An empty user password is allowed: it produces an owner-only document. + if opts.UserPassword == "" && ownerPassword == "" { + err := errors.New("at least a user or owner password is required") span.RecordError(err) span.SetStatus(codes.Error, err.Error()) return err } - if ownerPassword == "" { - ownerPassword = userPassword - } - - args := make([]string, 0, 7+len(engine.globalArgs)) + args := make([]string, 0, 14+len(engine.globalArgs)) args = append(args, inputPath) args = append(args, engine.globalArgs...) args = append(args, "--replace-input") - args = append(args, "--encrypt", userPassword, ownerPassword, "256", "--") + args = append(args, "--encrypt", opts.UserPassword, ownerPassword, "256") + args = append(args, qpdfPermissionArgs(opts.Permissions)...) + args = append(args, "--") cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { diff --git a/test/integration/README.md b/test/integration/README.md index 0132c2f..29b1161 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -72,6 +72,7 @@ Available tags: - `the (response|webhook request) PDF(s) should be valid "" with a tolerance of failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`) - `the (response|webhook request) PDF(s) (should|should NOT) be flatten` - `the (response|webhook request) PDF(s) (should|should NOT) be encrypted` +- `the (response|webhook request) PDF(s) (should|should NOT) allow ""` (actions: `printing`, `copying`, `modifying`, `annotating`) - `the (response|webhook request) PDF(s) (should|should NOT) have the "" file embedded` - `the "" PDF should have image(s)` - `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings) diff --git a/test/integration/features/pdfengines_encrypt.feature b/test/integration/features/pdfengines_encrypt.feature index 62bc6a3..17b8636 100644 --- a/test/integration/features/pdfengines_encrypt.feature +++ b/test/integration/features/pdfengines_encrypt.feature @@ -113,9 +113,31 @@ Feature: /forms/pdfengines/encrypt Then the response status code should be 400 Then the response body should match string: """ - Invalid form data: form field 'userPassword' is required + Invalid form data: a 'userPassword' or 'ownerPassword' is required """ + # https://github.com/gotenberg/gotenberg/discussions/1571 + # Owner-only: opens without a password but restricts printing. + Scenario: POST /forms/pdfengines/encrypt (Owner-only, restrict printing) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | ownerPassword | owner-secret | field | + | allowPrinting | false | field | + Then the response status code should be 200 + Then the response header "Content-Type" should be "application/pdf" + Then there should be 1 PDF(s) in the response + Then the response PDF(s) should NOT be encrypted + Then the response PDF(s) should NOT allow "printing" + + # Permission restrictions need a password to anchor them. + Scenario: POST /forms/pdfengines/encrypt (Permissions without password) + Given I have a default Gotenberg container + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | allowPrinting | false | field | + Then the response status code should be 400 + Scenario: POST /forms/pdfengines/encrypt (Routes Disabled) Given I have a Gotenberg container with the following environment variable(s): | PDFENGINES_DISABLE_ROUTES | true | diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go index 967e4e4..1683a27 100644 --- a/test/integration/scenario/scenario.go +++ b/test/integration/scenario/scenario.go @@ -1217,6 +1217,67 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri return nil } +// permissionFlags maps a human action to the permission key reported in a PDF's +// encryption dictionary. +var permissionFlags = map[string]string{ + "printing": "print", + "copying": "copy", + "modifying": "change", + "annotating": "addNotes", +} + +// thePdfsShouldAllowAction asserts whether every response PDF permits a given +// action (printing, copying, modifying, annotating). It reads the document's +// permission flags; an unencrypted document has no restrictions. +func (s *scenario) thePdfsShouldAllowAction(ctx context.Context, kind, should, action string) error { + flag, ok := permissionFlags[action] + if !ok { + return fmt.Errorf("unsupported permission action %q", action) + } + + dirPath := s.teststoreDir + + _, err := os.Stat(dirPath) + if os.IsNotExist(err) { + return fmt.Errorf("directory %q does not exist", dirPath) + } + + var paths []string + err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error { + if pathErr != nil { + return pathErr + } + if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return fmt.Errorf("walk %q: %w", dirPath, err) + } + + invert := should == "should NOT" + for _, path := range paths { + output, err := execCommandInIntegrationToolsContainer(ctx, []string{"pdfinfo", filepath.Base(path)}, path) + if err != nil { + return fmt.Errorf("read permissions of %q: %w", path, err) + } + + stripped := strings.ReplaceAll(strings.ReplaceAll(output, " ", ""), "\n", "") + denied := strings.Contains(stripped, flag+":no") + allowed := strings.Contains(stripped, flag+":yes") + + if invert && !denied { + return fmt.Errorf("expected PDF %q to deny %q, got: %q", path, action, output) + } + if !invert && !allowed { + return fmt.Errorf("expected PDF %q to allow %q, got: %q", path, action, output) + } + } + + return nil +} + func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, should string) error { dirPath := s.teststoreDir @@ -1474,6 +1535,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { ctx.Then(`^the (response|webhook request) PDF\(s\) should be valid "([^"]*)" with a tolerance of (\d+) failed rule\(s\)$`, s.thePdfsShouldBeValidWithAToleranceOf) ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be flatten$`, s.thePdfsShouldBeFlatten) ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted) + ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) allow "([^"]*)"$`, s.thePdfsShouldAllowAction) ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile) ctx.Then(`^the (response|webhook request) PDF\(s\) should have the "([^"]*)" file embedded with relationship "([^"]*)"$`, s.thePdfsShouldHaveEmbeddedFileWithRelationship) ctx.Then(`^the (response|webhook request) PDF\(s\) should declare Factur-X XMP with conformance level "([^"]*)"$`, s.thePdfsShouldDeclareFacturXConformanceLevel)