GitHub Security Advisory: GHSA-r33j-c622-r6qp
Package: github.com/gotenberg/gotenberg/v8 (GO)
Severity: HIGH (CVSS 7.5)
CWE: CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition')
Vulnerable versions: <= 8.31.0
Patched in: 8.32.0

Summary: Gotenberg has an unauthenticated denial of service via echo.Context pool reuse in webhook async goroutine

Details:
## Summary

The webhook middleware spawns a goroutine that holds a reference to the request's `echo.Context` after the synchronous handler returns `ErrAsyncProcess` and Echo recycles the context back to its `sync.Pool`. When a concurrent request claims the recycled context, `c.Reset()` clears the store. If the webhook goroutine reaches `hardTimeoutMiddleware` at that moment, an unchecked type assertion on a nil store entry panics outside any `recover()` scope, crashing the Gotenberg process. Any anonymous caller reaches the webhook path (default `webhook-deny-list` filters only the webhook destination, not the submitter). A single-source stress of ~24 webhook requests plus ~60 `GET /version` requests crashes the process in about two seconds.

## Details

`pkg/modules/webhook/middleware.go:338-382` starts the async goroutine and immediately returns `api.ErrAsyncProcess` to the caller:

```go
w.asyncCount.Add(1)
go func() {
    defer cancel()
    defer w.asyncCount.Add(-1)

    err := next(c)                    // line 343
    ...
    sendOutputFile(sendOutputFileParams{ ctx: ctx, ... })
}()

return api.ErrAsyncProcess             // line 382
```

`pkg/modules/api/middlewares.go:356-361` sees the sentinel, responds with `204 No Content`, and lets Echo return `c` to the pool:

```go
if errors.Is(err, ErrAsyncProcess) {
    return c.NoContent(http.StatusNoContent)
}
```

Echo's router calls `c.Reset()` before serving the next request from the same goroutine pool slot, wiping `c.store`. When the webhook goroutine's `next(c)` enters `hardTimeoutMiddleware` at `pkg/modules/api/middlewares.go:396-398`, the handler dereferences the store before the new recover scope exists:

```go
return func(c echo.Context) error {
    logger := c.Get("logger").(*slog.Logger)   // line 398

    ...
    go func() {
        defer func() { if r := recover(); r != nil { ... } }()   // recover is scoped here
        errChan <- next(c)
    }()
```

If a concurrent request has just acquired `c` from the pool, `c.Get("logger")` returns `nil`, and `nil.(*slog.Logger)` panics at line 398. The panic is not inside any goroutine with a `recover()`, so the Go runtime terminates the process with exit code 2.

No `echo.Recover` middleware is registered (`pkg/modules/api/api.go:480-536`). `GOTRACEBACK` defaults propagate the panic to stderr and exit.

## Proof of Concept

Reproduction on the stock Docker image with default configuration:

```bash
docker run -d --name gotenberg-poc -p 3000:3000 \
    -e GOTRACEBACK=all gotenberg/gotenberg:8 gotenberg --log-level=error
```

Single-process stress script (Alice sends both streams, no second actor):

```python
import requests, subprocess, time, json, threading

TARGET  = "http://localhost:3000"
WEBHOOK = "http://httpbin.org/post"   # passes default webhook-deny-list
STOP    = threading.Event()
html    = b"<html><body><h1>Q</h1></body></html>"

def webhook_fire():
    s = requests.Session()
    while not STOP.is_set():
        try: