[object Object]

← back to Interiordesignershowroom

harden /api/render + admin: persistent restart-proof render cost-guard (cf-connecting-ip + daily cap), scene_image allowlist, admin CSRF origin check

7f7de63c56ab7cd76d5886309bd8e18d8c8f7902 · 2026-08-01 22:07:17 -0700 · Steve Abrams

Files touched

Diff

commit 7f7de63c56ab7cd76d5886309bd8e18d8c8f7902
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 22:07:17 2026 -0700

    harden /api/render + admin: persistent restart-proof render cost-guard (cf-connecting-ip + daily cap), scene_image allowlist, admin CSRF origin check
---
 routes/admin.js | 13 +++++++++++++
 server.js       | 44 ++++++++++++++++++++++++++++++++++++--------
 2 files changed, 49 insertions(+), 8 deletions(-)

diff --git a/routes/admin.js b/routes/admin.js
index dac9a1c..30f7d1d 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -28,6 +28,19 @@ function auth(req, res, next) {
 router.use('/admin', auth);
 router.use('/admin', express.urlencoded({ extended: false }));
 
+// CSRF guard: Basic-auth creds auto-attach cross-site, so a state-changing POST
+// needs an origin check. Reject any admin POST whose Origin/Referer host doesn't
+// match the request host (a same-site form always sends a matching one).
+router.use('/admin', (req, res, next) => {
+  if (req.method !== 'POST') return next();
+  const host = req.get('host');
+  const src = req.get('origin') || req.get('referer') || '';
+  let ok = false;
+  try { ok = !!src && new URL(src).host === host; } catch (_) { ok = false; }
+  if (!ok) return res.status(403).send('Cross-origin request blocked.');
+  next();
+});
+
 // Steve's rule: admin cards show created date AND time, visible, ISO in title=.
 function when(ts) {
   if (!ts) return '';
diff --git a/server.js b/server.js
index 6fa7bb8..f6d1726 100644
--- a/server.js
+++ b/server.js
@@ -1,6 +1,7 @@
 try { require('dotenv').config(); } catch (_) { /* dotenv optional — env may come from pm2/shell */ }
 const express = require('express');
 const path = require('path');
+const fs = require('fs');
 const db = require('./lib/db');
 const { SITE, esc, layout, productCard } = require('./lib/render');
 const catalog = require('./lib/catalog');
@@ -236,6 +237,9 @@ app.get('/api/brands', async (_req, res, next) => {
 app.post('/api/rooms', async (req, res, next) => {
   try {
     const b = req.body || {};
+    // scene_image is rendered raw into <img src> on /room/:slug — only accept our own
+    // generated paths (/img/rooms/<16-hex>.png), never an attacker-supplied URL.
+    if (b.scene_image && !/^\/img\/rooms\/[a-f0-9]{16}\.png$/.test(b.scene_image)) b.scene_image = null;
     let ids = Array.isArray(b.product_ids) ? b.product_ids.map(Number).filter(Boolean).slice(0, 40) : [];
     // "Generate from a vibe alone": back-fill matching pieces, loosening filters
     // until we always land a populated room (room+style -> style -> color -> room -> any).
@@ -273,24 +277,48 @@ const COLORS_12 = [
   { name: 'Ochre', hex: '#caa23e', bucket: 'yellow' }, { name: 'Blush', hex: '#d9a7a9', bucket: 'pink' },
 ];
 
-// Cost guard: cap paid renders (global + per-IP, rolling hour) so a public
-// render button can't run up an unbounded Gemini bill.
-const _renderHits = [];
+// Cost guard: cap paid renders (global + per-IP hourly, plus a hard global
+// daily cap) so a public render button can't run up an unbounded Gemini bill.
+// PERSISTED to disk (data/render-hits.json) so a pm2 restart / deploy / crash
+// does NOT reset the counter to zero — the cap survives restarts, which is the
+// whole point of a spend guard on a paid endpoint.
+const RENDER_HITS_FILE = path.join(__dirname, 'data', 'render-hits.json');
+let _renderHits = [];
+try {
+  _renderHits = JSON.parse(fs.readFileSync(RENDER_HITS_FILE, 'utf8'));
+  if (!Array.isArray(_renderHits)) _renderHits = [];
+} catch (_) { _renderHits = []; } // missing/corrupt file -> start clean
+function _persistRenderHits() {
+  try {
+    fs.mkdirSync(path.dirname(RENDER_HITS_FILE), { recursive: true });
+    fs.writeFileSync(RENDER_HITS_FILE, JSON.stringify(_renderHits));
+  } catch (e) { console.error('[render] could not persist hits:', e.message); }
+}
 function renderAllowed(ip) {
-  const now = Date.now(), hourAgo = now - 3600e3;
-  while (_renderHits.length && _renderHits[0].t < hourAgo) _renderHits.shift();
-  const globalN = _renderHits.length, perIp = _renderHits.filter(h => h.ip === ip).length;
+  const now = Date.now(), hourAgo = now - 3600e3, dayAgo = now - 86400e3;
+  // prune anything older than the widest (daily) window
+  _renderHits = _renderHits.filter(h => h.t >= dayAgo);
+  const dayN = _renderHits.length;
+  const hourHits = _renderHits.filter(h => h.t >= hourAgo);
+  const globalN = hourHits.length;
+  const perIp = hourHits.filter(h => h.ip === ip).length;
   const GLOBAL_CAP = parseInt(process.env.RENDER_HOURLY_CAP || '60', 10);
   const IP_CAP = parseInt(process.env.RENDER_IP_CAP || '8', 10);
-  if (globalN >= GLOBAL_CAP || perIp >= IP_CAP) return false;
+  const DAILY_CAP = parseInt(process.env.RENDER_DAILY_CAP || '200', 10);
+  if (dayN >= DAILY_CAP || globalN >= GLOBAL_CAP || perIp >= IP_CAP) return false;
   _renderHits.push({ t: now, ip });
+  _persistRenderHits();
   return true;
 }
 
 // Photoreal AI render of a room scene (paid — Gemini image, ~$0.039/image).
 app.post('/api/render', async (req, res) => {
   try {
-    const ip = (req.headers['x-forwarded-for'] || req.ip || '').split(',')[0].trim();
+    // Behind Cloudflare, cf-connecting-ip is the real (un-spoofable-by-header) client;
+    // x-forwarded-for is a client-settable list, so only trust it as a fallback.
+    const ip = (req.headers['cf-connecting-ip']
+      || (req.headers['x-forwarded-for'] || '').split(',')[0]
+      || req.ip || '').trim();
     if (!renderAllowed(ip)) return res.status(429).json({ error: 'Render limit reached — try again in a bit.' });
     const b = req.body || {};
     const ids = Array.isArray(b.product_ids) ? b.product_ids.map(Number).filter(Boolean).slice(0, 4) : [];

← 8d27aa6 Frontend refinement: consistency + edge/empty states + mobil  ·  back to Interiordesignershowroom  ·  refine: price-freshness trust chip on cards (price_checked_a 1ead1eb →