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
| # | Control | Rule |
|---|---|---|
| 1 | CORS | CORS_ALLOWED_ORIGINS set AND not * |
| 2 | Docs | DOCS_ENABLED explicitly set |
| 3 | Metrics auth | METRICS_TOKEN set |
| 4 | HTTPS enforcement | REQUIRE_HTTPS=true |
| 5 | Proxy trust | TRUST_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
| Variable | Default | Effect |
|---|---|---|
HARDEN_PRODUCTION | false | When 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:
| Format | MIME type | Detection |
|---|---|---|
| PNG | image/png | First 4 bytes: 89 50 4E 47 |
| JPEG | image/jpeg | First 3 bytes: FF D8 FF (covers JFIF, Exif, raw JPEG) |
| GIF | image/gif | First 4 bytes: 47 49 46 38 (“GIF8”) |
| WebP | image/webp | Bytes 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
| Format | MIME type | Status |
|---|---|---|
| PNG | image/png | Accepted |
| JPEG | image/jpeg | Accepted |
| GIF | image/gif | Accepted |
| WebP | image/webp | Accepted |
| SVG | image/svg+xml | Rejected — script-injection risk |
Error responses
All validation failures return 415 Unsupported Media Type (HTTP status unchanged). The
message field now describes the specific failure:
| Case | Example message |
|---|---|
| SVG declared | SVG uploads are not accepted. SVG files can contain JavaScript… |
| Disallowed type | Unsupported file type: image/bmp. Accepted: image/png, image/jpeg, image/gif, image/webp |
| Content/MIME mismatch | File content does not match declared type. Declared: image/png, detected: image/jpeg. |
| Unrecognized content | File content does not match any accepted image format. Declared type was: image/png. |
| File too short | File 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 viaPROXY_MIME_BY_EXT. Existing.svgfiles are served withContent-Type: image/svg+xml— unchanged. - Private-mode inline rendering (
lib/asset-inline.ts): MIME type from file extension viaMIME_BY_EXTfor base64 data URIs. Existing.svgfiles are inlined asimage/svg+xmldata URIs — unchanged. - Public-mode filesystem:
@fastify/staticserves by extension — unchanged. - Public-mode S3: files were stored in S3 with
ContentTypeset 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
| Area | Change | Default / migration |
|---|---|---|
| SVG uploads | POST /assets with image/svg+xml now returns 415 | Convert to PNG or WebP before uploading |
| MIME/content mismatch | File content that doesn’t match declared type now returns 415 | Was never valid; no action needed for well-formed clients |
HARDEN_PRODUCTION | New env var; defaults to false | No action required; existing deployments unaffected |
| Existing stored SVGs | Still served unchanged via all four serve paths | Audit and replace with raster formats where appropriate |
No database migrations required for any of the above.
Upgrading
- Pull v0.27.0.
- No database migrations needed for any storage mode.
- Restart the API.
- 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.
- 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. - Optional — harden production: Once all five security controls are configured, set
HARDEN_PRODUCTION=truefor fail-fast startup enforcement. See deployment-guide.md § Hardened Production Mode.