Images, fonts and other assets
Templates are self-contained documents, so anything they do not carry inline has to be fetched. Every one of those fetches — images, stylesheets, webfonts, scripts — goes through one guarded egress path. Nothing in the render pipeline opens a socket any other way.
What a template may reference
Section titled “What a template may reference”| Source | Allowed | Notes |
|---|---|---|
https://… on a public host | yes | The normal case. |
http://… on a public host | yes | Port 80 only, and you are trusting the network. Prefer https. |
data: URI | yes | Passed straight through, never fetched. The most reliable option. |
about:, blob: | yes | Internal to the page. |
file://, ftp://, anything else | no | Refused before any connection. |
| Any private, loopback, link-local or metadata address | no | See the policy below. |
| A filesystem path or Liquid include | no | Partials are disabled; there is no filesystem to include from. |
The SSRF policy
Section titled “The SSRF policy”Every outbound request is checked as follows.
- Scheme.
httpandhttpsonly. - Port. 80 and 443 only. An explicit
:8080is refused. - Hostname denylist, before DNS.
localhost,metadata,metadata.google.internal,metadata.goog,metadata.azure.com,instance-data,169.254.169.254,kubernetes.default,::1and anything ending in.local,.internal,.localhost,.localdomain,.home.arpaor.cluster.local. - Address check, after DNS. Every address the name resolves to is checked, and one bad answer
refuses the whole request. Blocked IPv4 ranges:
0.0.0.0/8,10.0.0.0/8,100.64.0.0/10(CGNAT),127.0.0.0/8,169.254.0.0/16,172.16.0.0/12,192.0.0.0/24,192.0.2.0/24,192.88.99.0/24,192.168.0.0/16,198.18.0.0/15,198.51.100.0/24,203.0.113.0/24,224.0.0.0/4,240.0.0.0/4and the broadcast address. IPv6 is checked properly too:::1,fc00::/7,fe80::/10,ff00::/8, and the embedded-IPv4 forms — IPv4-mapped, NAT6464:ff9b::/96and 6to42002::/16— are unwrapped and checked as IPv4. - Pinned connection. The socket connects to the exact address that was validated, so a second DNS answer cannot rebind the request onto a private host between the check and the connect.
- Redirects, revalidated. Up to 3 hops (
ASSET_MAX_REDIRECTS), each one re-checked from scheme onwards. A redirect into a private range is the classic bypass and it does not work.
Limits
Section titled “Limits”| Limit | Default | Setting |
|---|---|---|
| Wall-clock budget per asset | 5 seconds | ASSET_FETCH_TIMEOUT_MS |
| Maximum response size | 10 MB | ASSET_MAX_BYTES |
| Redirect hops | 3 | ASSET_MAX_REDIRECTS |
The size cap is enforced twice: a Content-Length larger than the cap is refused before the body
is read, and a response that grows past it mid-stream is cut off. The time budget covers all hops
together, not each one.
Requests go out with User-Agent: GalleyRender/1.0 (+https://galleyrender.com/docs/assets). If
you allowlist by user agent, that is the string.
What a refused asset does
Section titled “What a refused asset does”A refused asset fails the render, on both engines, with
asset_blocked naming the URL. A document with a hole in it is
worse than an error: an agent cannot see the hole, and a missing logo on an invoice is the kind of
thing nobody notices until a customer does.
This is the same on chromium, where every subresource the page requests is intercepted and
fetched through the guarded path, and on satori, where remote <img src="https://…"> URLs are
inlined as data URIs before the card is drawn.
options.on_blocked_asset
Section titled “options.on_blocked_asset”| Value | Behaviour |
|---|---|
"fail" (default) | The render fails with asset_blocked. details.url and details.reason name the first refusal; details.blocked_assets lists up to ten and details.blocked_count is the total. |
"skip" | The asset is dropped and the render continues — a missing image on chromium, a transparent pixel on satori. The refusal is logged on our side and nothing surfaces in the response. |
Set it per render, or as a template default in options:
{ "template": "invoice@3", "data": { }, "options": { "on_blocked_asset": "skip" } }The asset_blocked error
Section titled “The asset_blocked error”{ "error": { "type": "asset_blocked", "message": "Refused to fetch http://169.254.169.254/latest/meta-data/: address 169.254.169.254 is in a blocked range (169.254.0.0/16 link-local / cloud instance metadata).", "docs_url": "https://galleyrender.com/docs/errors/asset_blocked", "details": { "url": "http://169.254.169.254/latest/meta-data/", "reason": "address 169.254.169.254 is in a blocked range (169.254.0.0/16 link-local / cloud instance metadata)" } }}details.reason always says exactly which rule refused it: a blocked range, a denylisted
hostname, a bad port, a DNS failure, too many redirects, a size cap, a timeout, or an upstream
non-2xx. When a render asked for several assets and more than one was refused, details also
carries blocked_assets (up to ten, each with its own url and reason) and blocked_count.
Full page: asset_blocked.
Chromium
Section titled “Chromium”Both of the usual approaches work, because fonts.googleapis.com and fonts.gstatic.com are
ordinary public hosts:
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400..600&display=swap"><style> body { font-family: "Newsreader", Georgia, serif; }</style><style> @font-face { font-family: "Acme Sans"; src: url("https://assets.example.com/fonts/acme-sans.woff2") format("woff2"); font-weight: 400; font-display: block; } body { font-family: "Acme Sans", system-ui, sans-serif; }</style>The renderer waits for document.fonts.ready before it captures the page, so text is measured
against the real face rather than a fallback — as long as the font actually loads. Use
font-display: block and always supply a fallback in the stack: if the font is refused or slow,
font-display: swap will bake the fallback into your PDF.
The most robust option is no network at all:
@font-face { font-family: "Acme Sans"; src: url("data:font/woff2;base64,d09GMgABAAAAA…") format("woff2");}Satori
Section titled “Satori”The satori engine does not fetch fonts and does not use system fonts. It ships Inter at
weights 400, 600 and 700, and that is the whole set. A @font-face rule or a Google Fonts link in
a Satori template is ignored, not an error. If a card needs a specific typeface, render it through
chromium instead.
Images
Section titled “Images”<img src="https://assets.example.com/logo.png" alt="" width="160"><img src="data:image/png;base64,iVBORw0KGgoAAAANS…" alt="" width="160">Guidance that holds for both engines:
- Give every image explicit dimensions. A missing image with no width collapses the layout around it.
- Keep each file well under the 10 MB cap and well under 5 seconds to fetch. A slow CDN is indistinguishable from a broken URL once the budget runs out.
- Serve from a stable, public URL. Signed URLs from another service expire; a template that rendered last month will not render next month.
- Inline anything small, fixed and important — a logo, a signature, a watermark. It removes a whole class of failure.
Scripts and stylesheets
Section titled “Scripts and stylesheets”JavaScript runs in chromium renders, which is how chart templates draw. It is subject to
everything above: a script from a CDN goes through the same policy, service workers are blocked,
and the page starts with no cookies or storage of any kind. If a chart library is essential to a
template, inline it rather than fetching it — it is one fewer network dependency between your
data and your document.