← 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
M routes/admin.jsM server.js
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 →