Security model
Read this before exposing Velkin beyond your laptop. Velkin makes specific trade-offs that only make sense once you understand its trust model.
Trust model
Velkin is built for trusted internal use — a team operating it behind a LAN or VPN, in the spirit of jsreport or Carbone. Template authors are assumed to be developers, not anonymous public-internet users.
Two consequences follow directly:
- Templates run arbitrary Handlebars and HTML by design. A template author can write any markup and CSS. That is the product, not a vulnerability — the same way a CI pipeline runs the code you put in it.
- There is no application-level authentication. No login, no API keys, no bearer tokens. Anyone who can reach the port has full access. You provide the boundary.
If you need to expose Velkin to untrusted users, this model does not fit — you'd be running attacker-controlled templates, and you should reconsider the deployment.
The real boundaries
Even within the trusted model, Velkin defends the host from what a template can reach. These are the boundaries that actually matter.
SSRF filter
When Chromium renders an HTML template it fetches the page's subresources (images, fonts, stylesheets). The SSRF guard governs what those fetches — and any URL a template references — are allowed to reach:
data:,blob:,about:— always allowed (local, self-contained).file:,chrome:,view-source:, and similar host-reading schemes — always blocked.http(s)to private, loopback, and link-local addresses (RFC 1918,127.0.0.0/8,169.254.0.0/16) — blocked by default. This is what stops a template from reaching cloud metadata (169.254.169.254) or internal services.http(s)to public hosts — allowed.
The guard blocks on literal private IPs; a hostname that resolves to a
private address is not blocked at parse time, because blocking all hostnames
would break legitimate CDN references. For tighter control, add an egress proxy
or a Kubernetes NetworkPolicy as defense in depth.
Operator toggles (default off — see the backend environment reference):
PDF_ALLOW_PRIVATE_NETWORK=true— allow private/loopback fetches.PDF_DISABLE_NETWORK_FILTER=true— disable subresource interception entirely.
Page JavaScript is off by default
The PDF is captured after the DOM is ready, so an untrusted template with in-page JavaScript could mutate the page or attempt to exfiltrate data. Velkin runs Chromium with JavaScript disabled by default.
Enable it (PDF_ALLOW_JAVASCRIPT=true) only when your templates need a
client-side library (Chart.js, D3, …) and you trust their source.
This flag controls scripts that run inside the rendered HTML page. It is
unrelated to the JavaScript helpers you write in
helpers.hbs — those always run, but in the locked-down goja VM described next.
Helper JavaScript runs in a sandbox
A report's helpers.hbs can contain JavaScript helpers
(Handlebars.registerHelper). That code executes in a goja VM — a pure-Go
ES2020 interpreter — not Node and not a browser:
- No host access. There is no
require,fs,fetch,process, network, or timers. Helpers can only transform the data they're given into strings. - No shared state. A fresh VM is created for every render, so one report's helpers can't leak data into another's, and concurrent renders are isolated.
- Tiny global surface. Only
console(a no-op) and a minimalIntl.NumberFormat(Italian Euro) are shimmed in.
A helper that throws fails the render with 422 template_error — it can't crash
the server or reach the host. The one caveat: there's no execution timeout on
the VM, so a helper stuck in an infinite loop blocks that single request. That's
acceptable under the trusted-developer model, but worth knowing if you later open
the deployment up. The blast radius of a buggy or hostile helper otherwise stays
contained to one render.
CSRF protection for the Studio
Because the Studio is a browser app with no auth, mutating endpoints (POST,
PUT, PATCH, DELETE) reject requests that look cross-site:
Sec-Fetch-Site: cross-site→403 csrf_blocked. Browsers set this header automatically, so a malicious page on another origin can't drive your API.- A request body must be
application/jsonormultipart/form-data, which blocks the simple-request CSRF vectors (text/plain, form encodings).
Non-browser clients (curl, your backend) don't send Sec-Fetch-Site and are
unaffected.
Chromium sandbox
The container runs with seccomp:unconfined so Chromium's own sandbox can
create the namespaces it needs — the renderer processes are still syscall-filtered
by that sandbox. --no-sandbox is off by default; only enable
PDF_ALLOW_NO_SANDBOX on runtimes that can't provide user namespaces.
Resource limits
- Template uploads and inline HTML content are capped (
MAX_BINARY_UPLOAD_BYTES, 16 MiB default). - JSON nesting depth is bounded (rejecting payloads nested beyond ~64 levels), and decoding rejects unknown fields and trailing documents.
- The overall JSON body size is uncapped by default; set
MAX_JSON_BODY_BYTESto bound large renderdatapayloads if you accept input from less-trusted callers. - PDF and LibreOffice conversions run under timeouts (
PDF_TIMEOUT,SOFFICE_TIMEOUT); LibreOffice runs in--safe-modewith an ephemeral profile and is killed on overrun.
Deploying safely
- Keep it on a private network. A VPN, a private subnet, or
localhostonly. Don't bind the API to a public interface. - Put a reverse proxy in front. Terminate TLS at nginx/Caddy/Traefik and
expose only the
frontendservice (it proxies/api). - Add your own auth at the proxy if you need access control — HTTP basic auth, mTLS, or SSO (OAuth2 Proxy, Authelia). Velkin intentionally leaves this to you.
- Leave the escape-hatch flags off (
PDF_ALLOW_*,PDF_DISABLE_*) unless a concrete need forces your hand, and understand what each one opens up.