GitHub Security Advisory: GHSA-3cv5-q585-h563
Package: github.com/gotenberg/gotenberg/v8 (GO)
Severity: MODERATE (CVSS 5.3)
CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal'), CWE-73: External Control of File Name or Path
Vulnerable versions: <= 8.31.0
Patched in: unpatched

Summary: Goteberg has arbitrary PDF read via stampExpression and watermarkExpression in merge, split, and convert routes

Details:
## Summary

Six conversion routes (`pdfengines/merge`, `pdfengines/split`, `libreoffice/convert`, `chromium/convert/url`, `chromium/convert/html`, `chromium/convert/markdown`) accept `stampSource=pdf` + `stampExpression=/path` and `watermarkSource=pdf` + `watermarkExpression=/path` from anonymous callers. The dedicated stamp/watermark routes require an uploaded file when the source type is image or pdf; these six routes only overwrite the expression when a file is uploaded, leaving the user-controlled path intact when no file is attached. pdfcpu opens the path and composites its pages onto the output PDF, which returns to the caller. An attacker reads any PDF the Gotenberg process can access on the container filesystem.

## Details

The dedicated stamp route at `pkg/modules/pdfengines/routes.go:1322-1332` rejects requests missing the stamp file:

```go
if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF {
    if stampFile == "" {
        return api.WrapError(errors.New("no stamp file provided"), ...)
    }
    stamp.Expression = stampFile
}
```

The merge, split, LibreOffice, and Chromium routes use a lax pattern across twelve call sites (six stamp + six watermark):

```go
// pkg/modules/pdfengines/routes.go:679-683 (merge), 803 (split);
// pkg/modules/libreoffice/routes.go:307-311;
// pkg/modules/chromium/routes.go:433-438, 508-513, 592-597
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
    stamp.Expression = stampFile
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
    watermark.Expression = watermarkFile
}
```

When `stampFile == ""` (no file attached to the `stamp` form field), the guard short-circuits and `stamp.Expression` keeps the raw user-supplied `stampExpression` form string. The same pattern applies to `watermarkFile`/`watermarkExpression`.

`pkg/modules/pdfcpu/pdfcpu.go:635` forwards the expression straight to the pdfcpu CLI:

```go
args := []string{"stamp", "add", "-mode", "pdf", "--", stamp.Expression, onDesc, inputPath, outputPath}
cmd, err := gotenberg.CommandContext(ctx, logger, cfg.BinPath, args...)
```

pdfcpu reads the target PDF at that path and composites its pages as a stamp on every page of the merged output.

## Proof of Concept

Reproduction on the stock Docker image. The scenario models a deployment that mounts host paths into the container (common for document-processing pipelines) or where another request leaves a PDF in the shared `/tmp` filesystem:

```bash
docker run -d --name gotenberg-poc -p 3000:3000 gotenberg/gotenberg:8
docker exec gotenberg-poc sh -c 'cat > /tmp/victim_doc.pdf' < victim.pdf
```

Where `victim.pdf` contains extractable text such as `BOB-CONFIDENTIAL-CONTRACT-2026-04-20`.

Alice attacks without auth:

```python
import requests, io, subprocess
T = "http://localhost:3000"

minimal = (b"%PDF-1.4\n1 0 obj\n<< /Type /C