-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The advisory names one commit. The commit ships the pattern the description calls the bug.\n\nGHSA-5q7p-7jgv-ww56 and the NVD record for CVE-2026-40280 share the same external references. After dedupe, three URLs remain. Two of them are advisory pages, one in the GHSA UI and one in the cve.org mirror. The third is the only code reference:\n\n```\nhttps://github.com/gotenberg/gotenberg/commit/3f01ca18d3cc21375a1e2da4b5a3f261c8548e47\n```\n\nThat commit modifies five files. Two are tests. One is the Makefile. One is the compose file. The remaining change is a single default value in `pkg/modules/api/api.go`:\n\n```diff\n-fs.StringSlice(\"api-download-from-deny-list\", []string{},\n- \"Set the denied URLs for the download from feature using regular expressions - supports multiple values\")\n+fs.StringSlice(\"api-download-from-deny-list\",\n+ []string{`^https?://(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|169\\.254\\.|0\\.0\\.0\\.0|127\\.|localhost|\\[::1\\]|\\[fd)`},\n+ \"Set the denied URLs for the download from feature using regular expressions - supports multiple values\")\n```\n\nThe same change lands in `pkg/modules/webhook/webhook.go` for `--webhook-deny-list`. Before 8.31.0, those two flags shipped with an empty default, and the operator had to supply their own regex if they wanted any protection. After this commit, they ship with the pattern shown above. The regex is compiled with `regexp2.MustCompile(pattern, 0)`. The zero is the options bitfield. `regexp2.IgnoreCase` would have been a value of `1`. The compiled engine treats `H` and `h` as different bytes.\n\nGo's `net/url.Parse` does not. The standard library lowercases the scheme during parse: a URL of `HTTP://...` becomes a `*url.URL` whose `.Scheme` field is `\"http\"`. When `net/http` builds the outbound request, the connection goes to the lowercase scheme regardless of what the caller wrote. The regex runs against the caller-supplied string before parse; the dial happens after parse. The gate inspects one representation. The action uses another.\n\n## The bypass\n\nA Gotenberg 8.30.1 instance running with the new default deny-list, denying `--webhook-deny-list` URLs that match `^https?://`:\n\n```bash\ncurl -X POST http://gotenberg.local:3000/forms/chromium/convert/html \\\n -F \"files=@index.html\" \\\n -F \"files=@style.css\" \\\n -H \"Gotenberg-Webhook-Url: HTTP://169.254.169.254/latest/meta-data/iam/security-credentials/\" \\\n -H \"Gotenberg-Webhook-Method: POST\"\n```\n\nThe webhook middleware extracts the header, builds the URL string `HTTP://169.254.169.254/...`, and hands it to `gotenberg.FilterDeadline` along with the default deny-list. The regex sees `HTTP://169.254...`. `H` is not in the character class `[h]` implied by `^https?`. The regex does not match. The deny-list does not trigger. The middleware passes the URL to a `retryablehttp.Client`, which wraps a vanilla `net/http.Client`, which calls `url.Parse`, which lowercases the scheme. The outbound POST hits 169.254.169.254 on port 80 with the rendered conversion payload. The response from the EC2 instance metadata service comes back along the retry-client error path, and the timing alone tells the attacker the destination was reachable. The same shape works against `Http://10.0.0.5/`, against `HTTPS://127.0.0.1:8500/v1/kv/`, against any RFC1918 destination, against any host that resolves to one.\n\nThe regex was placed there as the defense. The defense never runs against the address the dial uses.\n\n## The actual fix is in a different file, four days later.\n\nThe 8.31.0 release contains nineteen commits past 8.30.1. The one the advisory references is the regex-default commit on April 7. A second commit on April 11, `924576d`, \"fix(outboundURLs): better detaults,\" is not referenced by the CVE record. It adds a new file:\n\n```\npkg/gotenberg/outbound.go +325 lines\n```\n\nThe file introduces `FilterOutboundURL`, `IsPublicIP`, `ResolveAndCheckPublic`, `NewOutboundHttpClient`, and a `secureDialContext` that pins the dial to a resolved address. The author left the prose that names what the function is for in the docstring:\n\n```go\n// The function:\n//\n// 1. Parses rawURL with [net/url] and lowercases the scheme and host. This\n// prevents case-variant bypasses such as HTTP://127.0.0.1 from evading\n// case-sensitive deny-list regexes.\n// 2. Applies allowList and denyList against the normalized form using the\n// same OR semantics as [FilterDeadline].\n// 3. When no allow-list entry explicitly matched and the scheme is one of\n// http, https, ws, or wss, resolves the host and verifies every\n// resolved address with [IsPublicIP].\n```\n\n`decideOutbound` does the work the regex layer was supposed to do, in the order the regex layer was supposed to do it:\n\n```go\nparsed, err := url.Parse(rawURL)\nif err != nil {\n return outboundDecision{}, fmt.Errorf(\"parse URL %q: %w\", rawURL, ErrFiltered)\n}\nparsed.Scheme = strings.ToLower(parsed.Scheme)\nparsed.Host = strings.ToLower(parsed.Host)\nnormalized := parsed.String()\n// ... regex match runs against `normalized`, not `rawURL` ...\n```\n\nCanonicalize, then gate. The regex on the default deny-list is unchanged across the two commits, still case-sensitive, still compiled with options `0`. What changed is that the regex no longer reads the attacker-supplied string. It reads what comes out of the parser. `HTTPS://169.254.169.254/` arrives at the regex as `https://169.254.169.254/`, and the character class catches it.\n\n`decideOutbound` does not stop at the regex. After the deny-list has cleared (or did not match), the function pulls the hostname out of the parsed URL and runs `ResolveAndCheckPublic`. That call resolves the hostname through `net.DefaultResolver` and rejects any returned address for which `IsPublicIP` returns false. The rejection list covers everything the regex misses:\n\n```go\ncase addr.IsLoopback(),\n addr.IsPrivate(),\n addr.IsLinkLocalUnicast(),\n addr.IsLinkLocalMulticast(),\n addr.IsMulticast(),\n addr.IsUnspecified(),\n addr.IsInterfaceLocalMulticast():\n return false\n```\n\n`addr = addr.Unmap()` runs first, so `[::ffff:127.0.0.1]` and `[::ffff:10.0.0.5]` come out as their IPv4 forms and get rejected. `2130706433` (the decimal form of `127.0.0.1`) gets parsed by `netip.ParseAddr` as the literal IP and rejected. A hostname like `internal.dev.example.com` that resolves to `10.0.0.5` gets rejected at resolution, regardless of what the regex thinks of the URL string. The list of bypasses the regex never blocked, and that `FilterDeadline` callers had to enumerate by hand, becomes a single check at the IP layer.\n\nThe plumbing for the two flags the CVE names is in the same commit:\n\n```diff\n// pkg/modules/webhook/middleware.go\n-err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline)\n+err := gotenberg.FilterOutboundURL(ctx, webhookUrl, w.allowList, w.denyList, deadline)\n\n// pkg/modules/api/context.go\n-err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)\n+err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline)\n```\n\nThe HTTP clients used to dial the webhook and to download from URLs are also replaced with `gotenberg.NewOutboundHttpClient`, which re-runs `decideOutbound` on every redirect hop via the wrapping `RoundTripper` and pins the dial to the validated IP through `secureDialContext`. That closes a redirect-based variant and a DNS-rebinding variant that the regex layer never addressed.\n\nThis commit closes the bug. The CVE record does not name it.\n\n## The regex in 8.31.0 is still case-sensitive.\n\nBoth commits land in 8.31.0. The release tarball is the same artifact whether the operator reads the GHSA references or not. The thing that prevents `HTTPS://10.0.0.5/` from reaching 10.0.0.5 in 8.31.0 is the host-IP resolution check in `decideOutbound`, not the regex. The regex on the default deny-list, considered in isolation, has the same bypass it had on April 7. Operators who supply their own deny-list, opting out of the default, inherit the same isolation: `FilterOutboundURL` lowercases the scheme before applying any pattern, so a custom regex written without `(?i)` starts matching where it previously did not.\n\nThe docstring on `FilterOutboundURL` is testimony. The author wrote out the specific bypass the function exists to block (`HTTP://127.0.0.1`) in the source comment. The regex was not fixed. A different layer was added in front of it.\n\n## This is the third time\n\nGotenberg's SSRF history is three CVEs in two years. Each one is the same root: a regex-string match against a URL whose downstream interpreter canonicalizes the input differently than the regex reads it. Each patch addressed the textual layer.\n\n| Date | CVE | Bug | Fix shape |\n| ------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| 2024-01 | CVE-2024-21527 | `/convert/html` rendered HTML can include `