Skip to content

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

Merged
mairas merged 8 commits into
mainfrom
replicate/pr-1080-image-assets-server
Jul 1, 2026
Merged

Image assets (1/3): Signal K plugin secure upload, processing, worker pool, cache (replicates mxtommy/Kip #1080)#54
mairas merged 8 commits into
mainfrom
replicate/pr-1080-image-assets-server

Conversation

@mairas

@mairas mairas commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Replicates upstream PR mxtommy/Kip#1080 ("Image assets (1/3): Signal K plugin secure upload, processing, worker pool, cache") by dillan.

Method: commit-by-commit cherry-pick (5 commits, linear history, no merge commits). Each commit's original author was preserved. The package.json and package-lock.json changes auto-merged cleanly with no conflict; SKip identity (@halos-org/skip, version 4.8.0) was retained while the PR's new dependencies (heic-convert, isomorphic-dompurify, multer, sharp) and the test:plugin script change were incorporated.

Note: package-lock.json was auto-merged by git and will need regeneration (npm install) before a clean build, since dependency installation/build was intentionally not run during replication.

This is an experimental replica carried in the fork for evaluation. It needs a build + review before merge.

dillan added 5 commits July 1, 2026 00:08
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.
@mairas mairas added the upstream-replica Replica of an upstream mxtommy/Kip PR label Jun 30, 2026
node --test only expands glob patterns on Node 21+. The quoted
"kip-plugin/tests/*.test.cjs" was treated as a literal path on the
Node 20 CI leg, failing with "Could not find". Drop the quotes so the
shell expands the glob, which works across the 20/22/24 matrix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETfeXXs8R5ciw6nGxGYj2C
@mairas

mairas commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Content review: accept-with-fixes — strong ingest/sanitization, but verified DoS + authz gaps to close before merge

Reviewed the full implementation (image-router.ts, image-store.ts, worker-pool.ts, image-processing.ts, image-worker.ts) across three lenses — upload/sanitization, processing-DoS/filesystem, authz/reliability — reading the code and attempting exploits. Every finding below was independently re-verified against the code. This is not a clean adopt: the sanitization is genuinely strong, but the serving path has DoS vectors that can OOM the Signal K host process, and the write gate is authentication-only.

What's genuinely solid (verified in code):

  • Type smuggling is closed. detectImageType keys off magic bytes and ignores the client MIME/extension; every raster (and HEIC, transcoded at ingest) is unconditionally re-encoded to WebP by sharp before serving, so a JPEG/HTML or WebP/HTML polyglot loses any payload. Only SVG is served as-is, behind layered defense.
  • SVG XSS defense is layered and I couldn't break it: DOMPurify at ingest + serve-time CSP sandbox + X-Content-Type-Options: nosniff.
  • Decompression-bomb caps run before full decode: 10 MB byte cap (multer + re-checked in ingest), a width*height*pages > 50 MP raster check via sharp.metadata(), and a stricter 24 MP budget before the pure-JS heic-convert decode.
  • The worker-pool crash-recovery state machine is well-engineered: idempotent failWorker via a dead WeakSet (a native crash firing both error+exit doesn't double-spawn), per-job wall-clock timeout with timer.unref(), poison job rejected once and not retried.

Findings:

[high · reliability] Serving flood can OOM the Signal K host processimage-store.ts:281-289, worker-pool.ts:66-77, image-router.ts:124-142. The worker-pool job queue is unbounded and GET /images/:id is unauthenticated with no rate/concurrency limit. On a cache miss, getServable reads the full original (≤10 MB) into main-thread heap then queues the job; coalescing only dedupes identical id:width keys. A burst of GET /images/<id>?w=<width> across all widths × all ids parks each original in the unbounded queue → the main SK process (which also serves the boat's navigation data) OOM-kills. Verified real.

[high · reliability] Concurrent cache-miss GETs multiply full-size buffers in memory — same root; ~1000 concurrent misses ≈ multiple GB of heap on a 2–8 GB Pi. Verified real. Fix both with a bounded queue + a concurrency cap on the serve route.

[medium · security] All read routes are unauthenticatedimage-router.ts:86,101,133. GET /images (list — includes uploadedBy principal ids), GET /images/:id, GET /images/cache have no isAuth. On a secured boat with no anonymous-read allowance, a guest on the (often open) WiFi enumerates and downloads the entire shared library. Verified real.

[medium · security] Write gate checks authentication, not authorizationimage-router.ts:19-24. isAuthenticatedRequest returns true for any principal with an identifier; it never checks permission level, so a read-only SK account (e.g. crew given view-only access) can POST/DELETE images and purge the cache. Verified real.

[medium · reliability] multer leaves non-file fields unboundedimage-router.ts:54. limits sets only fileSize+files:1; fields/parts/fieldSize default to Infinity/1 MB. A multipart body with a tiny valid file + a stream of 1 MB text fields buffers into req.body (memoryStorage) until OOM. Verified real — set fields/parts limits.

[medium · design] Auth rests on an unverified SK assumptionimage-router.ts:20-22 fail-opens (returns true) when both skPrincipal and skIsAuthenticated are undefined. If SK doesn't populate those on plugin-router requests on the deployed version, a secured server silently fail-opens on writes. Needs verification against a real security-enabled SK server before unattended deployment — not a deferring code comment.

[low · reliability] destroy() leaks pending jobsworker-pool.ts:161-165 terminates workers without rejecting queued/in-flight job promises; awaiting Express handlers hang until socket timeout on plugin stop/restart. Verified real (downgraded to low).

Plus lows: variant cache has no size eviction (unauth GETs can force ~3.5 GB across 500 images × 7 widths on the shared SK data volume), TOCTOU-racy quota under concurrent uploads, orphaned originals never reclaimed on partial write, non-atomic cache/original writes, and a clean-idle-exit zombie worker burning one job into a timeout.

Bottom line: the ingest/sanitization path is well-built and I couldn't smuggle a malicious file or stored XSS through it. But before this runs unattended, the two HIGH OOM vectors (unbounded queue + no serve-path backpressure), the open read routes, and the authn-only write gate should be fixed — taking down the Signal K server is the worst failure mode here.

(Resolution applied earlier: conflict-free; CI fix — unquoted the plugin-test glob so Node 20's node --test finds the files. Green on 20/22/24.)

… review)

Address the DoS and authz findings from the content review:

- getServable now coalesces concurrent identical variant generations into
  one read+decode and caps concurrent DISTINCT generations (ImageStore
  GenerationLimiter), so an unauthenticated serve flood can no longer
  multiply full-size originals in memory and OOM the Signal K host. Excess
  distinct requests queue to a bound, then get a fast 503.
- WorkerPool: bound the job queue (was unbounded), and reject queued +
  in-flight jobs on destroy() instead of leaking their promises.
- multer: cap non-file multipart parts (fields:0) — busboy otherwise
  defaults to an unlimited number of 1 MB text fields buffered in memory.
- Gate GET /images (list) and /images/cache: both are fetched by the
  authenticated HTTP client and expose library metadata / uploader ids.
  The /images/:id serve route stays open by necessity (<img> can't carry
  the bearer token); its DoS is now bounded above rather than by auth.
- Write files atomically (temp + rename) so a mid-write crash can't serve
  a half-written original or variant.

Not fixed here (needs verification against a security-enabled SK server,
not possible in this repo): the write gate checks authentication, not write
permission, and fail-opens when SK populates no principal fields. SK's
server-api exposes no principal/permission surface to key these off.

Tests added: coalescing, concurrency-cap 503, queue bound, destroy
rejection, read-route gating, serve-open, 503 mapping. Plugin suite
100/100 on Node 24; 0 fail on Node 20.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ETfeXXs8R5ciw6nGxGYj2C
@mairas

mairas commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Fixes pushed

Addresses the DoS + reliability findings from the review:

  • Serve-path OOM (both HIGH): getServable coalesces concurrent identical variant generations and caps concurrent distinct generations (a GenerationLimiter), so a serve flood can no longer multiply full-original buffers in memory. The worker-pool queue is bounded and destroy() now rejects pending jobs instead of leaking them. Excess load returns 503.
  • multer: fields: 0 closes the unbounded-text-field memory DoS.
  • Read gating: GET /images (list, exposes uploader ids) and /images/cache now require auth — both go through the app's authenticated HTTP client. GET /images/:id (serve) stays open by necessity (a browser <img> can't carry the bearer token); its DoS is bounded above rather than by auth.
  • Atomic writes: originals + cached variants written via temp + rename.

Tests added: coalescing, concurrency-cap 503, queue bound, destroy rejection, read-gating, serve-open, 503 mapping. Plugin suite 100/100 on Node 24, 0 fail on Node 20; CI green.

Deliberately not fixed here (flagging so it isn't lost): the write gate still checks authentication, not write permission — a read-only SK account can still upload/delete/purge — and it fail-opens if SK sets no principal fields on plugin routes. @signalk/server-api exposes no principal/permission surface to key these off, so this needs an SK-server integration decision and an end-to-end check against a security-enabled server, which can't be done from this repo. Left for the e2e step.

@mairas
mairas merged commit a0dbd84 into main Jul 1, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

upstream-replica Replica of an upstream mxtommy/Kip PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants