Pulp Engine v0.44.0 — Release Notes
This release hardens the security posture across three layers: legacy SVG assets are now actively
blocked on all PulpEngine-controlled serving paths (upgraded from the advisory warning introduced
in v0.34.0), the PDF renderer gains a DNS-rebinding SSRF guard, and the publish gate in the editor
becomes a fail-closed nine-phase state machine that surfaces asset policy violations before any
render attempt. Operators who have not yet remediated legacy SVG assets will see 410 responses and
asset_blocked validation errors after upgrading; the remediation workflow is unchanged and is
documented in the runbook.
What changed
1. SVG asset hard block — all PulpEngine-controlled serving paths
Prior to this release, legacy SVG assets triggered a startup warning (legacy_svg_detected) but
were still served on request. This release closes that gap by enforcing an active block at every
path that Pulp Engine controls.
Private proxy (ASSET_ACCESS_MODE=private):
GET /assets/:filename now returns 410 Gone for any file whose name ends with .svg. The
response body matches the standard error shape and includes operator guidance:
{
"error": "Gone",
"code": "asset_blocked",
"message": "SVG assets are blocked. Use GET /assets?legacySvg=true to enumerate affected assets..."
}
Public filesystem mode (ASSET_BINARY_STORE=filesystem, ASSET_ACCESS_MODE=public):
An onSend hook fires after @fastify/static resolves the file but before any bytes leave the
socket. If the resolved path is under /assets/ and ends with .svg, the hook destroys the
read stream, clears stale static headers (ETag, Last-Modified, Accept-Ranges, Content-Length),
and returns the same 410 Gone body.
Asset inlining (private-mode PDF and HTML renders):
inlineAssets() pre-checks every asset src against the .svg extension before performing any
I/O. SVG srcs are classified as LEGACY_SVG_BLOCKED immediately. In strict mode (production PDF
and HTML renders) this throws AssetBlockedError, which each render endpoint catches and
converts to 422 { code: 'asset_blocked' }. In soft mode (editor HTML preview) the original URL
is left in place and a legacy_svg_blocked_inline log event is emitted.
New error codes:
asset_blocked has been added to both ValidateIssueCodeSchema and RenderErrorCodeSchema in
shared.ts. Clients can distinguish a policy block from a render error or a missing-template
error by checking code.
PreviewPanel (apps/editor):
The error classifier now maps code === 'asset_blocked' to its own ErrorKind, which is shown
to the user as a distinct message rather than falling through to the generic render error label.
Public S3 exception:
When ASSET_BINARY_STORE=s3 and ASSET_ACCESS_MODE=public, assets are served directly from S3.
Pulp Engine is not in the request path for those URLs, so the runtime block described above does
not apply. Operators in this configuration must:
- Delete the SVG objects from the S3 bucket directly, or
- Add a bucket or CDN policy that returns 410/403 for
*.svgkeys, or - Switch to
ASSET_ACCESS_MODE=privateto route asset delivery through the Pulp Engine proxy.
The startup audit logs a dedicated legacy_svg_s3_public_unblockable warning when this
condition is detected, so the gap is visible in logs even before action is taken.
2. /render/validate — Step 1.5: blocked asset pre-check
POST /render/validate now performs a blocked-asset check between its structural validation pass
and the full render attempt:
- Queries
assetStore.list({ legacySvg: true })to obtain the current set of blocked asset URLs. - Walks the template document tree with
collectBlockedAssetRefs()(from the newtemplate-asset-scan.tsutility) looking for static/assets/…image src values that match a blocked URL. - If any match is found, returns immediately:
{ "valid": false, "issues": [{ "code": "asset_blocked", "message": "…" }] }
This prevents the render engine from being invoked for a template that is known to be unserviceable, and gives the editor’s publish gate an early, cheap signal to surface to the operator before asking the user to publish.
3. GET /assets?legacySvg=true — referencedBy annotation
When the ?legacySvg=true query parameter is present, the asset list endpoint now annotates each
returned asset record with the active templates that reference it via a static image src:
{
"id": "...",
"filename": "logo.svg",
"mimeType": "image/svg+xml",
"referencedBy": [
{ "key": "loan-approval-letter", "name": "Loan Approval Letter", "currentVersion": "1.0.0" }
]
}
referencedBy is an array of { key, name, currentVersion } objects, sorted by template key,
and is only populated on the ?legacySvg=true path. The field is absent on normal asset list
responses.
Implementation:
A new utility module, apps/api/src/lib/template-asset-scan.ts, provides:
normalizeAssetSrc(src)— strips query strings and fragments before matchingcollectStaticAssetSrcs(node)— recursively walks a template document tree (handlingchildren,columns,elseChildren,emptyChildren) and returns a deduplicatedSetof static (non-Handlebars) image src values
The startup audit in storage.plugin.ts now uses the same utility to cross-reference legacy SVG
assets against all active templates and includes the referencingTemplateKeys array in the
legacy_svg_detected log event. If no active templates reference the SVGs, the warning says so
explicitly (“safe to delete”).
4. DNS-rebinding SSRF guard (packages/pdf-renderer)
A new module, packages/pdf-renderer/src/network-guard.ts, provides synchronous and
asynchronous checks that the PDF renderer uses to block requests to private or internal addresses.
Blocked address ranges:
| Range | Description |
|---|---|
127.0.0.0/8, ::1 | Loopback |
localhost | Literal hostname |
10.0.0.0/8 | RFC 1918 private |
172.16.0.0/12 | RFC 1918 private |
192.168.0.0/16 | RFC 1918 private |
169.254.0.0/16 | Link-local / cloud metadata (AWS, GCP) |
fc00::/7 | ULA IPv6 (fc00:… and fd…) |
Exports:
isBlockedHostname(hostname)— synchronous check against literal strings (loopback, localhost)isBlockedIpAddress(ip)— checks a resolved numeric IP against the ranges aboveresolveHostnameIps(hostname)— async OS DNS resolution with a 5-second timeout; returns an array of resolved addresses
Five-layer defence in renderer.ts:
- Trusted-origin exemption — asset server origin always passes (no self-blockage).
- Malformed URL fast-path — non-parseable URLs are blocked synchronously.
- Synchronous hostname check — literal loopback/localhost strings blocked without DNS.
- Non-HTTP bypass —
data:,blob:and other non-HTTP(S) schemes pass through unchanged. - Literal IP shortcut — if the host already looks like a numeric IP, skip DNS and check directly; otherwise resolve and check each returned address.
A per-render hostname cache prevents repeated DNS lookups for the same hostname within a single render job.
5. Publish gate hardening (apps/editor)
PublishGateDialog — nine-phase state machine:
The dialog now tracks explicit phases instead of ad-hoc boolean flags:
| Phase | Description |
|---|---|
idle | Dialog opened; no check started yet |
checking | Synchronous structural + asset validation running |
blocked | Template has blocking structural errors |
data_invalid | Preview data JSON is malformed — cannot proceed to preflight |
asset_fetch_failed | Asset list fetch failed — cannot perform asset checks |
preflight | Async /render/validate call in progress |
ready | All checks passed — publish permitted |
preflight_failed | Template renders with errors despite passing static checks |
preflight_error | Validation service returned a non-2xx error |
preflight_unavailable | Network unreachable (TypeError from fetch) |
Key changes from the previous implementation:
asset_fetch_failed— previously, a failed asset list fetch was silently swallowed and validation proceeded as though there were no assets. It now surfaces as a hard block with an operator-visible message.data_invalid— malformed preview data JSON previously allowed the preflight to proceed (or fail in an undiagnosed way). It is now detected early and shown as a specific error before any server call.asset_blocked— ifcollectAssetIssuesreturns a blocked SVG issue, the dialog enters theblockedphase immediately, without running the server-side preflight.
App.tsx now tracks assetsFetchFailed: boolean alongside loadedAssets and passes both to
PublishGateDialog. EditorShell exposes an onAssetsFetchError callback to the asset panel.
collectAssetIssues in template-validator.ts:
A new export detects:
- Static
/assets/…image src references that are absent from the loaded asset list (missing) - Static
/assets/…image src references whose matching asset hasmimeType: 'image/svg+xml'or a.svgfilename extension (blocked)
Dynamic Handlebars sources ({{…}}) and external URLs are ignored. Asset URLs are normalised
(query strings and fragments stripped) before matching.
Rich-text link protocol validation has also been extended to block javascript: and data: URIs
in addition to the existing safe-protocol allowlist.
RenderConfigEditor — stale-state fix:
The render configuration editor (page size, orientation, margins, header/footer) now uses a dirty-ref guard per field. External changes (e.g. from undo) are applied only when the field is not in an active editing state, preventing undo from silently leaving the visible UI out of sync with the stored value.
6. Tests
New and updated API tests:
asset-access-mode.test.ts— SVG blocking in public and private modes; 410 responses for.svgfiles across filesystem and S3 configurationsasset-inline.test.ts—AssetBlockedErrorthrown in strict mode; soft-fail URL preservationasset-list-filter.test.ts—referencedByannotation populated and sorted correctly; absent on non-?legacySvg=truerequestsrender-asset-fail.test.ts— route-level regression: production HTML and PDF renders return422 { code: 'asset_blocked' }when a template references a legacy SVG in private mode; preview HTML soft-fail returns 200 with broken URL preserved
New and updated editor tests:
PublishGateDialog.test.tsx— 25+ tests covering all nine phases and transitionstemplate-validator.test.ts—collectAssetIssuesfor missing assets, blocked SVGs by mimeType and extension, Handlebars exclusion, external URL exclusion; link protocol validation
New and updated pdf-renderer tests:
network-guard.test.ts— 20+ tests: blocked/allowed IP addresses, literal hostname checks, DNS resolution, 5-second timeout, error handlingrenderer.test.ts— SSRF guard integration: private IP URLs blocked, trusted-origin URLs pass through, per-render cache behaviour
New component test files:
ChildAddSelect.test.tsxSectionNodeView.test.tsxRenderConfigEditor.test.tsxConditionalNodeProps.test.tsx
Validation
pnpm typecheck— cleanpnpm lint— 0 errorspnpm --filter @pulp-engine/api test:file— passed, 0 failedpnpm --filter @pulp-engine/editor test— passed, 0 failedpnpm --filter @pulp-engine/pdf-renderer test— passed, 0 failedpnpm build— all packages successfulpnpm test(postgres) — skipped locally; covered by CItestjob
Upgrade
Breaking changes:
GET /assets/:filenamereturns410 Gone(previously200) for any.svgasset in private proxy mode. Clients that fetch SVG assets directly must be updated or the assets replaced.POST /render,POST /render/html,POST /render/preview/pdfreturn422 { code: 'asset_blocked' }if the template references a legacy SVG in private mode.POST /render/validatemay now return{ valid: false, issues: [{ code: 'asset_blocked' }] }for templates that previously passed validation.
Remediation workflow (unchanged from v0.34.0):
- Check the startup log for
legacy_svg_detected— if absent, no action needed. GET /assets?legacySvg=true(admin credentials) to enumerate affected assets with the newreferencedByarray showing which templates reference each one.- Update or remove the SVG image references in affected templates.
- Upload raster replacements (PNG, JPEG, GIF, or WebP) via
POST /assets/upload. DELETE /assets/:idonce no templates reference the SVG.- Restart and confirm
legacy_svg_detectedno longer appears.
See the runbook § Asset upload validation for full details.
S3 + public mode operators: The runtime block does not apply to direct S3 delivery. See § 1 — Public S3 exception above for the required out-of-band remediation steps.
No other breaking changes. Operators not using ASSET_ACCESS_MODE=private and without legacy
SVG assets are unaffected. All env var names, database schemas, and non-asset API shapes are
unchanged.
docker pull ghcr.io/OWNER/pulp-engine:v0.44.0