← back to Small Business Builder
[morning-review] small-business-builder: gate interview admin + whitelist QA keys + UI/render hardening
41c8a33d7dc8348d433752dd4e40e48a062f7943 · 2026-05-04 13:01:26 -0700 · SteveStudio2
Critical:
- Gate GET /biz/:slug/interview and /interview/live behind ADMIN_TOKEN
(POST already gated); adds shared requireAdmin() helper.
- Whitelist incoming qa keys against QUESTIONS ids before persisting,
so attackers can't balloon JSONB or inject collision keys.
High:
- Cast $2::jsonb on interview INSERT to avoid pg-driver double-encode.
- profile.js: white-space:pre-wrap on .qa .a so multi-paragraph answers
render with linebreaks intact.
- profile.js: form maxlength now uses q.max_chars (was *2), aligning
the soft limit with what the server keeps.
Medium:
- profile.js: format recorded_at in UTC so "Recorded January 2026"
doesn't flicker by host TZ.
- profile.js: new safeHref() validates http(s) scheme on b.website
before rendering as href or schema.org url (blocks javascript: XSS).
- profile.js: coerce qa_json from string when legacy rows stored JSON
text rather than a jsonb object.
- server/index.js: /n/:slug uses slugify() rather than an inline regex,
matching the slugs emitted in links.
- interview-questions.js: localizeQuestion substitutes [neighborhood]
across every string field, not just q + placeholder.
Files touched
M src/lib/interview-questions.jsM src/render/profile.jsM src/server/index.js
Diff
commit 41c8a33d7dc8348d433752dd4e40e48a062f7943
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 4 13:01:26 2026 -0700
[morning-review] small-business-builder: gate interview admin + whitelist QA keys + UI/render hardening
Critical:
- Gate GET /biz/:slug/interview and /interview/live behind ADMIN_TOKEN
(POST already gated); adds shared requireAdmin() helper.
- Whitelist incoming qa keys against QUESTIONS ids before persisting,
so attackers can't balloon JSONB or inject collision keys.
High:
- Cast $2::jsonb on interview INSERT to avoid pg-driver double-encode.
- profile.js: white-space:pre-wrap on .qa .a so multi-paragraph answers
render with linebreaks intact.
- profile.js: form maxlength now uses q.max_chars (was *2), aligning
the soft limit with what the server keeps.
Medium:
- profile.js: format recorded_at in UTC so "Recorded January 2026"
doesn't flicker by host TZ.
- profile.js: new safeHref() validates http(s) scheme on b.website
before rendering as href or schema.org url (blocks javascript: XSS).
- profile.js: coerce qa_json from string when legacy rows stored JSON
text rather than a jsonb object.
- server/index.js: /n/:slug uses slugify() rather than an inline regex,
matching the slugs emitted in links.
- interview-questions.js: localizeQuestion substitutes [neighborhood]
across every string field, not just q + placeholder.
---
src/lib/interview-questions.js | 14 ++++++++------
src/render/profile.js | 35 +++++++++++++++++++++++++++++------
src/server/index.js | 35 ++++++++++++++++++++++++++++++++---
3 files changed, 69 insertions(+), 15 deletions(-)
diff --git a/src/lib/interview-questions.js b/src/lib/interview-questions.js
index 3c679ae..51bbeb0 100644
--- a/src/lib/interview-questions.js
+++ b/src/lib/interview-questions.js
@@ -188,12 +188,14 @@ export function questionsBySection() {
.map(([key, meta]) => ({ key, ...meta, questions: out[key] || [] }));
}
-// Apply per-shop substitutions to question text (e.g., "[neighborhood]" → "Silver Lake").
+// Apply per-shop substitutions to every string field on a question
+// (e.g., "[neighborhood]" → "Silver Lake"). Substituting across the whole
+// object — not just `q` and `placeholder` — keeps fields like `seo_intent`
+// from silently leaking literal "[neighborhood]" tokens if surfaced later.
export function localizeQuestion(q, business) {
const hood = business?.neighborhood || 'the neighborhood';
- return {
- ...q,
- q: q.q.replace(/\[neighborhood\]/g, hood),
- placeholder: q.placeholder.replace(/\[neighborhood\]/g, hood),
- };
+ const sub = (s) => (typeof s === 'string' ? s.replace(/\[neighborhood\]/g, hood) : s);
+ const out = { ...q };
+ for (const k of Object.keys(out)) out[k] = sub(out[k]);
+ return out;
}
diff --git a/src/render/profile.js b/src/render/profile.js
index c7852e3..e4f9773 100644
--- a/src/render/profile.js
+++ b/src/render/profile.js
@@ -46,6 +46,17 @@ function safeHostname(raw) {
}
}
+// Return a safe http(s) URL for use in href, or '' if scheme is not allowed.
+// Without this, a stored `javascript:` URL would render as a clickable XSS.
+function safeHref(raw) {
+ if (!raw) return '';
+ const s = String(raw).trim();
+ if (!s) return '';
+ // No scheme → assume https so bare-domain entries still link.
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(s)) return `https://${s}`;
+ return /^https?:\/\//i.test(s) ? s : '';
+}
+
// Build the FAQ schema.org JSON-LD blob from filled answers.
function faqJsonLd(filled) {
const mainEntity = filled.map(({ q, a }) => ({
@@ -74,7 +85,7 @@ function localBusinessJsonLd(b) {
postalCode: b.zip || undefined,
} : undefined,
telephone: b.phone || undefined,
- url: b.website || undefined,
+ url: safeHref(b.website) || undefined,
image: b.hero_image_url || undefined,
description: b.about_text || undefined,
};
@@ -88,7 +99,13 @@ export function renderProfile({ business, interview, otherShopsInHood = [] } = {
const accent = NEIGHBORHOOD_ACCENT[b.neighborhood] || '#1f6f70';
const cat = CATEGORY_LABEL[b.category] || 'Salon';
const hoodSlug = neighborhoodSlug(b.neighborhood);
- const qa = interview?.qa_json || {};
+ // qa_json may legitimately come back as either a JS object (jsonb) or a JSON
+ // string (legacy rows). Coerce so qa[q.id] is always a string answer.
+ let qa = interview?.qa_json || {};
+ if (typeof qa === 'string') {
+ try { qa = JSON.parse(qa); } catch { qa = {}; }
+ }
+ if (!qa || typeof qa !== 'object' || Array.isArray(qa)) qa = {};
const sections = questionsBySection();
// Pull the "philosophy" answer up as the editorial pull-quote.
@@ -164,7 +181,7 @@ img{display:block;max-width:100%;height:auto}
.qa:last-child{border-bottom:none}
.qa .q{font-family:var(--serif);font-weight:500;font-size:21px;line-height:1.25;letter-spacing:-.005em;margin-bottom:14px;color:var(--ink)}
.qa .q::before{content:"Q.";font-family:var(--mono);font-size:11px;letter-spacing:.16em;color:var(--accent);margin-right:10px;vertical-align:1px}
-.qa .a{font-size:17px;line-height:1.6;color:#2a2a2a;padding-left:32px;border-left:2px solid var(--accent)}
+.qa .a{font-size:17px;line-height:1.6;color:#2a2a2a;padding-left:32px;border-left:2px solid var(--accent);white-space:pre-wrap}
.qa .a::before{content:"";display:none}
.qa-empty{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute);font-style:italic;padding-left:32px}
@@ -218,7 +235,11 @@ img{display:block;max-width:100%;height:auto}
<div class="hero-stats">
${Number.isFinite(Number(b.years_in_business)) && Number(b.years_in_business) > 0 ? `<div>Established<b>${esc(String(Number(b.years_in_business)))} yrs</b></div>` : ''}
${b.address ? `<div>Address<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0">${esc(b.address)}</b></div>` : ''}
- ${b.website ? `<div>Online<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0"><a href="${esc(b.website)}" target="_blank" rel="noopener">${esc(safeHostname(b.website) || b.website)}</a></b></div>` : ''}
+ ${(() => {
+ const href = safeHref(b.website);
+ if (!href) return '';
+ return `<div>Online<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0"><a href="${esc(href)}" target="_blank" rel="noopener">${esc(safeHostname(href) || href)}</a></b></div>`;
+ })()}
</div>
</div>
<div class="hero-img">
@@ -266,7 +287,9 @@ ${filled.length === 0 ? `
${(() => {
if (!interview?.recorded_at) return interview?.recorded_by ? `Recorded · by ${esc(interview.recorded_by)}` : '';
const d = new Date(interview.recorded_at);
- const dateStr = isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-US',{month:'long',year:'numeric'});
+ // Format in UTC so server TZ doesn't make "Recorded January 2026" flicker
+ // by region.
+ const dateStr = isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-US',{month:'long',year:'numeric',timeZone:'UTC'});
return `Recorded ${dateStr}${interview?.recorded_by ? ` · by ${esc(interview.recorded_by)}` : ''}`;
})()}
</div>
@@ -381,7 +404,7 @@ h1{font-family:var(--serif);font-weight:500;font-size:48px;letter-spacing:-.02em
return `
<div class="qa">
<label for="q_${esc(q.id)}">${esc(lq.q)}</label>
- <textarea id="q_${esc(q.id)}" name="qa_${esc(q.id)}" maxlength="${maxChars * 2}" placeholder="${esc(lq.placeholder)}">${esc(v)}</textarea>
+ <textarea id="q_${esc(q.id)}" name="qa_${esc(q.id)}" maxlength="${maxChars}" placeholder="${esc(lq.placeholder)}">${esc(v)}</textarea>
<div class="hint">~${maxChars} chars · ${esc(q.seo_intent || '')}</div>
</div>`;
}).join('')}
diff --git a/src/server/index.js b/src/server/index.js
index 688039f..f4eb035 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -30,6 +30,9 @@ import { renderEdit } from '../render/edit.js';
import { renderHome } from '../render/home.js';
import { renderProfile, renderInterviewForm } from '../render/profile.js';
import { renderInterviewLive } from '../render/interview-live.js';
+import { QUESTIONS } from '../lib/interview-questions.js';
+
+const QUESTION_IDS = new Set(QUESTIONS.map(q => q.id));
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -78,6 +81,20 @@ async function loadBySlug(slug) {
return one('SELECT * FROM businesses WHERE slug=$1', [slug]);
}
+// Gate any handler behind the same ADMIN_TOKEN cookie (or ?t=) used by /admin.
+// Used to block public visitors from reaching the interview admin UI.
+function requireAdmin(req, res) {
+ const tok = req.cookies?.smb_admin || req.query?.t || req.body?.admin_token || '';
+ if (!ADMIN_TOKEN || tok !== ADMIN_TOKEN) {
+ res.status(401).set('content-type', 'text/html; charset=utf-8')
+ .send(`<form style="font-family:system-ui;padding:48px;max-width:420px;margin:0 auto"><h2 style="margin-bottom:12px">Admin only</h2><p style="color:#666;margin-bottom:18px">Interview editor is gated. Enter admin token.</p><input name="t" placeholder="admin token" style="width:100%;padding:10px;margin-bottom:10px"><button style="padding:10px 18px">Enter</button></form>`);
+ return false;
+ }
+ // Refresh cookie so subsequent requests don't need ?t=.
+ res.cookie('smb_admin', tok, { httpOnly: true, sameSite: 'lax' });
+ return true;
+}
+
// --- routes -----------------------------------------------------------------
app.get('/healthz', async (_req, res) => {
@@ -145,9 +162,11 @@ app.get('/n/:slug', async (req, res) => {
const hoodRows = await many(
`SELECT DISTINCT neighborhood FROM businesses WHERE neighborhood IS NOT NULL`
);
+ // Use the same slugify() that emits links elsewhere so the resolution rule is
+ // single-sourced (diacritics/'/punctuation handling stays in lockstep).
const matchedHoods = hoodRows
.map(r => r.neighborhood)
- .filter(n => n && n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') === slug);
+ .filter(n => n && slugify(n) === slug);
if (!matchedHoods.length) return res.status(404).send('Neighborhood not found');
if (matchedHoods.length > 1) {
log.err('neighborhood slug collision', { slug, matched: matchedHoods });
@@ -201,7 +220,10 @@ app.get('/biz/:slug', async (req, res) => {
});
// Interview admin form — fill in the Q&A.
+// Gated behind ADMIN_TOKEN — paired with the POST gate so anonymous visitors
+// can't even reach the editing UI.
app.get('/biz/:slug/interview', async (req, res) => {
+ if (!requireAdmin(req, res)) return;
const b = await loadBySlug(req.params.slug);
if (!b) return res.status(404).send('Not found');
const interview = await one(
@@ -213,7 +235,9 @@ app.get('/biz/:slug/interview', async (req, res) => {
});
// Live AI-avatar interview — webcam + speech. Browser-native, no server-side LLM.
+// Same admin gate as the form route.
app.get('/biz/:slug/interview/live', async (req, res) => {
+ if (!requireAdmin(req, res)) return;
const b = await loadBySlug(req.params.slug);
if (!b) return res.status(404).send('Not found');
const interview = await one(
@@ -289,16 +313,21 @@ app.post('/api/biz/interview', async (req, res) => {
// Strip empty answers so the JSONB stays clean. Coerce arrays-as-values to a
// single string so a malicious `qa_<id>=a&qa_<id>=b` repeat-key payload
- // can't sneak in non-string types.
+ // can't sneak in non-string types. Whitelist against the canonical
+ // QUESTION_IDS so an attacker can't balloon the JSONB with arbitrary keys
+ // (or sneak in keys that later collide with other fields).
const cleanQa = {};
for (const [k, v] of Object.entries(qa)) {
+ if (!QUESTION_IDS.has(k)) continue;
const raw = Array.isArray(v) ? v[v.length - 1] : v;
const s = String(raw || '').trim();
if (s) cleanQa[k] = s.slice(0, 4000);
}
+ // Cast to ::jsonb explicitly so any pg driver version handles the string
+ // identically — without the cast some versions double-encode.
await query(
- `INSERT INTO business_interviews (business_id, qa_json, recorded_by) VALUES ($1, $2, $3)`,
+ `INSERT INTO business_interviews (business_id, qa_json, recorded_by) VALUES ($1, $2::jsonb, $3)`,
[b.id, JSON.stringify(cleanQa), (recorded_by || '').slice(0, 120) || null]
);
← b789cdc [morning-review] small-business-builder: harden public profi
·
back to Small Business Builder
·
deploy: ship smb-builder to Kamatera + capture full schema i c6fa785 →