Pulp Engine Document Rendering
Get started
Release v0.44.0

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:

  1. Delete the SVG objects from the S3 bucket directly, or
  2. Add a bucket or CDN policy that returns 410/403 for *.svg keys, or
  3. Switch to ASSET_ACCESS_MODE=private to 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:

  1. Queries assetStore.list({ legacySvg: true }) to obtain the current set of blocked asset URLs.
  2. Walks the template document tree with collectBlockedAssetRefs() (from the new template-asset-scan.ts utility) looking for static /assets/… image src values that match a blocked URL.
  3. 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=truereferencedBy 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 matching
  • collectStaticAssetSrcs(node) — recursively walks a template document tree (handling children, columns, elseChildren, emptyChildren) and returns a deduplicated Set of 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:

RangeDescription
127.0.0.0/8, ::1Loopback
localhostLiteral hostname
10.0.0.0/8RFC 1918 private
172.16.0.0/12RFC 1918 private
192.168.0.0/16RFC 1918 private
169.254.0.0/16Link-local / cloud metadata (AWS, GCP)
fc00::/7ULA IPv6 (fc00:… and fd…)

Exports:

  • isBlockedHostname(hostname) — synchronous check against literal strings (loopback, localhost)
  • isBlockedIpAddress(ip) — checks a resolved numeric IP against the ranges above
  • resolveHostnameIps(hostname) — async OS DNS resolution with a 5-second timeout; returns an array of resolved addresses

Five-layer defence in renderer.ts:

  1. Trusted-origin exemption — asset server origin always passes (no self-blockage).
  2. Malformed URL fast-path — non-parseable URLs are blocked synchronously.
  3. Synchronous hostname check — literal loopback/localhost strings blocked without DNS.
  4. Non-HTTP bypassdata:, blob: and other non-HTTP(S) schemes pass through unchanged.
  5. 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:

PhaseDescription
idleDialog opened; no check started yet
checkingSynchronous structural + asset validation running
blockedTemplate has blocking structural errors
data_invalidPreview data JSON is malformed — cannot proceed to preflight
asset_fetch_failedAsset list fetch failed — cannot perform asset checks
preflightAsync /render/validate call in progress
readyAll checks passed — publish permitted
preflight_failedTemplate renders with errors despite passing static checks
preflight_errorValidation service returned a non-2xx error
preflight_unavailableNetwork 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 — if collectAssetIssues returns a blocked SVG issue, the dialog enters the blocked phase 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 has mimeType: 'image/svg+xml' or a .svg filename 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 .svg files across filesystem and S3 configurations
  • asset-inline.test.tsAssetBlockedError thrown in strict mode; soft-fail URL preservation
  • asset-list-filter.test.tsreferencedBy annotation populated and sorted correctly; absent on non-?legacySvg=true requests
  • render-asset-fail.test.ts — route-level regression: production HTML and PDF renders return 422 { 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 transitions
  • template-validator.test.tscollectAssetIssues for 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 handling
  • renderer.test.ts — SSRF guard integration: private IP URLs blocked, trusted-origin URLs pass through, per-render cache behaviour

New component test files:

  • ChildAddSelect.test.tsx
  • SectionNodeView.test.tsx
  • RenderConfigEditor.test.tsx
  • ConditionalNodeProps.test.tsx

Validation

  • pnpm typecheck — clean
  • pnpm lint — 0 errors
  • pnpm --filter @pulp-engine/api test:file — passed, 0 failed
  • pnpm --filter @pulp-engine/editor test — passed, 0 failed
  • pnpm --filter @pulp-engine/pdf-renderer test — passed, 0 failed
  • pnpm build — all packages successful
  • pnpm test (postgres) — skipped locally; covered by CI test job

Upgrade

Breaking changes:

  • GET /assets/:filename returns 410 Gone (previously 200) for any .svg asset 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/pdf return 422 { code: 'asset_blocked' } if the template references a legacy SVG in private mode.
  • POST /render/validate may now return { valid: false, issues: [{ code: 'asset_blocked' }] } for templates that previously passed validation.

Remediation workflow (unchanged from v0.34.0):

  1. Check the startup log for legacy_svg_detected — if absent, no action needed.
  2. GET /assets?legacySvg=true (admin credentials) to enumerate affected assets with the new referencedBy array showing which templates reference each one.
  3. Update or remove the SVG image references in affected templates.
  4. Upload raster replacements (PNG, JPEG, GIF, or WebP) via POST /assets/upload.
  5. DELETE /assets/:id once no templates reference the SVG.
  6. Restart and confirm legacy_svg_detected no 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