Outbound URL Filtering
Gotenberg makes outbound HTTP calls in four places:
- Chromium navigations and sub-resources.
- Webhook callbacks.
downloadFromfetches.- LibreOffice fetches for document references (OOXML external images, RTF
INCLUDEPICTURE, ODT linked images). Since 8.34.0, content linked from uploaded documents is dropped at the source; the filters below cover any fetch LibreOffice still performs.
Each surface routes through the same pipeline: allow/deny regex, IP-class checks, and a DNS-rebind pinning proxy.
Default Posture
Defaults are permissive. Gotenberg runs inside a trusted network behind your own API gateway.
Out of the box:
- Webhook callbacks reach
http://hooks.compose.internal. - Chromium loads
http://my-cdn.svc/banner.jpeg. downloadFromfetcheshttp://shared-storage.internal/document.docx.
Operators exposing Gotenberg to untrusted callers opt into stricter checks via the variables below.
Always-On Protections
Hardening for specific primitives. No setting turns them off.
DNS-rebind pinning proxy. Chromium and LibreOffice route every outbound HTTP/HTTPS request through an in-process proxy. The proxy resolves DNS once and pins the dial to the validated IP. Setting CHROMIUM_PROXY_SERVER or CHROMIUM_HOST_RESOLVER_RULES skips it, as does a hop carried by the environment proxy, which owns DNS for the hops it carries.
file:// rejected on URL routes. /forms/chromium/convert/url and /forms/chromium/screenshot/url return 400 Bad Request for any file:// URL. Use the html or markdown variants to render local HTML.
file:// sub-resources scoped per request. Routes that render local HTML load assets relative to the request's own working directory. Sibling requests' files are unreachable.
Linked content dropped in LibreOffice documents. Since 8.34.0, LibreOffice refuses to load content linked from an untrusted location. Uploaded documents always load from a per-request temporary directory, which is never trusted, so linked file:// paths and external URLs are dropped before any fetch happens. An allow-list match cannot re-enable them. Embedded content stored inside the document is unaffected.
Per-Module Variables
Each module exposes the same two boolean variables. All default to false. Each variable also exists as a CLI flag; see the Chromium, Webhook, API, and LibreOffice configuration tables.
| Environment variable | Effect |
|---|---|
CHROMIUM_DENY_PRIVATE_IPS | Rejects Chromium navigations and sub-resources resolving to a non-public IP. |
CHROMIUM_DENY_PUBLIC_IPS | Rejects Chromium navigations and sub-resources resolving to a public IP. |
WEBHOOK_DENY_PRIVATE_IPS | Rejects webhook URLs (success, error, events) resolving to a non-public IP. |
WEBHOOK_DENY_PUBLIC_IPS | Rejects webhook URLs resolving to a public IP. |
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS | Rejects downloadFrom URLs resolving to a non-public IP. |
API_DOWNLOAD_FROM_DENY_PUBLIC_IPS | Rejects downloadFrom URLs resolving to a public IP. |
LIBREOFFICE_DENY_PRIVATE_IPS | Rejects LibreOffice outbound fetches resolving to a non-public IP. |
LIBREOFFICE_DENY_PUBLIC_IPS | Rejects LibreOffice outbound fetches resolving to a public IP. |
Non-public means loopback, RFC1918, link-local, or IPv6 unique-local. The check also unwraps IPv6 forms that embed an IPv4 destination, IPv4-mapped (::ffff:10.0.0.1), IPv4-translated, 6to4 (2002::/16), and Teredo (2001::/32), and rejects them when the embedded IPv4 is non-public.
LibreOffice also exposes LIBREOFFICE_ALLOW_LIST and LIBREOFFICE_DENY_LIST, mirroring Chromium and webhook.
Rejected URLs return 403 Forbidden. LibreOffice fetches are the exception: the proxy rejects them inside the conversion, which succeeds without the resource.
Precedence
- Deny-list always wins. A deny-list match rejects the URL regardless of allow-list or IP-class variables.
- Allow-list bypasses the IP check. A match skips
*_DENY_PRIVATE_IPSand*_DENY_PUBLIC_IPS. - IP-class variables apply last. Both flags on rejects every URL except allow-list matches.
Environment Proxy
Gotenberg ignores the proxy environment variables by default. Each module opts in with its own variable, all false by default.
| Environment variable | Effect |
|---|---|
CHROMIUM_ENABLE_ENVIRONMENT_PROXY | Routes Chromium navigations and sub-resources through the proxy. |
LIBREOFFICE_ENABLE_ENVIRONMENT_PROXY | Routes LibreOffice outbound fetches through the proxy. |
API_DOWNLOAD_FROM_ENABLE_ENVIRONMENT_PROXY | Routes downloadFrom fetches through the proxy. |
WEBHOOK_ENABLE_ENVIRONMENT_PROXY | Routes webhook callbacks through the proxy. |
Gotenberg reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY, plus their lowercase spellings, and authenticates with the credentials embedded in the proxy URL. Chromium and LibreOffice cannot authenticate to a proxy themselves, so Gotenberg performs the CONNECT handshake for them. Give those two an http or https proxy URL.
Enabling a variable relaxes no filter. The allow-list, the deny-list and the IP-class checks run against the destination URL before the request reaches the proxy, on every redirect hop. Only DNS pinning changes: a hop the proxy carries is dialed against the proxy, which owns DNS from there, while a hop the proxy declines, such as a NO_PROXY host, stays pinned to the IPs resolved during validation.
CHROMIUM_ENABLE_ENVIRONMENT_PROXY needs CHROMIUM_PROXY_SERVER and CHROMIUM_HOST_RESOLVER_RULES unset. Both skip the pinning proxy that performs the upstream handshake.
Gotenberg refuses to start when a module has its variable on and a proxy variable that is not a usable proxy URL. The error names the variable and never prints its value.
Gotenberg 8.34.0 and earlier inherited HTTP_PROXY and HTTPS_PROXY implicitly, so allow-listed webhook callbacks, downloadFrom fetches and LibreOffice fetches egressed through the proxy with no variable set. They now egress directly. Set the module variable to restore the proxy.
Recipes
Internet-Facing API
Block private destinations across the four modules.
services:
gotenberg:
image: gotenberg/gotenberg:8
environment:
CHROMIUM_DENY_PRIVATE_IPS: "true"
WEBHOOK_DENY_PRIVATE_IPS: "true"
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS: "true"
LIBREOFFICE_DENY_PRIVATE_IPS: "true"
Whitelist known internal hosts:
environment:
CHROMIUM_ALLOW_LIST: "^https?://[^/]+\\.internal\\.example\\.com"
WEBHOOK_ALLOW_LIST: "^https?://hooks\\.internal\\.example\\.com"
The allow-list match bypasses the IP check for those URLs only.
Air-Gapped Network
Block traffic that would leave the private network.
services:
gotenberg:
image: gotenberg/gotenberg:8
environment:
CHROMIUM_DENY_PUBLIC_IPS: "true"
WEBHOOK_DENY_PUBLIC_IPS: "true"
API_DOWNLOAD_FROM_DENY_PUBLIC_IPS: "true"
LIBREOFFICE_DENY_PUBLIC_IPS: "true"
Strict Whitelist
Combine both flags. Every destination must match the allow-list.
services:
gotenberg:
image: gotenberg/gotenberg:8
environment:
CHROMIUM_DENY_PRIVATE_IPS: "true"
CHROMIUM_DENY_PUBLIC_IPS: "true"
CHROMIUM_ALLOW_LIST: "^https://(api|cdn|images)\\.internal\\.example\\.com"
Equivalent of an egress firewall expressed at the Gotenberg layer.
Trusted Network (Default)
Do nothing. The defaults work for Docker Compose and Kubernetes deployments.
Decision Matrix
| URL shape | *_DENY_PRIVATE_IPS=false (default) | *_DENY_PRIVATE_IPS=true | *_DENY_PUBLIC_IPS=true | Both true |
|---|---|---|---|---|
| Public hostname | passes | passes | rejected | rejected |
| Private IP literal | passes | rejected | passes | rejected |
| Private hostname → private IP | passes | rejected | passes | rejected |
| Cloud metadata IP (169.254.169.254) | passes | rejected | passes | rejected |
URL matching *_ALLOW_LIST | passes | passes (bypass) | passes (bypass) | passes (bypass) |
URL matching *_DENY_LIST | rejected | rejected | rejected | rejected |
Migration from 8.31.0
8.32.0 removes the baked-in private-range regex from WEBHOOK_DENY_LIST and API_DOWNLOAD_FROM_DENY_LIST. Operators who relied on it set the matching IP-class variable.
environment:
WEBHOOK_DENY_PRIVATE_IPS: "true"
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS: "true"
The DNS-based check covers IP literals and hostnames that resolve to a private IP. It is strictly more than the previous textual regex.
If you prefer textual matching, paste the previous regex back into the deny-list:
^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
8.31.0 also blocked Chromium sub-resources resolving to private IPs by default. 8.32.0 restores the 8.30.x permissive behavior. Set CHROMIUM_DENY_PRIVATE_IPS=true for the strict 8.31.0-style posture.
Operator-supplied deny-list patterns are honored as before. Only the baked-in defaults changed.
What's Next?
Observe requests with Telemetry, or tune the instance in Configuration.