Skip to content

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.

SourceAllowedNotes
https://… on a public hostyesThe normal case.
http://… on a public hostyesPort 80 only, and you are trusting the network. Prefer https.
data: URIyesPassed straight through, never fetched. The most reliable option.
about:, blob:yesInternal to the page.
file://, ftp://, anything elsenoRefused before any connection.
Any private, loopback, link-local or metadata addressnoSee the policy below.
A filesystem path or Liquid includenoPartials are disabled; there is no filesystem to include from.

Every outbound request is checked as follows.

  1. Scheme. http and https only.
  2. Port. 80 and 443 only. An explicit :8080 is refused.
  3. Hostname denylist, before DNS. localhost, metadata, metadata.google.internal, metadata.goog, metadata.azure.com, instance-data, 169.254.169.254, kubernetes.default, ::1 and anything ending in .local, .internal, .localhost, .localdomain, .home.arpa or .cluster.local.
  4. 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/4 and the broadcast address. IPv6 is checked properly too: ::1, fc00::/7, fe80::/10, ff00::/8, and the embedded-IPv4 forms — IPv4-mapped, NAT64 64:ff9b::/96 and 6to4 2002::/16 — are unwrapped and checked as IPv4.
  5. 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.
  6. 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.
LimitDefaultSetting
Wall-clock budget per asset5 secondsASSET_FETCH_TIMEOUT_MS
Maximum response size10 MBASSET_MAX_BYTES
Redirect hops3ASSET_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.

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.

ValueBehaviour
"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" } }
400 Bad Request
{
"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.

Both of the usual approaches work, because fonts.googleapis.com and fonts.gstatic.com are ordinary public hosts:

Google Fonts
<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>
Your own @font-face
<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:

An inlined font
@font-face {
font-family: "Acme Sans";
src: url("data:font/woff2;base64,d09GMgABAAAAA…") format("woff2");
}

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.

A remote image
<img src="https://assets.example.com/logo.png" alt="" width="160">
The same image, inlined
<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.

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.