The NVD record for CVE-2026-40280 lists exactly one fix commit. The commit, 3f01ca1, adds a default deny-list regex to two flags that previously had none: ^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd). Run that pattern against HTTPS://169.254.169.254/latest/meta-data/. It does not match. The CVE description names case-sensitivity as the bug. The patch the advisory points at adds a case-sensitive regex.
CVE-2026-40280: Gotenberg's Named Fix Adds the Case-Sensitive Regex. A Commit Four Days Later Replaces the Layer.
patterns
cve
proof of concept
The advisory names one commit. The commit ships the pattern the description calls the bug.
GHSA-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:
https://github.com/gotenberg/gotenberg/commit/3f01ca18d3cc21375a1e2da4b5a3f261c8548e47That 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:
-fs.StringSlice("api-download-from-deny-list", []string{},
- "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
+fs.StringSlice("api-download-from-deny-list",
+ []string{`^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)`},
+ "Set the denied URLs for the download from feature using regular expressions - supports multiple values")The 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.
Go'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.
The bypass
A Gotenberg 8.30.1 instance running with the new default deny-list, denying --webhook-deny-list URLs that match ^https?://:
curl -X POST http://gotenberg.local:3000/forms/chromium/convert/html \
-F "[email protected]" \
-F "[email protected]" \
-H "Gotenberg-Webhook-Url: HTTP://169.254.169.254/latest/meta-data/iam/security-credentials/" \
-H "Gotenberg-Webhook-Method: POST"The 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.
The regex was placed there as the defense. The defense never runs against the address the dial uses.
The actual fix is in a different file, four days later.
The 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:
pkg/gotenberg/outbound.go +325 linesThe 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:
// The function:
//
// 1. Parses rawURL with [net/url] and lowercases the scheme and host. This
// prevents case-variant bypasses such as HTTP://127.0.0.1 from evading
// case-sensitive deny-list regexes.
// 2. Applies allowList and denyList against the normalized form using the
// same OR semantics as [FilterDeadline].
// 3. When no allow-list entry explicitly matched and the scheme is one of
// http, https, ws, or wss, resolves the host and verifies every
// resolved address with [IsPublicIP].decideOutbound does the work the regex layer was supposed to do, in the order the regex layer was supposed to do it:
parsed, err := url.Parse(rawURL)
if err != nil {
return outboundDecision{}, fmt.Errorf("parse URL %q: %w", rawURL, ErrFiltered)
}
parsed.Scheme = strings.ToLower(parsed.Scheme)
parsed.Host = strings.ToLower(parsed.Host)
normalized := parsed.String()
// ... regex match runs against `normalized`, not `rawURL` ...Canonicalize, 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.
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:
case addr.IsLoopback(),
addr.IsPrivate(),
addr.IsLinkLocalUnicast(),
addr.IsLinkLocalMulticast(),
addr.IsMulticast(),
addr.IsUnspecified(),
addr.IsInterfaceLocalMulticast():
return falseaddr = 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.
The plumbing for the two flags the CVE names is in the same commit:
// pkg/modules/webhook/middleware.go
-err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline)
+err := gotenberg.FilterOutboundURL(ctx, webhookUrl, w.allowList, w.denyList, deadline)
// pkg/modules/api/context.go
-err := gotenberg.FilterDeadline(downloadFromCfg.allowList, downloadFromCfg.denyList, dl.Url, deadline)
+err := gotenberg.FilterOutboundURL(ctx, dl.Url, downloadFromCfg.allowList, downloadFromCfg.denyList, deadline)The 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.
This commit closes the bug. The CVE record does not name it.
The regex in 8.31.0 is still case-sensitive.
Both 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.
The 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.
This is the third time
Gotenberg'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.
| Date | CVE | Bug | Fix shape |
|---|---|---|---|
| 2024-01 | CVE-2024-21527 | /convert/html rendered HTML can include <iframe src="localhost/etc/passwd">; Chromium reads local files |
8.1.0 surfaces --chromium-deny-list and --chromium-allow-list; the default regex evolves to ^file:(?!//\/tmp/).* |
| 2026-03 | CVE-2026-27018 | The ^file: default can be bypassed with FILE: or File: because the regex is case-sensitive |
8.29.0 adds an AllowedFilePrefixes path-prefix check that runs alongside the regex; the deny-list pattern itself is unchanged |
| 2026-05 | CVE-2026-40280 | The same case-sensitivity issue, this time in the webhook + downloadFrom defaults that 8.31.0 is about to introduce | 8.31.0 ships the case-sensitive default AND, four days later, an IP-resolution layer (pkg/gotenberg/outbound.go) that canonicalizes the scheme before the regex runs and resolves the host |
Each row except the last has the same shape: the patch adjusted the regex, or added another string-pattern check alongside it. The 8.31.0 commit the advisory references follows that pattern (add another string-pattern default to two flags). The 8.31.0 commit the advisory does not reference is the first one to change the design: resolve the host, check the IP, pin the dial, treat the network layer as the gate. The string-pattern-deny-list-as-URL-gate is the design-debt driver here; three CVE cycles patched its symptoms, and 924576d is the first commit in the series that does anything else.
The pattern's name is gate-before-canonicalize. The regex deny-list inspects the caller-supplied URL string, and the downstream subsystem (Go's url.Parse, Chromium's URL normalizer, the OS resolver) canonicalizes that string into a form the regex was not written to match. Three patches treat the regex as the gate. The fourth treats the dial as the gate.
Audit your patch references
The fix in 924576d is unambiguous: a new module, with new public API, replacing FilterDeadline at every call site that issued an outbound request. It is also unreferenced by the CVE record, the GHSA advisory, and every secondary tracker that mirrors them. The reference list in NVD contains four URLs, one of which is a duplicate of the advisory page. None of them point at the commit that closes the bug.
For operators on 8.31.0, the bug is closed because the release tarball contains both commits. For operators reading the advisory to understand what to backport to a fork, what to audit on a vendored copy, or what to disable in the interim while testing, the references are not enough. The defense against HTTP://169.254.169.254/ does not live in pkg/modules/api/api.go. It lives in pkg/gotenberg/outbound.go. A patch-presence scanner that diffs against the referenced commit will report a false positive against a 8.30.1 fork that backported only 3f01ca1.
PoC: gotenberg/gotenberg, advisory GHSA-5q7p-7jgv-ww56.
The advisory references one commit. The commit references two layers. One of the two is the bug.