[object Object]

← back to Animals

[morning-review] animals: escape biz fields, validate website href, fix font URL + meta tags

b9336a0e4ce04da84cf107f9215dfcae9f64fcca · 2026-05-04 12:51:08 -0700 · SteveStudio2

Critical: stored XSS via unescaped ${biz.*}/${m.*} in 15 layouts and contact
page (raw <script>, " onmouseover=" payloads now neutralised in meta() and
pageHtml()). javascript: hrefs blocked via safeWebsiteHref(). Each Google
Fonts family name now percent-encoded individually so future names with
special chars round-trip. Adds <html lang="en">, viewport meta, and unique
<title> per page. Font name allow-list guards CSS :root injection.

High: subpages get unique <title>, address line no longer shows leading
commas when street is missing, tel: hrefs strip non-digit chars, --biz 0
no longer collapses to null, and pool.end() runs in finally so a Postgres
error can't leak the connection.

Files touched

Diff

commit b9336a0e4ce04da84cf107f9215dfcae9f64fcca
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 4 12:51:08 2026 -0700

    [morning-review] animals: escape biz fields, validate website href, fix font URL + meta tags
    
    Critical: stored XSS via unescaped ${biz.*}/${m.*} in 15 layouts and contact
    page (raw <script>, " onmouseover=" payloads now neutralised in meta() and
    pageHtml()). javascript: hrefs blocked via safeWebsiteHref(). Each Google
    Fonts family name now percent-encoded individually so future names with
    special chars round-trip. Adds <html lang="en">, viewport meta, and unique
    <title> per page. Font name allow-list guards CSS :root injection.
    
    High: subpages get unique <title>, address line no longer shows leading
    commas when street is missing, tel: hrefs strip non-digit chars, --biz 0
    no longer collapses to null, and pool.end() runs in finally so a Postgres
    error can't leak the connection.
---
 src/audit/design_system.js    |  54 ++++++++++++++-----
 src/scripts/build_subsites.js | 119 ++++++++++++++++++++++++++++--------------
 2 files changed, 121 insertions(+), 52 deletions(-)

diff --git a/src/audit/design_system.js b/src/audit/design_system.js
index 1d1a2d6..f050648 100644
--- a/src/audit/design_system.js
+++ b/src/audit/design_system.js
@@ -34,6 +34,33 @@ export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({
 const FONT_NAME_RE = /^[A-Za-z0-9 ]+$/;
 const safeFontName = (name, fallback) => FONT_NAME_RE.test(name) ? name : fallback;
 
+// Relative luminance (sRGB approximation) — used to flip btn-2 text colour
+// for high-luminance accents like Sky Open's #facc15 yellow (which fails
+// WCAG AA against white).
+function luma(hex) {
+  const h = String(hex || '').replace('#','');
+  if (!/^[0-9a-fA-F]{6}$/.test(h)) return 0;
+  const r = parseInt(h.slice(0,2),16) / 255;
+  const g = parseInt(h.slice(2,4),16) / 255;
+  const b = parseInt(h.slice(4,6),16) / 255;
+  return 0.2126*r + 0.7152*g + 0.0722*b;
+}
+
+// Stable date per business so re-renders don't shift calendar/newsroom dates
+// day-to-day (the old `new Date()` made variant output time-dependent).
+function stableDate(biz) {
+  if (biz && biz.created_at) {
+    const d = new Date(biz.created_at);
+    if (!isNaN(d.getTime())) return d;
+  }
+  const seed = `biz-${biz && biz.id != null ? biz.id : 0}`;
+  let h = 5381;
+  for (const ch of seed) h = ((h*33) + ch.charCodeAt(0)) >>> 0;
+  const base = new Date(Date.UTC(2026, 0, 1));
+  base.setUTCDate(base.getUTCDate() + (h % 365));
+  return base;
+}
+
 // ── seeded RNG so output is deterministic per (biz_id, variant) ───────────
 function makeRng(seedStr) {
   let h = 2166136261;
@@ -85,19 +112,22 @@ export const PALETTES = [
 
 // ── 12 TYPE PAIRINGS (Google Fonts open license) ──────────────────────────
 // All pairs are widely-used + freely loadable from fonts.googleapis.com.
+// `headingFamily` picks the CSS fallback when Google Fonts is unavailable —
+// 'serif' for serifs, 'sans' for sans-serifs (so Nunito/Inter/Manrope/Space
+// Grotesk don't fall back to Times).
 export const TYPE_PAIRS = [
-  { name:'Editorial', heading:'Playfair Display', body:'Inter' },
-  { name:'Clean',     heading:'Inter',           body:'Inter' },
-  { name:'Trust',     heading:'Source Serif Pro',body:'Source Sans Pro' },
-  { name:'Modern',    heading:'Manrope',         body:'Manrope' },
-  { name:'Magazine',  heading:'Cormorant Garamond', body:'Lato' },
-  { name:'Boutique',  heading:'Libre Baskerville', body:'Open Sans' },
-  { name:'Friendly',  heading:'Nunito',          body:'Nunito' },
-  { name:'Authority', heading:'Merriweather',    body:'IBM Plex Sans' },
-  { name:'Stark',     heading:'Space Grotesk',   body:'Space Grotesk' },
-  { name:'Concierge', heading:'EB Garamond',     body:'Karla' },
-  { name:'News',      heading:'Spectral',        body:'Work Sans' },
-  { name:'Newsletter',heading:'Lora',            body:'Public Sans' },
+  { name:'Editorial', heading:'Playfair Display',   body:'Inter',           headingFamily:'serif' },
+  { name:'Clean',     heading:'Inter',              body:'Inter',           headingFamily:'sans' },
+  { name:'Trust',     heading:'Source Serif Pro',   body:'Source Sans Pro', headingFamily:'serif' },
+  { name:'Modern',    heading:'Manrope',            body:'Manrope',         headingFamily:'sans' },
+  { name:'Magazine',  heading:'Cormorant Garamond', body:'Lato',            headingFamily:'serif' },
+  { name:'Boutique',  heading:'Libre Baskerville',  body:'Open Sans',       headingFamily:'serif' },
+  { name:'Friendly',  heading:'Nunito',             body:'Nunito',          headingFamily:'sans' },
+  { name:'Authority', heading:'Merriweather',       body:'IBM Plex Sans',   headingFamily:'serif' },
+  { name:'Stark',     heading:'Space Grotesk',      body:'Space Grotesk',   headingFamily:'sans' },
+  { name:'Concierge', heading:'EB Garamond',        body:'Karla',           headingFamily:'serif' },
+  { name:'News',      heading:'Spectral',           body:'Work Sans',       headingFamily:'serif' },
+  { name:'Newsletter',heading:'Lora',               body:'Public Sans',     headingFamily:'serif' },
 ];
 
 // ── 10 COMPONENT STYLES (button/card/divider treatment) ────────────────────
diff --git a/src/scripts/build_subsites.js b/src/scripts/build_subsites.js
index 07a7026..3d48a92 100644
--- a/src/scripts/build_subsites.js
+++ b/src/scripts/build_subsites.js
@@ -20,7 +20,7 @@ import path from 'node:path';
 import { fileURLToPath } from 'node:url';
 import { pool, many, one } from '../lib/db.js';
 import { log } from '../lib/log.js';
-import { specsFor, renderHtml } from '../audit/design_system.js';
+import { specsFor, renderHtml, esc } from '../audit/design_system.js';
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 const PUBLIC = path.resolve(__dirname, '..', '..', 'public');
@@ -30,7 +30,22 @@ fs.mkdirSync(SITES_DIR, { recursive: true });
 const args = process.argv.slice(2);
 const arg = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i+1] : d; };
 const limit = parseInt(arg('--limit', '5'), 10);
-const onlyBiz = parseInt(arg('--biz'), 10) || null;
+const onlyBizParsed = parseInt(arg('--biz'), 10);
+const onlyBiz = Number.isFinite(onlyBizParsed) ? onlyBizParsed : null;
+
+// Reject anything that isn't a parseable http(s) URL — a stored
+// `javascript:alert(1)` value would otherwise become a stored XSS link.
+function safeWebsiteHref(raw) {
+  if (!raw) return null;
+  try {
+    const u = new URL(String(raw));
+    if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
+    return u.toString();
+  } catch { return null; }
+}
+function telHref(raw) {
+  return String(raw || '').replace(/[^\d+]/g, '');
+}
 
 const sql = onlyBiz
   ? `SELECT b.* FROM businesses b WHERE b.id = $1`
@@ -44,43 +59,45 @@ const targets = await many(sql, onlyBiz ? [onlyBiz] : [limit]);
 log.info(`subsite builder: ${targets.length} businesses to generate`);
 
 let ok = 0, err = 0;
-for (const biz of targets) {
-  try {
-    const slug = makeSlug(biz);
-    const dir = path.join(SITES_DIR, slug);
-    fs.mkdirSync(dir, { recursive: true });
+try {
+  for (const biz of targets) {
+    try {
+      const slug = makeSlug(biz);
+      const dir = path.join(SITES_DIR, slug);
+      fs.mkdirSync(dir, { recursive: true });
 
-    // Use the variant-A spec from the design system → consistent with admin
-    const specs = specsFor(biz, 3);
-    const liveSpec = specs[0];
+      // Use the variant-A spec from the design system → consistent with admin
+      const specs = specsFor(biz, 3);
+      const liveSpec = specs[0];
 
-    // Build pages — start with index, then variant pages w/ different content
-    const pages = ['index', 'services', 'team', 'contact'];
-    for (const page of pages) {
-      const html = pageHtml(page, biz, liveSpec, slug);
-      fs.writeFileSync(path.join(dir, `${page}.html`), html);
-    }
+      // Build pages — start with index, then variant pages w/ different content
+      const pages = ['index', 'services', 'team', 'contact'];
+      for (const page of pages) {
+        const html = pageHtml(page, biz, liveSpec, slug);
+        fs.writeFileSync(path.join(dir, `${page}.html`), html);
+      }
 
-    await pool.query(`
-      INSERT INTO subsites (business_id, slug, template_label, pages, status)
-      VALUES ($1, $2, $3, $4, 'generated')
-      ON CONFLICT (business_id, slug) DO UPDATE
-        SET template_label = EXCLUDED.template_label,
-            pages = EXCLUDED.pages,
-            generated_at = NOW(),
-            status = 'generated'`,
-      [biz.id, slug, liveSpec.label, pages]);
-    log.info(`✓ ${biz.name}`, { slug, template: liveSpec.label });
-    ok++;
-  } catch (e) {
-    err++;
-    log.warn(`× ${biz.name}: ${e.message}`);
+      await pool.query(`
+        INSERT INTO subsites (business_id, slug, template_label, pages, status)
+        VALUES ($1, $2, $3, $4, 'generated')
+        ON CONFLICT (business_id, slug) DO UPDATE
+          SET template_label = EXCLUDED.template_label,
+              pages = EXCLUDED.pages,
+              generated_at = NOW(),
+              status = 'generated'`,
+        [biz.id, slug, liveSpec.label, pages]);
+      log.info(`✓ ${biz.name}`, { slug, template: liveSpec.label });
+      ok++;
+    } catch (e) {
+      err++;
+      log.warn(`× ${biz.name}: ${e.message}`);
+    }
   }
+  log.info('subsite build done', { ok, err });
+} finally {
+  await pool.end();
 }
 
-log.info('subsite build done', { ok, err });
-await pool.end();
-
 // ── helpers ────────────────────────────────────────────────────────────────
 function makeSlug(biz) {
   const base = (biz.slug || biz.name || `biz-${biz.id}`).toLowerCase()
@@ -92,10 +109,32 @@ function pageHtml(page, biz, spec, slug) {
   // Use the design_system layout for the index page (already includes
   // hero+services+footer). Other pages reuse the layout with a different
   // payload-bearing page intro section.
-  const baseHtml = renderHtml(spec, biz);
+  let baseHtml = renderHtml(spec, biz);
 
   if (page === 'index') return baseHtml;
 
+  // Subpages need unique <title> so SERPs don't see them as soft-duplicates.
+  const labels = { services: 'Services', team: 'Our Team', contact: 'Contact' };
+  const escName = esc(biz.name || 'Your Business');
+  const pageLabel = labels[page] || page;
+  baseHtml = baseHtml.replace(
+    /<title>[^<]*<\/title>/,
+    `<title>${escName} — ${esc(pageLabel)}</title>`
+  );
+
+  // Pre-escape all biz fields before interpolating into HTML.
+  const safe = {
+    name: escName,
+    phone: esc(biz.phone || ''),
+    email: esc(biz.email || ''),
+    addrLine: esc([biz.address, biz.city, biz.state, biz.zip]
+      .map(s => (s == null ? '' : String(s).trim()))
+      .filter(Boolean)
+      .join(', ')),
+  };
+  const tel = telHref(biz.phone);
+  const websiteUrl = safeWebsiteHref(biz.website);
+
   // For services / team / contact: inject a page header into the same shell.
   // Simplest: append a page-specific section before </body>.
   const heads = {
@@ -109,13 +148,13 @@ function pageHtml(page, biz, spec, slug) {
         <li>End-of-life consultations + in-home euthanasia</li>
       </ul></section>`,
     team: `<section style="padding:4em 3em;max-width:900px;margin:0 auto"><h1>Our Team</h1>
-      <p style="line-height:1.7;font-size:1.1em">Board-certified veterinarians, Fear Free Certified® technicians, and front-desk staff who remember your pet's name. Photos + bios coming soon — call us at ${biz.phone || ''} for an introduction.</p></section>`,
+      <p style="line-height:1.7;font-size:1.1em">Board-certified veterinarians, Fear Free Certified® technicians, and front-desk staff who remember your pet's name. Photos + bios coming soon — call us at ${safe.phone} for an introduction.</p></section>`,
     contact: `<section style="padding:4em 3em;max-width:900px;margin:0 auto"><h1>Contact</h1>
-      <p style="line-height:1.7;font-size:1.1em"><strong>${biz.name}</strong><br>
-      ${[biz.address, biz.city, biz.state, biz.zip].filter(Boolean).join(', ')}<br>
-      ${biz.phone ? `<a href="tel:${biz.phone}">${biz.phone}</a><br>` : ''}
-      ${biz.email ? `<a href="mailto:${biz.email}">${biz.email}</a><br>` : ''}
-      ${biz.website ? `<a href="${biz.website}" target="_blank" rel="noopener">${biz.website}</a><br>` : ''}
+      <p style="line-height:1.7;font-size:1.1em"><strong>${safe.name}</strong><br>
+      ${safe.addrLine}<br>
+      ${tel ? `<a href="tel:${esc(tel)}">${safe.phone}</a><br>` : ''}
+      ${biz.email ? `<a href="mailto:${safe.email}">${safe.email}</a><br>` : ''}
+      ${websiteUrl ? `<a href="${esc(websiteUrl)}" target="_blank" rel="noopener noreferrer">${esc(websiteUrl)}</a><br>` : ''}
       </p></section>`,
   };
   // Inject the section right before </body>

← 82583ba [morning-review] animals: drop trailing footer separator + b  ·  back to Animals  ·  tighten .gitignore: add missing standing-rule patterns (*.lo a79a69b →