← 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
M src/audit/design_system.jsM src/scripts/build_subsites.js
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 →