[object Object]

← back to Dw Photo Capture

security: fix /api/learn prototype-pollution DoS (cycle 15)

f35ef39813cd18ba8292cd0ff0dc4323d108bf90 · 2026-06-26 06:44:41 -0700 · Steve Abrams

Investigating brainstorm idea #4 found a REAL, reproducible denial-of-service. POSTing
{vendor:'__proto__'} to /api/learn crashed the whole process: VENDOR_PROFILES['__proto__']
returns Object.prototype (truthy), so 'const prof = VENDOR_PROFILES[vendor] || ...' binds
prof to Object.prototype, then prof.prefixes[pre] dereferences undefined → uncaught
TypeError → process exit (launchd then auto-restarted it). One authenticated request — and
bgIdentify auto-calls /api/learn with VLM-derived vendor names — could repeatedly kill the
scanner.

Fix: reject the dangerous prototype keys (__proto__/constructor/prototype) with 400; no real
vendor name is one. Verified: pre-fix reproduced (HTTP 000 + crash in dwphoto.err.log);
post-fix all 3 payloads → 400 'invalid vendor', server stays up (selfcheck 200), normal
learn still 200. lint + 46 tests green. $0 (local).

Files touched

Diff

commit f35ef39813cd18ba8292cd0ff0dc4323d108bf90
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jun 26 06:44:41 2026 -0700

    security: fix /api/learn prototype-pollution DoS (cycle 15)
    
    Investigating brainstorm idea #4 found a REAL, reproducible denial-of-service. POSTing
    {vendor:'__proto__'} to /api/learn crashed the whole process: VENDOR_PROFILES['__proto__']
    returns Object.prototype (truthy), so 'const prof = VENDOR_PROFILES[vendor] || ...' binds
    prof to Object.prototype, then prof.prefixes[pre] dereferences undefined → uncaught
    TypeError → process exit (launchd then auto-restarted it). One authenticated request — and
    bgIdentify auto-calls /api/learn with VLM-derived vendor names — could repeatedly kill the
    scanner.
    
    Fix: reject the dangerous prototype keys (__proto__/constructor/prototype) with 400; no real
    vendor name is one. Verified: pre-fix reproduced (HTTP 000 + crash in dwphoto.err.log);
    post-fix all 3 payloads → 400 'invalid vendor', server stays up (selfcheck 200), normal
    learn still 200. lint + 46 tests green. $0 (local).
---
 OVERNIGHT_LEDGER.md | 1 +
 server.js           | 4 ++++
 2 files changed, 5 insertions(+)

diff --git a/OVERNIGHT_LEDGER.md b/OVERNIGHT_LEDGER.md
index 751442c..ad7fef5 100644
--- a/OVERNIGHT_LEDGER.md
+++ b/OVERNIGHT_LEDGER.md
@@ -57,3 +57,4 @@ the single highest-value safe one, the officer executes it, and the loop resched
 | 12 | brainstorm (qwen3:14b $0) → investigate+pick #5 | A/B proved OCR-confidence-threshold MOOT (Vision = 1.000 or no-read); shipped fingerprintVendor edge tests (empty/short→null, punct-normalized, REJECTED logo never matches) | 32→36 tests pass; lint clean | $0 |
 | 13 | DEBUG disk-full + disk-light refine | Data volume was 100% full (ENOSPC blocked all Bash); diagnosed: home=138G (Projects 83G, Ollama 45G/16 models), ~250G in sudo-gated areas (Steve-gated, not auto-deleted); shipped a disk-light pure-test refinement (5 fuzzyVendor edge guards) | df recovered 124Mi→4.7Gi (purgeable reclaimed); 36→41 tests; lint clean; no server restart/scratch | $0 |
 | 14 | brainstorm #1 (learning-loop) | profileToLex prefix support gate (>=2 scans) — one bad scan can no longer inject a wrong prefix into the ranking lexicon; catalog prefixes (builder-vetted n>=3) unaffected; +5 profileToLex tests | df 27G healthy; real-data impact = 1 unconfirmed prefix gated (WC test artifact), 0 vendors lose matching; 41→46 tests; lint clean; restart 200 | $0 |
+| 15 | brainstorm #4 (SECURITY) | found+fixed a real DoS: vendor="__proto__" → VENDOR_PROFILES["__proto__"] returns Object.prototype → prof.prefixes deref CRASHED the process. Guard rejects __proto__/constructor/prototype with 400 | df 31G; exploit reproduced (HTTP 000 + crash, launchd auto-restarted); post-fix all 3 → 400, server stays up, normal learn 200 | $0 |
diff --git a/server.js b/server.js
index e49c785..cb33202 100644
--- a/server.js
+++ b/server.js
@@ -579,6 +579,10 @@ const appHandler = (req, res) => {
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
       const vendor = String(p.vendor || '').trim();
       if (vendor.length > 120) return send(res, 400, { err: 'vendor too long' });   // bound the regex rebuildLexicon compiles
+      // reject prototype keys: VENDOR_PROFILES['__proto__'] returns Object.prototype (truthy),
+      // so prof.prefixes[pre] would deref undefined and CRASH the process (DoS). No real vendor
+      // name is one of these. (proto-pollution guard)
+      if (vendor === '__proto__' || vendor === 'constructor' || vendor === 'prototype') return send(res, 400, { err: 'invalid vendor' });
       const sku = String(p.sku || '').toUpperCase().replace(/\s+/g, '');
       if (!vendor || !sku) return send(res, 400, { err: 'vendor + sku required' });
       const prof = VENDOR_PROFILES[vendor] || (VENDOR_PROFILES[vendor] = {

← bf76d61 auto-save: 2026-06-26T06:43:07 (1 files) — data/build.json  ·  back to Dw Photo Capture  ·  fuzzy: ambiguity guard so a partial read can't bind the wron 830b25f →