Skip to content

Image assets (1/3): Signal K plugin — secure upload, processing, worker pool, cache - #1080

Closed
dillan wants to merge 5 commits into
mxtommy:masterfrom
dillan:feat/image-assets-server
Closed

Image assets (1/3): Signal K plugin — secure upload, processing, worker pool, cache#1080
dillan wants to merge 5 commits into
mxtommy:masterfrom
dillan:feat/image-assets-server

Conversation

@dillan

@dillan dillan commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Image assets (1/3) — Signal K plugin: secure upload, on-demand processing, worker pool, cache

Part 1 of 3 adding user-uploaded image assets to KIP dashboards (e.g. a diagram of where safety equipment is stowed on the boat). This PR is the server side only — the Express routes the KIP plugin exposes under /plugins/kip. The Angular client (widget + config UI) follows in PRs 2 and 3.

This PR is independent and can be reviewed/merged on its own; the client PRs depend on it at runtime.

What it does

  • Shared, boat-wide image library stored on the Signal K server. Originals are stored once; resized WebP copies are generated on demand on first request and cached on disk.
  • Formats in: JPG, PNG, WebP, GIF (including animated), HEIC/HEIF, SVG. 10 MB per-upload limit (enforced server-side by multer).
  • On-demand processing off the main thread: a worker_threads pool (size max(1, nCPU-1)) runs every sharp/heic-convert job, with in-flight coalescing so concurrent requests for the same uncached variant do the work once. Per-job timeout + crash/exit recovery so a bad image can't wedge the pool.
  • Variants: raster is always re-encoded to WebP, resized with fit:'inside', withoutEnlargement:true, width snapped to a fixed allow-list [160,320,640,960,1280,1920,2560]. Animated GIF → animated WebP. SVG is served as sanitized vector (not resized).

Security (untrusted uploads)

  • Login required to upload/delete/purge (anonymous/read-only refused); viewing only needs the normal connection.
  • Type by content sniff (magic bytes / XML root), not filename or Content-Type.
  • Raster always re-encoded via sharp on serve — original raster bytes are never served (defeats polyglots); limitInputPixels / HEIC pixel cap guard decompression bombs.
  • SVG sanitized on upload with DOMPurify (USE_PROFILES:{svg,svgFilters}, FORBID_TAGS:[script,foreignObject], strips on* + external refs); rejected if emptied. Served as image/svg+xml and only ever rendered via <img src>, where scripts don't run.
  • Safe headers on every image: validated Content-Type, X-Content-Type-Options: nosniff, restrictive CSP (default-src 'none'; sandbox), Content-Disposition: inline, long immutable cache.
  • Server UUID ids, :id validated /^[A-Za-z0-9-]+$/ (no traversal). Upload storage quota (count + total bytes) caps disk use.

The malicious-upload surface went through two rounds of adversarial review. The first hardened the 4 empirically-confirmed issues it found (worker crash/timeout recovery, HEIC transcode-at-ingest with a pixel cap, upload storage quota, poison-asset rejection via decodability check + narrowed HEIC brand allow-list). A second pre-publication pass found and fixed an SVG sanitizer gap: DOMPurify was only forbidding the legacy xlink:href, so a modern href on <image>/<a>/<feImage> survived as a phone-home beacon in the stored bytes — now both are forbidden (internal fill="url(#id)"/filter="url(#id)" references are preserved), with a test covering it.

Endpoints (under /plugins/kip)

POST /images · GET /images · GET /images/:id?w= · DELETE /images/:id · GET /images/cache · DELETE /images/cache

New dependencies

sharp (prebuilt ARM/Pi binaries), heic-convert (portable HEIC decode — avoids needing libheif in the sharp build), multer, isomorphic-dompurify. Worker pool uses built-in worker_threads.

package-lock.json is regenerated: it adds the 4 deps and their platform binaries (@img/sharp-*, jsdom-related), and npm install also pruned some orphaned protractor/selenium-era lockfile entries that were no longer referenced by package.json. No devDependency or intentional dependency was changed beyond the 4 additions. lockfileVersion is unchanged (3).

Tests

kip-plugin/tests/*.test.cjs (node:test): store validate/sanitize/store, on-demand generation + cache, HEIC→WebP, animated GIF→animated WebP, SVG script/onload/external-href neutralized, traversal id rejected, serving headers, ?w snap + cache, coalescing, worker crash/hang recovery, quota + poison-asset rejection, anonymous upload/delete/purge refused. Run with npm run test:plugin.

Status / gates

  • npm run build:plugin (tsc) clean; npm run test:plugin93/93 pass.
  • Heads-up on package-lock.json: the diff is large because npm install of the 4 deps records all platform binaries (@img/sharp-*, @esbuild/*) and also pruned a cluster of orphaned protractor/selenium/request-era entries that were no longer referenced by package.json. No devDependency or intentional dependency changed beyond the 4 additions, lockfileVersion stayed 3, and the lockfile is internally consistent (npm ci resolves). Happy to squash/regenerate the lockfile however you prefer on merge.

This is part 1 of a 3-PR stack; the Angular client (PR 2) and config UI (PR 3) follow and depend on this at runtime.


📚 3-PR stack (review/merge in order)

  1. Image assets (1/3): Signal K plugin — secure upload, processing, worker pool, cache #1080 — Signal K plugin: secure upload, processing, worker pool, cache (server)
  2. Image assets (2/3): Angular client — ImageAssetService + Image widget #1081 — Angular client: ImageAssetService + Image widget
  3. Image assets (3/3): widget config UI + settings image-cache card #1082 — Widget config UI + settings image-cache card

PR 2 needs PR 1 at runtime; PR 3 is stacked on PR 2. Together they add the full image-assets feature.

You are viewing PR 1 of 3.

dillan added 5 commits June 23, 2026 19:15
First slice of user-uploaded image assets (server). ImageStore is an Express-free,
unit-tested core that:
- detects image type by content magic bytes (png/jpeg/webp/gif/heic/svg), not the
  client extension/MIME, and rejects anything else;
- sanitizes SVG with DOMPurify (strips scripts/handlers/external refs) and stores it
  as vector, rejecting SVGs that don't survive sanitization;
- validates raster decodability + dimensions via sharp with a pixel-budget guard;
- stores the original bytes plus a per-image sidecar JSON of metadata (id-addressed
  UUIDs, no client path), and supports list/getMeta/remove with id validation.

Adds the server deps (multer, sharp, heic-convert, isomorphic-dompurify) and runs all
kip-plugin test files. On-demand WebP re-encode/resize + cache + worker pool and the
HTTP routes build on this next.
Builds the serving layer on top of ImageStore:
- image-processing: convert/resize a validated original to WebP, preserving animation
  (GIF/WebP) and decoding HEIC/HEIF via heic-convert; widths snapped to an allow-list;
  worker-count helper = n-1 CPUs (clamped to 1).
- worker-pool: runs processing in a worker_threads pool (default n-1) so the SK server
  stays responsive; concurrent requests for the same variant are coalesced into one job.
- image-worker: the pool's worker entry (sharp.concurrency(1) per worker).
- ImageStore.getServable: serves sanitized SVG as-is, or an on-demand WebP variant that
  is cached on disk and re-served thereafter; raster is always re-encoded (raw original
  never served); every response carries nosniff + restrictive CSP + inline headers.
  Adds cacheStats()/purgeCache() for the settings UI.

22 node:test cases cover snapping, animation preservation, on-demand+cache, SVG passthrough,
cache stats/purge, safe headers, and worker-pool sizing/coalescing. Fixes the plugin test
script to run every *.test.cjs file.
image-router registers the /images endpoints on the plugin's Express router:
- POST /images        upload (multer, 10MB, login required, auth checked before parsing)
- GET  /images        list the shared library
- GET  /images/:id?w= serve a cached WebP variant (raster) or sanitized SVG, with safe headers
- DELETE /images/:id  remove (login required)
- GET/DELETE /images/cache   cache size + purge (purge login required)

Auth: SK's security middleware is the primary gate (as for the existing displays routes); a
defensive isAuthenticatedRequest() also checks the SK-set req.skPrincipal so anonymous users are
refused when security is enabled, while a server with no security configured stays permissive.
Cache routes are registered before /images/:id so "cache" is not matched as an id; ids are
UUID-validated.

index.ts lazily builds the ImageStore + worker pool on first route use (the data dir is only
known after init) and tears the pool down on stop(). 8 route tests cover auth rejection, listing,
serving + headers, id validation/404, cache stats, and not-ready 503. Full plugin suite: 87 green.
…view

HIGH — worker pool could permanently wedge all image serving: a worker that died via
native crash/OOM/hard-exit (no 'error' event) silently left the pool, which never
respawned, so queued jobs (and their HTTP requests) hung forever. The pool now detects
deaths via BOTH 'error' and 'exit', rejects the in-flight job, and spawns a replacement,
and a per-job timeout rejects+replaces a hung worker. (tests: crash-recovery, timeout-recovery)

HIGH — HEIC decode bypassed the pixel budget: heic-convert decodes the full raster before
sharp, so a 12KB/49MP HEIC forced ~632MB per serve x 7 widths. HEIC is now transcoded to a
canonical WebP ONCE at upload, bounded by a strict 24MP HEIC cap, and served as cheap WebP
resizes thereafter (no per-request HEIC decode).

LOW — un-serveable "poison" assets: a non-HEVC HEIF / brand-spoofed AVIF detected as HEIC was
stored but failed on every GET. The ingest transcode now proves decodability, so such files
are rejected up front; the HEIC brand allow-list is narrowed to HEVC brands. (test: AVIF-as-HEIC)

LOW — no upload quota let an authenticated user fill the shared data volume over time. Added a
configurable image-count + total-bytes cap, checked before any decode work. (tests: both quotas)

Full plugin suite: 92 green.
…egexes

DOMPurify was only forbidding the legacy xlink:href attribute, so a modern
href on <image>/<a>/<feImage> survived sanitization — an external href is a
phone-home beacon in the stored SVG bytes, which is supposed to be self-safe
(defense in depth beyond the serve-path sandbox CSP). Forbid 'href' as well.
Internal references drawings rely on (fill="url(#g)", filter="url(#f)")
are not href attributes and are preserved; added a test covering both.

Also replace two regexes that contained literal control bytes (a raw BOM and
a raw NUL..0x1f range) with \uFEFF / \x00-\x1f escape sequences. Behavior is
identical, but the source no longer reads as a binary file to git/grep/editors.
@tkurki

tkurki commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

This would or could be useful as an independent plugin or as an extendion of server’s applicationData scaffolding. There is nothing inherently KIP specific, is there?

@dillan

dillan commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

This would or could be useful as an independent plugin or as an extendion of server’s applicationData scaffolding. There is nothing inherently KIP specific, is there?

Not much Kip specific other than the widget & widget configuration. If there is enough interest I could split things out into a separate image handling plugin. I didn't go that route initially because I think it's valuable to give users the "batteries included" experience when and where possible. Making users install a plugin first adds friction.

@godind Mind sharing your perspective on this discussion?

@godind

godind commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

I agree with both of you: I like very much the "batteries included" mindset and I'm more than happy to add a new camera display widget (it's been requested a few times over the last 2-3 years) but I also agree that the video processing engine part would be better leveraged as a server plugin/feature.

That way, as Teppo says, you have applicationData and other features that could open the door to say, recording storage, re-encoding, YouTube publishing, dropbox sync and what not, without KIP running.

Leave only the display and control part as KIP a widget ;)

@godind

godind commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Oh and KIP has the ability to add required plugins to widgets, auto enable plugins and push config, not sure I have done the autoinstall part yet...maybe, but not a big deal to add. That way when you add the widget, KIP asks the server to install and enable it.

@dillan

dillan commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Ok, I'll split this out. Thanks!

@dillan

dillan commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favor of the standalone plugin. The image server engine in this PR became the standalone SK Image Signal K plugin (git@github.com:dillan/sk-image, published 1.5.0, in the App Store). Its security posture — content-type sniffing, SVG sanitization, WebP re-encode, size/pixel caps, nosniff + CSP headers — carried over and has since shipped through 1.5.0 (verified: identical supported-format allow-list and sanitize/header protections). The KIP-side display widget lands in #1120.

@dillan dillan closed this Jul 6, 2026
@dillan
dillan deleted the feat/image-assets-server branch July 6, 2026 19:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants