← back to Consulting Designerwallcoverings Com
server.js
181 lines
'use strict';
/**
* Designer Wallcoverings — Consulting portal.
* Generated by the `consulting` skill (fuses fantasea-consulting + prestige-car-wash).
*
* Routes:
* GET / -> signin.html (UNGATED) — client info + credentials
* GET /intake -> intake.html (UNGATED) — new-client questionnaire
* POST /api/intake -> append to data/intakes.json (UNGATED submit)
* GET /api/health -> {ok:true} (UNGATED)
* GET /portal -> portal.html (GATED) — the consulting deliverable
* GET /admin -> admin/index.html (GATED) — integrated-social command center
* GET /api/admin/:bucket -> data/<bucket>.json (GATED read)
* POST /api/admin/:bucket -> overwrite data/<bucket>.json (GATED write)
*/
const fs = require('fs');
const path = require('path');
const express = require('express');
const { execFile } = require('node:child_process');
try { require('dotenv').config(); } catch { /* dotenv optional */ }
const app = express();
// Red-team TK-22 FINDING 4: the /api/intake rate limiter keyed off the raw,
// client-controllable X-Forwarded-For header, so an attacker who rotated XFF on
// each request bypassed the 5/IP/60s cap entirely (reproduced: 7 rotating-XFF
// POSTs all returned 200), defeating FINDING 1's protection. On this host the
// chain is nginx -> Node over loopback (server: nginx, x-powered-by: Express),
// with nginx appending the REAL client via $proxy_add_x_forwarded_for. Trusting
// ONLY the loopback hop makes req.ip resolve the value nginx appended (the true
// $remote_addr, rightmost-untrusted) and ignore any attacker-injected XFF that
// arrived before nginx — defeating the rotate-XFF spoof. Env-overridable
// (TRUST_PROXY) for other topologies; defaults to 'loopback'.
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback');
app.use(express.json({ limit: '1mb' }));
const PORT = process.env.PORT || 9702;
const PUB = path.join(__dirname, 'public');
const DATA = path.join(__dirname, 'data');
// ---- credentials -----------------------------------------------------------
// Unified fleet login + this client's own login. Extra pairs via BASIC_AUTH_EXTRA
// (comma-separated user:pass). These same creds are shown on the sign-in page.
const CREDS = ['admin:DW2024!', 'Steve:Design2026!']
.concat((process.env.BASIC_AUTH_EXTRA || '').split(',').map((s) => s.trim()).filter(Boolean));
const ACCEPTED = new Set(CREDS.map((c) => 'Basic ' + Buffer.from(c).toString('base64')));
function gate(req, res, next) {
if (ACCEPTED.has(req.headers.authorization || '')) return next();
res.set('WWW-Authenticate', 'Basic realm="Designer Wallcoverings Portal"');
return res.status(401).send('Authentication required');
}
// ---- data helpers ----------------------------------------------------------
const readJSON = (f, fb) => { try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); } catch { return fb; } };
// Atomic write with a single-copy .bak snapshot so any bad write is recoverable.
const writeJSON = (f, v) => {
const dest = path.join(DATA, f);
const body = JSON.stringify(v, null, 2);
try { if (fs.existsSync(dest)) fs.copyFileSync(dest, dest + '.bak'); } catch { /* best-effort backup */ }
const tmp = dest + '.tmp';
fs.writeFileSync(tmp, body);
fs.renameSync(tmp, dest); // atomic swap — never leaves a half-written file
};
const BUCKETS = ['client', 'socials', 'suggestions', 'competitors', 'best-times', 'content-calendar', 'ads', 'directories', 'media', 'intakes'];
// ---- abuse controls for the one UNGATED write (/api/intake) -----------------
// Red-team TK-22 FINDING 1: unauthenticated POST /api/intake had no rate-limit,
// no field caps, and no stored-count cap — a single 400KB POST grew the file
// 2KB->402KB, and every POST rewrites the whole (growing) file, giving an
// unbounded disk-fill DoS with quadratic CPU amplification on a public host.
const INTAKE_MAX_STORED = 500; // hard cap on retained records (FIFO drop)
const INTAKE_MAX_JSON = 16 * 1024; // max serialized size of one submitted intake
const INTAKE_RL_WINDOW_MS = 60 * 1000;
const INTAKE_RL_MAX = 5; // max submissions per IP per window
const intakeHits = new Map(); // ip -> [timestamps]
function intakeRateLimited(ip) {
const now = Date.now();
const arr = (intakeHits.get(ip) || []).filter((t) => now - t < INTAKE_RL_WINDOW_MS);
if (arr.length >= INTAKE_RL_MAX) { intakeHits.set(ip, arr); return true; }
arr.push(now);
intakeHits.set(ip, arr);
if (intakeHits.size > 5000) { // bound the map itself
for (const [k, v] of intakeHits) { if (v.every((t) => now - t >= INTAKE_RL_WINDOW_MS)) intakeHits.delete(k); }
}
return false;
}
// ---- ungated routes --------------------------------------------------------
app.get('/', (_req, res) => res.sendFile(path.join(PUB, 'signin.html')));
app.get('/intake', (_req, res) => res.sendFile(path.join(PUB, 'intake.html')));
app.get('/api/health', (_req, res) => res.json({ ok: true, client: 'Designer Wallcoverings', at: new Date().toISOString() }));
app.get('/api/client', (_req, res) => res.json(readJSON('client.json', {}))); // public-safe summary for signin page
app.post('/api/intake', (req, res) => {
// req.ip is Express's trusted client IP (derived from XFF only at the
// configured trust-proxy hops) — not the raw, spoofable header. See FINDING 4.
const ip = (req.ip || req.socket.remoteAddress || 'unknown').toString();
if (intakeRateLimited(ip)) {
return res.status(429).json({ ok: false, error: 'Too many submissions — please try again in a minute.' });
}
const intake = (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) ? req.body : {};
// Reject oversized payloads outright rather than persisting them.
let serialized;
try { serialized = JSON.stringify(intake); } catch { serialized = ''; }
if (!serialized || serialized.length > INTAKE_MAX_JSON) {
return res.status(413).json({ ok: false, error: 'Submission too large.' });
}
const all = readJSON('intakes.json', []);
const list = Array.isArray(all) ? all : [];
list.push({ received_at: new Date().toISOString(), source: 'form', ip, intake });
// FIFO cap so the file (and every rewrite) stays bounded.
const trimmed = list.length > INTAKE_MAX_STORED ? list.slice(list.length - INTAKE_MAX_STORED) : list;
writeJSON('intakes.json', trimmed);
res.json({ ok: true, message: 'Thanks — your consulting intake was received.' });
});
// ---- gated routes ----------------------------------------------------------
app.get('/portal', gate, (_req, res) => res.sendFile(path.join(PUB, 'portal.html')));
app.get('/admin', gate, (_req, res) => res.sendFile(path.join(PUB, 'admin', 'index.html')));
app.get('/api/admin/:bucket', gate, (req, res) => {
if (!BUCKETS.includes(req.params.bucket)) return res.status(404).json({ error: 'unknown bucket' });
res.json(readJSON(req.params.bucket + '.json', []));
});
// Red-team TK-22 FINDING 2: this gated write blind-wrote req.body with no shape
// check, so a single malformed/empty admin POST (e.g. {"wiped":true} or {}) to
// `client` instantly corrupted the PUBLIC /api/client the signin page reads.
// Guard: require a JSON object|array, reject scalars/null, and refuse an empty
// container that would wipe existing non-empty content. writeJSON now also keeps
// a .bak so any accepted-but-wrong write is one-copy recoverable.
const ADMIN_MAX_JSON = 512 * 1024; // 512KB per bucket write
app.post('/api/admin/:bucket', gate, (req, res) => {
if (!BUCKETS.includes(req.params.bucket)) return res.status(404).json({ error: 'unknown bucket' });
const body = req.body;
if (body === null || typeof body !== 'object') {
return res.status(400).json({ error: 'body must be a JSON object or array' });
}
let serialized;
try { serialized = JSON.stringify(body); } catch { serialized = ''; }
if (!serialized) return res.status(400).json({ error: 'body is not serializable' });
if (serialized.length > ADMIN_MAX_JSON) return res.status(413).json({ error: 'payload too large' });
// Refuse an empty write that would silently wipe existing non-empty content.
const incomingEmpty = Array.isArray(body) ? body.length === 0 : Object.keys(body).length === 0;
if (incomingEmpty) {
const existing = readJSON(req.params.bucket + '.json', null);
const existingNonEmpty = existing && (Array.isArray(existing) ? existing.length > 0 : Object.keys(existing).length > 0);
if (existingNonEmpty) {
return res.status(409).json({ error: 'refusing to overwrite existing content with an empty payload' });
}
}
writeJSON(req.params.bucket + '.json', body);
res.json({ ok: true });
});
// DW analysis snapshot (DTD verdict C): gated read of the collected snapshot +
// an on-demand refresh that re-runs the read-only collector then the build.
app.get('/api/analysis', gate, (_req, res) => res.json(readJSON('dw-analysis.json', {})));
let refreshing = false;
app.post('/api/refresh', gate, (_req, res) => {
if (refreshing) return res.status(429).json({ error: 'refresh already running' });
refreshing = true;
execFile('node', [path.join(__dirname, 'scripts', 'collect-dw.mjs')], { timeout: 120000 }, (e1, out1) => {
if (e1) { refreshing = false; return res.status(500).json({ error: 'collect failed', detail: String(e1).slice(0, 200) }); }
execFile('node', [path.join(__dirname, 'build.mjs')], { timeout: 120000 }, (e2) => {
refreshing = false;
if (e2) return res.status(500).json({ error: 'build failed', detail: String(e2).slice(0, 200) });
let collected = {};
try { collected = JSON.parse(out1 || '{}'); } catch { /* stray psql warning polluted stdout */ }
res.json({ ok: true, collected });
});
});
});
// gated static (portal assets + generated version concepts)
app.use('/portal', gate, express.static(path.join(PUB), { extensions: ['html'] }));
app.use('/versions', gate, express.static(path.join(PUB, 'versions'), { extensions: ['html'] }));
app.use('/admin', gate, express.static(path.join(PUB, 'admin'), { extensions: ['html'] }));
// gated work-order deliverable — the sample-only backlog CSVs + SUMMARY.md, read
// from data/work-orders (generated read-only by scripts/build-work-orders.mjs).
app.use('/work-orders', gate, express.static(path.join(DATA, 'work-orders')));
// ungated static (only the two entry pages + shared img)
app.use('/img', express.static(path.join(PUB, 'img')));
app.listen(PORT, () => console.log('Designer Wallcoverings consulting portal on ' + PORT));