[object Object]

← back to Dw Photo Capture

harden: process-level error safety net + string-validate dataUrl (fixes 1-request DoS)

9a8a27242f626d79f3f1609a162c53de3950e112 · 2026-09-20 18:26:25 -0700 · Steve Abrams

Verified DoS (TK-11962 review): POST {dataUrl:{}} to /api/identify, /api/recognize,
/api/identify-multi, /api/ocr, or /api/video threw TypeError (non-string .replace)
inside an async req.on('end') handler -> unhandled rejection -> Node v26 exited the
whole capture service for ALL users. No process-level handlers existed.

Fix (defense in depth):
- process.on('unhandledRejection'|'uncaughtException') log + keep serving (requests
  are independent; surviving one bad request beats a fleet-wide crash).
- typeof p.dataUrl==='string' guards at all 5 endpoints -> clean 400 instead of throw.

Reproduced crash then re-verified: every malformed-dataUrl probe now 400s, process
survives, healthz stays 200. Local only; prod deploy stays gated (TK-11928).

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

Files touched

Diff

commit 9a8a27242f626d79f3f1609a162c53de3950e112
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Sep 20 18:26:25 2026 -0700

    harden: process-level error safety net + string-validate dataUrl (fixes 1-request DoS)
    
    Verified DoS (TK-11962 review): POST {dataUrl:{}} to /api/identify, /api/recognize,
    /api/identify-multi, /api/ocr, or /api/video threw TypeError (non-string .replace)
    inside an async req.on('end') handler -> unhandled rejection -> Node v26 exited the
    whole capture service for ALL users. No process-level handlers existed.
    
    Fix (defense in depth):
    - process.on('unhandledRejection'|'uncaughtException') log + keep serving (requests
      are independent; surviving one bad request beats a fleet-wide crash).
    - typeof p.dataUrl==='string' guards at all 5 endpoints -> clean 400 instead of throw.
    
    Reproduced crash then re-verified: every malformed-dataUrl probe now 400s, process
    survives, healthz stays 200. Local only; prod deploy stays gated (TK-11928).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01D1JYdvZfHHAcizjE4LY79X
---
 server.js | 25 ++++++++++++++++++++-----
 1 file changed, 20 insertions(+), 5 deletions(-)

diff --git a/server.js b/server.js
index 21bc71a..a20477f 100644
--- a/server.js
+++ b/server.js
@@ -15,6 +15,21 @@ const fs = require('fs');
 const path = require('path');
 const { execFile } = require('child_process');
 
+// ── Process-level safety net (TK-11962) ──────────────────────────────────────
+// A single malformed request (verified: POST {dataUrl:{}} → non-string .replace)
+// used to throw inside an async req.on('end') handler → unhandled rejection →
+// Node v15+ EXITS the whole capture service for ALL users (reproduced 1-request
+// DoS). These handlers log and KEEP SERVING: every request here is independent
+// (no shared in-flight transaction to corrupt), so surviving one bad request is
+// strictly better than a fleet-wide crash. Paired with per-endpoint string
+// validation below (defense in depth — catches the NEXT unvalidated-input bug).
+process.on('unhandledRejection', (reason) => {
+  try { console.error('[unhandledRejection]', (reason && reason.stack) || reason); } catch (e) {}
+});
+process.on('uncaughtException', (err) => {
+  try { console.error('[uncaughtException]', (err && err.stack) || err); } catch (e) {}
+});
+
 // FileMaker Cloud client (sample-request lookup + sticker-print flag). Loaded guarded so a missing
 // dep / unset creds degrade gracefully instead of crashing the scanner.
 let FM = null;
@@ -1439,7 +1454,7 @@ const appHandler = (req, res) => {
     let body = ''; req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
     req.on('end', async () => {
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
-      if (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
+      if (typeof p.dataUrl !== 'string' || !p.dataUrl) return send(res, 400, { err: 'dataUrl required (string)' });
       const b64 = p.dataUrl.replace(/^data:image\/\w+;base64,/, '');
       const prompt = 'This is a wallcovering or fabric SAMPLE label. Identify the BRAND from its logo or wordmark and note the typesetting. Reply ONLY as compact JSON: {"brand":"<manufacturer/brand or empty if unsure>","confidence":<0-1>,"logo":"<short logo/wordmark description>","typeface":"<e.g. serif wordmark / sans caps / script>","code":"<any SKU or model number visible, else empty>"}';
       // engine-pluggable ({engine:...}); macvision keeps the model toggle ({model:.. }/{fast:true} → moondream).
@@ -1473,7 +1488,7 @@ const appHandler = (req, res) => {
     let body = ''; req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
     req.on('end', async () => {
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
-      if (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
+      if (typeof p.dataUrl !== 'string' || !p.dataUrl) return send(res, 400, { err: 'dataUrl required (string)' });
       const b64 = p.dataUrl.replace(/^data:image\/\w+;base64,/, '');
       const prompt = 'You are looking at a wallcovering or fabric SWATCH — the material itself, NOT a printed label. Describe the PATTERN so it can be matched against a catalog. Reply ONLY as compact JSON: {"description":"<one short sentence>","motif":"<main motif e.g. floral, damask, geometric, grasscloth, stripe, botanical, abstract, ikat, toile>","style":"<e.g. traditional, modern, transitional, scandinavian, art deco, contemporary>","material":"<e.g. grasscloth, non-woven, silk, vinyl, paper, leather>","colors":["<color name>","<color name>"],"background":"<background color name>","scale":"<small | medium | large>","code":"<any SKU or model number printed on it, else empty>"}';
       const engine = resolveEngines(p.engine)[0] || DEFAULT_ENGINE;
@@ -1649,7 +1664,7 @@ const appHandler = (req, res) => {
     let body = ''; req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
     req.on('end', async () => {
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
-      if (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
+      if (typeof p.dataUrl !== 'string' || !p.dataUrl) return send(res, 400, { err: 'dataUrl required (string)' });
       const b64 = p.dataUrl.replace(/^data:image\/\w+;base64,/, '');
       const prompt = 'This image is a product SAMPLE label OR a printed SPEC SHEET for an interior product '
         + '(wallcovering, fabric, trim, drapery, rug, or similar) from ANY manufacturer. Extract EVERY spec you can find. '
@@ -2167,7 +2182,7 @@ load();
     req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
     req.on('end', async () => {
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
-      if (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
+      if (typeof p.dataUrl !== 'string' || !p.dataUrl) return send(res, 400, { err: 'dataUrl required (string)' });
       let buf; try { buf = Buffer.from(p.dataUrl.replace(/^data:image\/\w+;base64,/, ''), 'base64'); }
       catch (e) { return send(res, 400, { err: 'bad dataUrl' }); }
       const engines = resolveEngines(p.engine || u.searchParams.get('engine'));
@@ -2196,7 +2211,7 @@ load();
     req.on('end', async () => {
       if (tooBig) return send(res, 413, { ok: false, err: 'video too large (max ~150MB)' });
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
-      if (!p.product_id || !p.dataUrl) return send(res, 400, { err: 'product_id + dataUrl required' });
+      if (!p.product_id || typeof p.dataUrl !== 'string' || !p.dataUrl) return send(res, 400, { err: 'product_id + dataUrl (string) required' });
       const m = /^data:(video\/\w+);base64,/.exec(p.dataUrl);
       const mime = (m && m[1]) || 'video/mp4';
       const buf = Buffer.from(p.dataUrl.replace(/^data:[^,]+,/, ''), 'base64');

← 39c872f auto-data-snapshot: 2026-09-20T12:15:15 (2 data files) — dat  ·  back to Dw Photo Capture  ·  auto-data-snapshot: 2026-09-20T18:25:19 (3 data files) — 5x/ 71c8d21 →