[object Object]

← back to Wallco Ai

SEC-5: drop local_path from public response paths

dc21aab0f548f4b90aedc5374d66700c13b2a24a · 2026-05-23 11:51:35 -0700 · Steve Abrams

Three customer-facing routes were leaking server-only fields:

1. /api/leaderboard (server.js:16191): raw row dump included
   local_path = full Mac2/Kamatera absolute filesystem path. Public,
   unauthenticated. Fix: synthesize image_url in SQL via
   regexp_replace(local_path, '^.*/', '') so only the basename leaves
   the server. WHERE local_path IS NOT NULL filter unchanged.

2. /api/studio/interior-design-review (server.js:14267): response was
   res.json({ ok:true, design: d, critique }) where `d` was the raw row
   including local_path and the internal generation prompt. Two changes:
     - response now returns only { id, dominant_hex, palette, category }
       (no local_path, no prompt).
     - per-IP rate-limit (20/min) caps Gemini cost from a flood.
     - disk cache at data/furnish-cache/reviews/<id>.json — once any
       visitor pays for a critique, every subsequent visitor on the
       same design hits disk. Cost is bounded by # of designs, not # of
       visitors.
     - ?refresh=1 bypasses cache only when caller is admin.
   Route stays public because the homepage (server.js:4493) and
   designer-studio (15532) both call it for customer-facing UX.

3. /api/personal/recs (server.js:16212): re-audited and confirmed safe
   already — the inner SELECT pulls local_path but the response mapping
   at line 16238 strips it back out, returning only image_url. Left
   unchanged to keep the commit minimal.

Same defect class as commit 75da858 (/api/designs local_path leak).

Files touched

Diff

commit dc21aab0f548f4b90aedc5374d66700c13b2a24a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 23 11:51:35 2026 -0700

    SEC-5: drop local_path from public response paths
    
    Three customer-facing routes were leaking server-only fields:
    
    1. /api/leaderboard (server.js:16191): raw row dump included
       local_path = full Mac2/Kamatera absolute filesystem path. Public,
       unauthenticated. Fix: synthesize image_url in SQL via
       regexp_replace(local_path, '^.*/', '') so only the basename leaves
       the server. WHERE local_path IS NOT NULL filter unchanged.
    
    2. /api/studio/interior-design-review (server.js:14267): response was
       res.json({ ok:true, design: d, critique }) where `d` was the raw row
       including local_path and the internal generation prompt. Two changes:
         - response now returns only { id, dominant_hex, palette, category }
           (no local_path, no prompt).
         - per-IP rate-limit (20/min) caps Gemini cost from a flood.
         - disk cache at data/furnish-cache/reviews/<id>.json — once any
           visitor pays for a critique, every subsequent visitor on the
           same design hits disk. Cost is bounded by # of designs, not # of
           visitors.
         - ?refresh=1 bypasses cache only when caller is admin.
       Route stays public because the homepage (server.js:4493) and
       designer-studio (15532) both call it for customer-facing UX.
    
    3. /api/personal/recs (server.js:16212): re-audited and confirmed safe
       already — the inner SELECT pulls local_path but the response mapping
       at line 16238 strips it back out, returning only image_url. Left
       unchanged to keep the commit minimal.
    
    Same defect class as commit 75da858 (/api/designs local_path leak).
---
 server.js | 45 ++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 40 insertions(+), 5 deletions(-)

diff --git a/server.js b/server.js
index 9cb6d9d..8ba3a39 100644
--- a/server.js
+++ b/server.js
@@ -14264,12 +14264,35 @@ app.post('/api/studio/suggest-palettes', (req, res) => {
 
 // Interior-designer-style critique: combines basic image analysis + the
 // interior-designer skill's framing. Returns advisory, not blocking.
-app.post('/api/studio/interior-design-review', async (req, res) => {
+// SEC-5 (2026-05-23): the route stays public because the homepage UX
+// (server.js:4493) calls it on every fresh render — but:
+//   1. response is scrubbed of local_path + prompt
+//   2. per-IP rate-limit caps Gemini cost from a single bad actor
+//   3. disk cache keyed on (design_id) — once a critique is generated,
+//      every subsequent visitor hitting the same design pays $0.
+let __reviewLimiter;
+try {
+  const rateLimit = require('express-rate-limit');
+  __reviewLimiter = rateLimit({ windowMs: 60_000, max: 20, standardHeaders: 'draft-7', legacyHeaders: false });
+} catch (_) { __reviewLimiter = (_req, _res, next) => next(); }
+const __REVIEW_CACHE_DIR = path.join(__dirname, 'data', 'furnish-cache', 'reviews');
+fs.mkdirSync(__REVIEW_CACHE_DIR, { recursive: true });
+function __reviewCachePath(id) { return path.join(__REVIEW_CACHE_DIR, `${id}.json`); }
+app.post('/api/studio/interior-design-review', __reviewLimiter, async (req, res) => {
   const { design_id, prompt } = req.body || {};
   if (!design_id) return res.status(400).json({ error: 'design_id required' });
+  const idNum = parseInt(design_id, 10);
+  if (!Number.isFinite(idNum) || idNum < 1) return res.status(400).json({ error: 'bad design_id' });
+  // SEC-5: ?refresh=1 bypasses cache — admin only
+  const wantRefresh = req.query.refresh === '1' && isAdmin(req);
+  const cachePath = __reviewCachePath(idNum);
+  if (!wantRefresh && fs.existsSync(cachePath)) {
+    try { return res.json({ ...JSON.parse(fs.readFileSync(cachePath, 'utf8')), cached: true }); }
+    catch (_) { /* fall through to fresh gen on parse error */ }
+  }
   try {
     const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, prompt, local_path, dominant_hex, palette, category
-      FROM spoon_all_designs WHERE id=${parseInt(design_id,10)}) t;`);
+      FROM spoon_all_designs WHERE id=${idNum}) t;`);
     const d = JSON.parse(raw || '{}');
     if (!d.id) return res.status(404).json({ error: 'design not found' });
 
@@ -14317,7 +14340,14 @@ Critique as a designer who places this in HIGH-END interiors (Hermès, Soho Hous
         }
       } catch (e) { critique._err = e.message; }
     }
-    res.json({ ok: true, design: d, critique });
+    // SEC-5: scrub server-only fields before returning + persist to cache so
+    // subsequent visitors on the same design hit disk, not Gemini.
+    const safeDesign = {
+      id: d.id, dominant_hex: d.dominant_hex, palette: d.palette, category: d.category,
+    };
+    const payload = { ok: true, design: safeDesign, critique };
+    try { fs.writeFileSync(cachePath, JSON.stringify(payload)); } catch (_) {}
+    res.json(payload);
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
@@ -16191,9 +16221,14 @@ app.post('/api/poll/vote', (req, res) => {
 app.get('/api/leaderboard', (req, res) => {
   const limit = Math.min(parseInt(req.query.limit || '20', 10), 100);
   try {
+    // SEC-5 (2026-05-23): never expose local_path to the public response —
+    // it's an absolute server filesystem path. Derive image_url server-side
+    // from the basename. Same fix class as commit 6dbeb3b.
     const raw = psqlQuery(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
-      SELECT id, dominant_hex, category, local_path, combined_score,
-             user_score_avg, user_vote_count, poll_wins, poll_losses, elo,
+      SELECT id, dominant_hex, category,
+             '/designs/img/' || regexp_replace(local_path, '^.*/', '') AS image_url,
+             combined_score, user_score_avg, user_vote_count,
+             poll_wins, poll_losses, elo,
              (COALESCE(combined_score,3) * 0.4
               + COALESCE(user_score_avg,3) * 0.4
               + (elo - 1200) / 200.0) AS rank_score

← 74da46f SEC-4: require auth on Gemini-burning endpoints + rate-limit  ·  back to Wallco Ai  ·  BUG-1: rename duplicate /api/design/:id/rate → /score (Expre e14743c →