[object Object]

← 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

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 →