Skip to content

Commit 678eca0

Browse files
committed
docs: 8.32.0 outbound URL filtering and security fixes
1 parent 184161b commit 678eca0

11 files changed

Lines changed: 311 additions & 85 deletions

docs/_shared/_stamp_pdfengines.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import SyntaxStampPDFEngines from "./_syntax_stamp_pdfengines.mdx";
33

44
Adds a stamp _on top of_ the content of each page during post-processing. Sources: `text`, `image`, or `pdf`.
55

6+
`image` and `pdf` sources require an uploaded `stamp` file. `stampExpression` cannot reference an arbitrary filesystem path; the route returns `400 Bad Request`.
7+
68
<SyntaxStampPDFEngines />
79

810
{props.showCurlExample && (

docs/_shared/_watermark_pdfengines.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import SyntaxWatermarkPDFEngines from "./_syntax_watermark_pdfengines.mdx";
33

44
Adds a watermark _behind_ the content of each page during post-processing. Sources: `text`, `image`, or `pdf`.
55

6+
`image` and `pdf` sources require an uploaded `watermark` file. `watermarkExpression` cannot reference an arbitrary filesystem path; the route returns `400 Bad Request`.
7+
68
<SyntaxWatermarkPDFEngines />
79

810
{props.showCurlExample && (

docs/configuration.mdx

Lines changed: 89 additions & 79 deletions
Large diffs are not rendered by default.

docs/convert-with-chromium/_shared/_http_networking.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,11 @@ ${props.curlFormData}
205205
${props.curlOutput}
206206
`}
207207
/>
208+
209+
### Outbound URL Filtering
210+
211+
Chromium routes every HTTP/HTTPS request through an in-process pinning proxy. The proxy resolves DNS once and dials the validated IP, closing the rebind window.
212+
213+
`file://` sub-resources are scoped to the request's own working directory. Sibling requests' files are unreachable.
214+
215+
Forbidden URLs return `403`. See [Outbound URL Filtering](/docs/outbound-url-filtering) for posture, recipes, and configuration.

docs/convert-with-chromium/convert-url-to-pdf.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ Converts a target URL to PDF using Headless Chromium. Supports JavaScript execut
4747
name: "url",
4848
type: "string",
4949
required: true,
50-
description: "URL of the page to convert into PDF.",
50+
description:
51+
"URL of the page to convert into PDF. file:// URLs return 400; render local HTML via /forms/chromium/convert/html or /forms/chromium/convert/markdown.",
5152
},
5253
]}
5354
curl={`

docs/convert-with-chromium/screenshot-url.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ Captures a screenshot of a target URL using Headless Chromium. Supports JavaScri
4040
name: "url",
4141
type: "string",
4242
required: true,
43-
description: "URL of the page to screenshot.",
43+
description:
44+
"URL of the page to screenshot. file:// URLs return 400; render local HTML via /forms/chromium/screenshot/html or /forms/chromium/screenshot/markdown.",
4445
},
4546
]}
4647
curl={`

docs/convert-with-libreoffice/convert-to-pdf.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,14 @@ curl \\
275275

276276
Macros in documents (e.g., `.docm`, `.xlsm`, `.pptm`) are **disabled** by default during conversion. Files are still converted, but macro-generated content will not execute.
277277

278+
### Embedded Outbound Fetches
279+
280+
LibreOffice resolves external URLs referenced by uploaded documents: OOXML images marked `TargetMode="External"`, RTF `INCLUDEPICTURE`, ODT linked images.
281+
282+
Since _8.32.0_, those fetches route through the same pinning proxy used by Chromium. Allow/deny lists and IP-class checks apply. Forbidden destinations return `403 Forbidden`.
283+
284+
See [Outbound URL Filtering](/docs/outbound-url-filtering).
285+
278286
## Structure & Metadata
279287

280288
### Document Outline (LibreOffice)
@@ -529,6 +537,8 @@ curl \\
529537

530538
### Watermark (PDF Engines)
531539

540+
`image` and `pdf` sources require an uploaded `watermark` file. `watermarkExpression` cannot reference an arbitrary filesystem path; the route returns `400 Bad Request`.
541+
532542
<ApiEndpoint
533543
formFields={[
534544
{
@@ -580,6 +590,8 @@ curl \\
580590

581591
### Stamp (PDF Engines)
582592

593+
`image` and `pdf` sources require an uploaded `stamp` file. `stampExpression` cannot reference an arbitrary filesystem path; the route returns `400 Bad Request`.
594+
583595
<ApiEndpoint
584596
formFields={[
585597
{

docs/outbound-url-filtering.mdx

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
---
2+
id: outbound-url-filtering
3+
title: Outbound URL Filtering
4+
---
5+
6+
import Sponsors from "@site/src/components/documentation/Sponsors";
7+
8+
Gotenberg makes outbound HTTP calls in four places:
9+
10+
- Chromium navigations and sub-resources.
11+
- Webhook callbacks.
12+
- `downloadFrom` fetches.
13+
- LibreOffice fetches for embedded document references (OOXML external images, RTF `INCLUDEPICTURE`, ODT linked images).
14+
15+
Each surface routes through the same pipeline: allow/deny regex, IP-class checks, and a DNS-rebind pinning proxy.
16+
17+
## Default Posture
18+
19+
Defaults are permissive. Gotenberg runs inside a trusted network behind your own API gateway.
20+
21+
Out of the box:
22+
23+
- Webhook callbacks reach `http://hooks.compose.internal`.
24+
- Chromium loads `http://my-cdn.svc/banner.jpeg`.
25+
- `downloadFrom` fetches `http://shared-storage.internal/document.docx`.
26+
27+
Operators exposing Gotenberg to untrusted callers opt into stricter checks via the variables below.
28+
29+
## Always-On Protections
30+
31+
Correctness fixes for specific primitives. No setting turns them off.
32+
33+
**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.
34+
35+
**`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.
36+
37+
**`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.
38+
39+
**Stamp and watermark file required for `image` and `pdf` sources.** `/forms/pdfengines/merge`, `/forms/pdfengines/split`, `/forms/libreoffice/convert`, and the three Chromium `/convert/*` routes return `400 Bad Request` when the source is `image` or `pdf` and no file is uploaded.
40+
41+
## Per-Module Variables
42+
43+
Each module exposes the same two boolean variables. All default to `false`.
44+
45+
| Environment variable | Effect |
46+
| ------------------------------------ | ---------------------------------------------------------------------------- |
47+
| `CHROMIUM_DENY_PRIVATE_IPS` | Rejects Chromium navigations and sub-resources resolving to a non-public IP. |
48+
| `CHROMIUM_DENY_PUBLIC_IPS` | Rejects Chromium navigations and sub-resources resolving to a public IP. |
49+
| `WEBHOOK_DENY_PRIVATE_IPS` | Rejects webhook URLs (success, error, events) resolving to a non-public IP. |
50+
| `WEBHOOK_DENY_PUBLIC_IPS` | Rejects webhook URLs resolving to a public IP. |
51+
| `API_DOWNLOAD_FROM_DENY_PRIVATE_IPS` | Rejects `downloadFrom` URLs resolving to a non-public IP. |
52+
| `API_DOWNLOAD_FROM_DENY_PUBLIC_IPS` | Rejects `downloadFrom` URLs resolving to a public IP. |
53+
| `LIBREOFFICE_DENY_PRIVATE_IPS` | Rejects LibreOffice outbound fetches resolving to a non-public IP. |
54+
| `LIBREOFFICE_DENY_PUBLIC_IPS` | Rejects LibreOffice outbound fetches resolving to a public IP. |
55+
56+
Non-public means loopback, RFC1918, link-local, or IPv6 unique-local.
57+
58+
LibreOffice also exposes `LIBREOFFICE_ALLOW_LIST` and `LIBREOFFICE_DENY_LIST`, mirroring Chromium and webhook.
59+
60+
Rejected URLs return `403 Forbidden`.
61+
62+
## Precedence
63+
64+
1. **Deny-list always wins.** A deny-list match rejects the URL regardless of allow-list or IP-class variables.
65+
2. **Allow-list bypasses the IP check.** A match skips `*_DENY_PRIVATE_IPS` and `*_DENY_PUBLIC_IPS`.
66+
3. **IP-class variables apply last.** Both flags on rejects every URL except allow-list matches.
67+
68+
## Recipes
69+
70+
### Internet-Facing API
71+
72+
Block private destinations across the four modules.
73+
74+
```yaml title="compose.yaml"
75+
services:
76+
gotenberg:
77+
image: gotenberg/gotenberg:8
78+
environment:
79+
CHROMIUM_DENY_PRIVATE_IPS: "true"
80+
WEBHOOK_DENY_PRIVATE_IPS: "true"
81+
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS: "true"
82+
LIBREOFFICE_DENY_PRIVATE_IPS: "true"
83+
```
84+
85+
Whitelist known internal hosts:
86+
87+
```yaml
88+
environment:
89+
CHROMIUM_ALLOW_LIST: "^https?://[^/]+\\.internal\\.example\\.com"
90+
WEBHOOK_ALLOW_LIST: "^https?://hooks\\.internal\\.example\\.com"
91+
```
92+
93+
The allow-list match bypasses the IP check for those URLs only.
94+
95+
### Air-Gapped Network
96+
97+
Block traffic that would leave the private network.
98+
99+
```yaml title="compose.yaml"
100+
services:
101+
gotenberg:
102+
image: gotenberg/gotenberg:8
103+
environment:
104+
CHROMIUM_DENY_PUBLIC_IPS: "true"
105+
WEBHOOK_DENY_PUBLIC_IPS: "true"
106+
API_DOWNLOAD_FROM_DENY_PUBLIC_IPS: "true"
107+
LIBREOFFICE_DENY_PUBLIC_IPS: "true"
108+
```
109+
110+
### Strict Whitelist
111+
112+
Combine both flags. Every destination must match the allow-list.
113+
114+
```yaml title="compose.yaml"
115+
services:
116+
gotenberg:
117+
image: gotenberg/gotenberg:8
118+
environment:
119+
CHROMIUM_DENY_PRIVATE_IPS: "true"
120+
CHROMIUM_DENY_PUBLIC_IPS: "true"
121+
CHROMIUM_ALLOW_LIST: "^https://(api|cdn|images)\\.internal\\.example\\.com"
122+
```
123+
124+
Equivalent of an egress firewall expressed at the Gotenberg layer.
125+
126+
### Trusted Network (Default)
127+
128+
Do nothing. The defaults work for Docker Compose and Kubernetes deployments.
129+
130+
## Decision Matrix
131+
132+
| URL shape | `*_DENY_PRIVATE_IPS=false` (default) | `*_DENY_PRIVATE_IPS=true` | `*_DENY_PUBLIC_IPS=true` | Both true |
133+
| ----------------------------------- | ------------------------------------ | ------------------------- | ------------------------ | --------------- |
134+
| Public hostname | passes | passes | rejected | rejected |
135+
| Private IP literal | passes | rejected | passes | rejected |
136+
| Private hostname → private IP | passes | rejected | passes | rejected |
137+
| Cloud metadata IP (169.254.169.254) | passes | rejected | passes | rejected |
138+
| URL matching `*_ALLOW_LIST` | passes | passes (bypass) | passes (bypass) | passes (bypass) |
139+
| URL matching `*_DENY_LIST` | rejected | rejected | rejected | rejected |
140+
141+
## Migration from 8.31.0
142+
143+
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.
144+
145+
```yaml
146+
environment:
147+
WEBHOOK_DENY_PRIVATE_IPS: "true"
148+
API_DOWNLOAD_FROM_DENY_PRIVATE_IPS: "true"
149+
```
150+
151+
The DNS-based check covers IP literals and hostnames that resolve to a private IP. It is strictly more than the previous textual regex.
152+
153+
If you prefer textual matching, paste the previous regex back into the deny-list:
154+
155+
```
156+
^https?://(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.|169\.254\.|0\.0\.0\.0|127\.|localhost|\[::1\]|\[fd)
157+
```
158+
159+
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.
160+
161+
Operator-supplied deny-list patterns are honored as before. Only the baked-in defaults changed.
162+
163+
<Sponsors />

docs/troubleshooting.mdx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ Issue not listed here? Check [GitHub issues](https://github.com/gotenberg/gotenb
1616
- **Network Idle:** For versions after _8.11.0_, try setting the `skipNetworkIdleEvent` field to _false_.
1717
- **Updates:** Consider upgrading to the latest Gotenberg version to benefit from debugging features like _Network Errors_ or _Invalid HTTP Status Codes_.
1818

19+
### Blank Charts or Empty Google Maps
20+
21+
Charting libraries (`@visx/responsive`, ApexCharts) and Google Maps rendered as empty rectangles between _8.29.0_ and _8.31.0_.
22+
23+
The chromedp _0.15.0_ print pipeline suspended `requestAnimationFrame`, `ResizeObserver`, `IntersectionObserver`, and CSS `transitionend` / `animationend` between the load event and `Page.printToPDF`. Libraries measuring their container inside an `rAF` callback never received a frame.
24+
25+
Fixed in _8.32.0_ by pinning `chromedp` back to _v0.14.2_. Upgrade to _8.32.0_ or later (issues [#1531](https://github.com/gotenberg/gotenberg/issues/1531), [#1534](https://github.com/gotenberg/gotenberg/issues/1534), [#1535](https://github.com/gotenberg/gotenberg/issues/1535)).
26+
27+
### Sub-Resources Stopped Loading After 8.31.0
28+
29+
_8.31.0_ blocked Chromium sub-resources resolving to private IPs by default. CSS, images, and iframes hosted on internal hostnames stopped loading.
30+
31+
_8.32.0_ restores permissive defaults. Private-IP sub-resources load again without configuration.
32+
33+
Set `CHROMIUM_DENY_PRIVATE_IPS=true` to keep the strict 8.31.0-style posture. See [Outbound URL Filtering](outbound-url-filtering).
34+
1935
### Localhost / Missing Assets
2036

2137
- **Container Networking:** The Gotenberg container has its own network. If you try to convert `http://localhost:3000`, it looks inside the container, not your host machine. Use your host's actual network IP, or Docker's DNS (e.g., `host.docker.internal`).
@@ -63,6 +79,14 @@ Issue not listed here? Check [GitHub issues](https://github.com/gotenberg/gotenb
6379
- Debian users should ensure they are using a current version of the distribution ([Issue #794](https://github.com/gotenberg/gotenberg/issues/794)).
6480
- For Synology and Paperless-ngx users, consult [this configuration comment](https://github.com/gotenberg/gotenberg/issues/763#issuecomment-1890472047).
6581

82+
### First Request Times Out, Subsequent Requests Fail Until Container Restart
83+
84+
Before _8.32.0_, the supervisor cached a failed first launch and returned the same error to every subsequent request. This happened when `LIBREOFFICE_START_TIMEOUT` was shorter than soffice's cold start.
85+
86+
Fixed in _8.32.0_. A failed launch leaves state clean and the next request retries from scratch ([Issue #1538](https://github.com/gotenberg/gotenberg/issues/1538)).
87+
88+
For older versions, increase `LIBREOFFICE_START_TIMEOUT` (default 20s) to comfortably exceed soffice cold-start time.
89+
6690
### PDF/A-1a Support
6791

6892
- Starting with version 7.6, LibreOffice no longer supports _PDF/A-1a_. It previously generated _PDF/A-1b_ files anyway, though some validators misidentified them ([LibreOffice commit](https://git.libreoffice.org/core/+/c4b12d06698402984b3ffdbd2c139f261fa35ca1%5E%21)).

docs/webhook-download.mdx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,8 @@ curl \\
218218

219219
## Outbound URL Security
220220

221-
Gotenberg resolves every outbound URL (webhook delivery, `downloadFrom` fetches, Chromium asset loads) and rejects non-public addresses: loopback, RFC1918, link-local, unspecified, multicast, IPv6 unique-local (`fc00::/7`), and IPv4-mapped IPv6 wrappers such as `[::ffff:127.0.0.1]`. The dial pins to the validated IP to prevent DNS rebinding between validation and connect.
221+
Webhook callbacks and `downloadFrom` fetches go through Gotenberg's outbound filter pipeline. Rejected URLs return `403 Forbidden`.
222222

223-
Since _8.31.0_, `--webhook-deny-list` and `--api-download-from-deny-list` default to a regex matching the same internal ranges. An allow-list match bypasses the IP check and lets you opt into specific internal destinations. The deny-list always applies and cannot be bypassed.
224-
225-
Rejected URLs return `403 Forbidden` to the caller. See the [Webhook](/docs/configuration#webhook) and [API](/docs/configuration#api) configuration flags.
223+
Defaults are permissive. See [Outbound URL Filtering](/docs/outbound-url-filtering) to opt into stricter checks.
226224

227225
<Sponsors />

0 commit comments

Comments
 (0)