← 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 →