|
| 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 /> |
0 commit comments