Pulp Engine Document Rendering
Get started
Release v0.27.0

Pulp Engine v0.27.0 — Release Notes

This release ships two independent security improvements: hardened production-mode startup enforcement (HARDEN_PRODUCTION) and asset upload trust boundary hardening. The HARDEN_PRODUCTION work was originally staged as v0.26.0 but was never tagged or published; it is included here as part of v0.27.0.


Hardened production mode (HARDEN_PRODUCTION)

An opt-in enforcement mode that converts the production startup warnings introduced in v0.25.0 into hard startup failures. Set HARDEN_PRODUCTION=true to guarantee that no required security control is silently unconfigured.

No breaking changes. HARDEN_PRODUCTION defaults to false. All existing deployments work unchanged.

What changed

When HARDEN_PRODUCTION=true, the API refuses to start unless all five required security controls are explicitly configured. All violations are collected and reported together in a single error message before exit, so operators can fix everything in one pass:

❌ HARDEN_PRODUCTION=true but required security controls are not configured:
   • CORS_ALLOWED_ORIGINS must be set to a comma-separated list of specific trusted origins
   • DOCS_ENABLED must be explicitly set (true or false)
   • METRICS_TOKEN must be set to protect GET /metrics
Configure all required controls or unset HARDEN_PRODUCTION to disable enforcement.

Enforced controls

#ControlRule
1CORSCORS_ALLOWED_ORIGINS set AND not *
2DocsDOCS_ENABLED explicitly set
3Metrics authMETRICS_TOKEN set
4HTTPS enforcementREQUIRE_HTTPS=true
5Proxy trustTRUST_PROXY=true (required when REQUIRE_HTTPS=true)

Why wildcard CORS is rejected in hardened mode: CORS_ALLOWED_ORIGINS=* has the same effect as leaving the variable unset — all origins are allowed. Permitting it in hardened mode would defeat the purpose of hardening.

New env var

VariableDefaultEffect
HARDEN_PRODUCTIONfalseWhen true, startup fails if any required security control is not explicitly configured. All violations reported together.

validate-deploy.sh update

The deployment validation script now accepts an optional fourth positional argument for the metrics bearer token:

./scripts/validate-deploy.sh http://api:3000 <admin-key> <editor-key> <metrics-token>

When provided, the /metrics check passes the Authorization: Bearer <token> header. Deployments with METRICS_TOKEN set require this argument for the metrics check to pass.

See deployment-guide.md § Hardened Production Mode for the full setup guide.


Asset upload trust boundary hardening

This release tightens asset upload validation by adding server-side magic-bytes detection, centralizing validation that was previously duplicated across three storage backends, and explicitly rejecting SVG uploads.

Breaking change for SVG uploads only. All other asset uploads are unaffected. Previously stored SVG assets remain readable via all serve paths — only new SVG uploads are rejected.


What changed

Centralized validation helper

A new internal module (lib/asset-validation.ts) consolidates all upload validation previously duplicated identically across the three storage backends (file, postgres, sqlserver). All three stores now call validateAssetUpload() at their interface boundary instead of each maintaining a local copy of the MIME type allowlist.

MIME type normalization

The declared MIME type is now normalized (trimmed, lowercased, parameters stripped) before any validation check. A client submitting image/PNG; charset=binary is treated as image/png. The normalized value is what gets stored in asset metadata.

Magic-bytes content detection

In addition to checking the declared MIME type against the allowlist, the API now reads the first bytes of every uploaded file and compares the detected format against the declared type. If they do not match, the upload is rejected with 415 Unsupported Media Type.

Detection is hand-rolled with no new npm dependency — the four supported image formats each have well-known byte signatures:

FormatMIME typeDetection
PNGimage/pngFirst 4 bytes: 89 50 4E 47
JPEGimage/jpegFirst 3 bytes: FF D8 FF (covers JFIF, Exif, raw JPEG)
GIFimage/gifFirst 4 bytes: 47 49 46 38 (“GIF8”)
WebPimage/webpBytes 0–3: 52 49 46 46 (“RIFF”) AND bytes 8–11: 57 45 42 50 (“WEBP”)

This closes a trust gap where a malicious upload could supply a trusted MIME type (e.g. image/png) with file content that does not match.

SVG explicitly rejected

image/svg+xml is removed from the accepted upload set. SVG is an XML format that can contain <script> elements, external entity references, and CSS keyframes — all of which can execute code or make external requests when rendered in a browser. Without a dedicated SVG sanitizer library, server-side acceptance of raw SVG is not commercially defensible.

The 415 error message for SVG explicitly states the reason.


Accepted formats

FormatMIME typeStatus
PNGimage/pngAccepted
JPEGimage/jpegAccepted
GIFimage/gifAccepted
WebPimage/webpAccepted
SVGimage/svg+xmlRejected — script-injection risk

Error responses

All validation failures return 415 Unsupported Media Type (HTTP status unchanged). The message field now describes the specific failure:

CaseExample message
SVG declaredSVG uploads are not accepted. SVG files can contain JavaScript…
Disallowed typeUnsupported file type: image/bmp. Accepted: image/png, image/jpeg, image/gif, image/webp
Content/MIME mismatchFile content does not match declared type. Declared: image/png, detected: image/jpeg.
Unrecognized contentFile content does not match any accepted image format. Declared type was: image/png.
File too shortFile is too short to determine type. Minimum 4 bytes required.

Backward compatibility

Breaking: SVG uploads now return 415. Callers that previously uploaded SVG files must convert to PNG or WebP before uploading.

Unchanged — existing stored SVG assets: Previously uploaded SVG assets are not automatically migrated or removed. All four asset serve paths derive content type from the stored filename extension, not from current metadata:

  • Private-mode proxy (GET /assets/:filename): content type from file extension via PROXY_MIME_BY_EXT. Existing .svg files are served with Content-Type: image/svg+xml — unchanged.
  • Private-mode inline rendering (lib/asset-inline.ts): MIME type from file extension via MIME_BY_EXT for base64 data URIs. Existing .svg files are inlined as image/svg+xml data URIs — unchanged.
  • Public-mode filesystem: @fastify/static serves by extension — unchanged.
  • Public-mode S3: files were stored in S3 with ContentType set at upload time. Existing SVG assets in S3 retain their original content type — unchanged.

Operator action: Existing stored SVG assets should be audited and replaced with raster formats (PNG or WebP) where appropriate. See runbook.md § Asset upload validation.

Unchanged — all other asset uploads: PNG, JPEG, GIF, and WebP uploads with matching MIME type and content continue to be accepted. The only new rejection case for these formats is a MIME/content mismatch (e.g. a JPEG file submitted as image/png), which was never a valid upload anyway.



Breaking changes summary

AreaChangeDefault / migration
SVG uploadsPOST /assets with image/svg+xml now returns 415Convert to PNG or WebP before uploading
MIME/content mismatchFile content that doesn’t match declared type now returns 415Was never valid; no action needed for well-formed clients
HARDEN_PRODUCTIONNew env var; defaults to falseNo action required; existing deployments unaffected
Existing stored SVGsStill served unchanged via all four serve pathsAudit and replace with raster formats where appropriate

No database migrations required for any of the above.


Upgrading

  1. Pull v0.27.0.
  2. No database migrations needed for any storage mode.
  3. Restart the API.
  4. SVG callers: If your pipeline uploads SVG files as image assets, convert them to PNG or WebP before uploading, or serve them from a separate static host.
  5. Audit existing SVGs: Query the asset list API for entries where mimeType = "image/svg+xml" and replace with raster formats where appropriate. See runbook.md § Asset upload validation.
  6. Optional — harden production: Once all five security controls are configured, set HARDEN_PRODUCTION=true for fail-fast startup enforcement. See deployment-guide.md § Hardened Production Mode.