[object Object]

← back to Animals

[morning-review] animals: drop trailing footer separator + bundle iter4 template safety (lang/title/viewport, font-name encode, XSS escape)

82583ba367be35abdad45d044d21793d31331424 · 2026-05-04 12:50:32 -0700 · SteveStudio2

Files touched

Diff

commit 82583ba367be35abdad45d044d21793d31331424
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 4 12:50:32 2026 -0700

    [morning-review] animals: drop trailing footer separator + bundle iter4 template safety (lang/title/viewport, font-name encode, XSS escape)
---
 src/audit/design_system.js | 94 +++++++++++++++++++++++++++++++++-------------
 1 file changed, 68 insertions(+), 26 deletions(-)

diff --git a/src/audit/design_system.js b/src/audit/design_system.js
index 522eb71..1d1a2d6 100644
--- a/src/audit/design_system.js
+++ b/src/audit/design_system.js
@@ -23,6 +23,17 @@
 
 import crypto from 'node:crypto';
 
+// ── HTML escape — wrap every interpolation that ends up in HTML text/attrs
+export const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({
+  '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;',
+}[c]));
+
+// Allow only printable ASCII font-name characters before injecting into CSS.
+// Curated PAIRS today are all ASCII alphanumeric+space, but this guards
+// against future entries with quotes/semicolons that would break :root rules.
+const FONT_NAME_RE = /^[A-Za-z0-9 ]+$/;
+const safeFontName = (name, fallback) => FONT_NAME_RE.test(name) ? name : fallback;
+
 // ── seeded RNG so output is deterministic per (biz_id, variant) ───────────
 function makeRng(seedStr) {
   let h = 2166136261;
@@ -170,16 +181,27 @@ export function renderHtml(spec, biz) {
 
 // ── shared shell / helpers ─────────────────────────────────────────────────
 function shell(biz, p, t, c, body) {
-  const fonts = (`${t.heading.replace(/ /g,'+')}:wght@400;700;900&family=${t.body.replace(/ /g,'+')}:wght@400;500;600;700`);
-  return `<!doctype html><html><head><meta charset=utf-8>
+  // Encode each family name so `&`, `=`, `:`, `@`, `;` stay as URL structure
+  // (and any future font name with whitespace/special chars round-trips).
+  const heading = safeFontName(t.heading, 'Inter');
+  const bodyFont = safeFontName(t.body, 'Inter');
+  const fontUrl =
+    `https://fonts.googleapis.com/css2` +
+    `?family=${encodeURIComponent(heading)}:wght@400;700;900` +
+    `&family=${encodeURIComponent(bodyFont)}:wght@400;500;600;700` +
+    `&display=swap`;
+  const pageTitle = body.title ?? `${biz.name || 'Your Business'}${biz.city ? ' — ' + biz.city : ''}`;
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(pageTitle)}</title>
 <link rel="preconnect" href="https://fonts.googleapis.com">
 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${fonts}&display=swap">
+<link rel="stylesheet" href="${fontUrl}">
 <style>
 :root{
   --ink:${p.ink}; --paper:${p.paper}; --accent:${p.accent};
   --accent2:${p.accent2}; --muted:${p.muted}; --rule:${p.rule};
-  --h-font:'${t.heading}', serif; --b-font:'${t.body}', system-ui, sans-serif;
+  --h-font:'${heading}', serif; --b-font:'${bodyFont}', system-ui, sans-serif;
   --btn-r:${c.btnRadius}; --card-r:${c.cardRadius}; --card-shadow:${c.cardShadow};
   --divider:${c.divider};
 }
@@ -196,14 +218,34 @@ ${body.css || ''}
 </style></head><body>${body.html}</body></html>`;
 }
 
-const meta = (biz) => ({
-  name: biz.name || 'Your Business',
-  cat: (biz.category || 'business').replace(/_/g,' '),
-  city: biz.city || '',
-  state: biz.state || '',
-  addr: [biz.address, biz.city, biz.state, biz.zip].filter(Boolean).join(', '),
-  phone: biz.phone || '',
-});
+function formatAddr(biz) {
+  const street = (biz.address || '').toString().trim();
+  const city = (biz.city || '').toString().trim();
+  const state = (biz.state || '').toString().trim();
+  const zip = (biz.zip || '').toString().trim();
+  const parts = [];
+  if (street) parts.push(street);
+  const csz = [city, state].filter(Boolean).join(', ');
+  const cszFinal = zip ? (csz ? `${csz} ${zip}` : zip) : csz;
+  if (cszFinal) parts.push(cszFinal);
+  return parts.join(', ');
+}
+
+// All fields are HTML-escaped — every layout interpolates them directly into
+// HTML text content, so escaping here neutralises XSS for the whole template
+// surface in one place.
+const meta = (biz) => {
+  const phone = esc(biz.phone || '');
+  return {
+    name: esc(biz.name || 'Your Business'),
+    cat: esc((biz.category || 'business').replace(/_/g,' ')),
+    city: esc(biz.city || ''),
+    state: esc(biz.state || ''),
+    addr: esc(formatAddr(biz)),
+    phone,
+    phoneSep: phone ? ` · ${phone}` : '',
+  };
+};
 
 // ── 15 layouts ────────────────────────────────────────────────────────────
 function layoutHeroSplash(biz, p, t, c) {
@@ -231,7 +273,7 @@ function layoutHeroSplash(biz, p, t, c) {
       <div><h3>Surgery & Dental</h3><p>In-house, with same-day discharge for most procedures.</p></div>
       <div><h3>Urgent Care</h3><p>Same-day sick visits — call us before going to the ER.</p></div>
     </section>
-    <footer class=foot>${m.name} · ${m.addr} · ${m.phone}</footer>`
+    <footer class=foot>${m.name} · ${m.addr}${m.phoneSep}</footer>`
   });
 }
 
@@ -273,7 +315,7 @@ function layoutEditorialMagazine(biz, p, t, c) {
         <div><h3>Surgery & Dental</h3><p>In-house surgical suite with anesthetic monitoring. Same-day discharge for most.</p></div>
         <div><h3>Urgent Sick Care</h3><p>Same-day appointments most weekdays. Call before 11 AM, visit before 5.</p></div>
       </section>
-      <footer><p class=muted>${m.addr} · ${m.phone}</p></footer>
+      <footer><p class=muted>${m.addr}${m.phoneSep}</p></footer>
     </div>`
   });
 }
@@ -307,7 +349,7 @@ function layoutSplit(biz, p, t, c) {
         <div class=svc><div><strong>Dental cleaning</strong><br><small>Includes anesthesia + monitoring</small></div><a href=#>From $450</a></div>
         <div class=svc><div><strong>Spay/Neuter</strong><br><small>Cats and dogs both</small></div><a href=#>From $225</a></div>
         <hr class=divider>
-        <p class=muted>${m.addr} · ${m.phone}</p>
+        <p class=muted>${m.addr}${m.phoneSep}</p>
       </section>
     </main>`
   });
@@ -353,7 +395,7 @@ function layoutBookingFirst(biz, p, t, c) {
         <div><h3>Real follow-up</h3><p>Email check-in 48h after every visit.</p></div>
       </div>
     </section>
-    <footer style="padding:2em 3em;color:var(--muted)">${m.addr} · ${m.phone}</footer>`
+    <footer style="padding:2em 3em;color:var(--muted)">${m.addr}${m.phoneSep}</footer>`
   });
 }
 
@@ -388,7 +430,7 @@ function layoutCommunity(biz, p, t, c) {
       <h2>Same-day sick visits, every weekday</h2>
       <a href=#>Check today's openings →</a>
     </section>
-    <footer style="padding:2em 3em;color:var(--muted);text-align:center">${m.addr} · ${m.phone}</footer>`
+    <footer style="padding:2em 3em;color:var(--muted);text-align:center">${m.addr}${m.phoneSep}</footer>`
   });
 }
 
@@ -428,7 +470,7 @@ function layoutAuthority(biz, p, t, c) {
       <div><h3>Surgery</h3><p>Soft-tissue, orthopedic, and neurosurgical procedures. Board-certified surgeons on every case.</p></div>
       <div><h3>Oncology</h3><p>Chemotherapy, radiation referrals, palliative-care consults. We call you within 24h of every diagnosis.</p></div>
     </section>
-    <footer style="padding:2em 4em;background:var(--ink);color:rgba(255,255,255,.7)">${m.addr} · ${m.phone}</footer>`
+    <footer style="padding:2em 4em;background:var(--ink);color:rgba(255,255,255,.7)">${m.addr}${m.phoneSep}</footer>`
   });
 }
 
@@ -461,7 +503,7 @@ function layoutConciergeLuxe(biz, p, t, c) {
       <div><h3>Concierge medicine</h3><p>Direct cell-phone access to your DVM. No portal. No phone tree.</p></div>
       <div><h3>Estate planning</h3><p>Pet-trust referrals, end-of-life planning, in-home euthanasia.</p></div>
     </section>
-    <footer style="padding:3em;text-align:center;color:var(--muted);font-size:.85em">${m.addr} · ${m.phone}</footer>`
+    <footer style="padding:3em;text-align:center;color:var(--muted);font-size:.85em">${m.addr}${m.phoneSep}</footer>`
   });
 }
 
@@ -507,7 +549,7 @@ function layoutNewsroom(biz, p, t, c) {
         <div><h3>Surgery & Dental</h3><p>In-house surgical suite with continuous anesthetic monitoring.</p></div>
         <div><h3>Specialty Referrals</h3><p>Cardiology, dermatology, oncology — we know the right specialists.</p></div>
       </section>
-      <footer style="padding:2em 0;color:var(--muted)">${m.addr} · ${m.phone}</footer>
+      <footer style="padding:2em 0;color:var(--muted)">${m.addr}${m.phoneSep}</footer>
     </article>`
   });
 }
@@ -559,7 +601,7 @@ function layoutBoutique(biz, p, t, c) {
       <div class=bq-card><div class=ico>🦴</div><h3>Dental</h3><p>Cleanings under anesthesia with continuous monitoring.</p></div>
       <div class=bq-card><div class=ico>❤️</div><h3>Senior care</h3><p>A dedicated quarterly visit for pets 8+, with bloodwork.</p></div>
     </section>
-    <footer style="padding:2em 4em;text-align:center;color:var(--muted)">${m.addr} · ${m.phone}</footer>`
+    <footer style="padding:2em 4em;text-align:center;color:var(--muted)">${m.addr}${m.phoneSep}</footer>`
   });
 }
 
@@ -588,7 +630,7 @@ function layoutPlayful(biz, p, t, c) {
       <div class=pl-card><div class=emoji>🐈</div><h3>Cats</h3><p>Cat-only exam room. Calmer everything.</p></div>
       <div class=pl-card><div class=emoji>🐰</div><h3>Exotics</h3><p>Rabbits, reptiles, birds, small mammals welcome.</p></div>
     </section>
-    <footer style="padding:2em 3em;text-align:center;color:var(--muted)">${m.addr} · ${m.phone}</footer>`
+    <footer style="padding:2em 3em;text-align:center;color:var(--muted)">${m.addr}${m.phoneSep}</footer>`
   });
 }
 
@@ -622,7 +664,7 @@ function layoutServiceTiles(biz, p, t, c) {
       <div class=st-tile><span class=num>07</span><h3>Behavior</h3><p>Training referrals, anxiety meds, consults.</p></div>
       <div class=st-tile><span class=num>08</span><h3>End of life</h3><p>In-home euthanasia + grief support.</p></div>
     </section>
-    <footer style="padding:2em 3em;color:var(--muted)">${m.addr} · ${m.phone}</footer>`
+    <footer style="padding:2em 3em;color:var(--muted)">${m.addr}${m.phoneSep}</footer>`
   });
 }
 
@@ -655,7 +697,7 @@ function layoutCalendar(biz, p, t, c) {
       </div>
       <a class=btn href=#book>Continue →</a>
     </section>
-    <footer style="padding:2em 3em;text-align:center;color:var(--muted)">${m.addr} · ${m.phone}</footer>`
+    <footer style="padding:2em 3em;text-align:center;color:var(--muted)">${m.addr}${m.phoneSep}</footer>`
   });
 }
 
@@ -688,7 +730,7 @@ function layoutFamily(biz, p, t, c) {
       <p>Most clients book within 3 days. Emergency? Call us — we take same-day sick visits Monday-Friday.</p>
       <a class=btn-2 href=#book>See this week's openings →</a>
     </section>
-    <footer style="padding:2em 4em;text-align:center;color:var(--muted)">${m.addr} · ${m.phone}</footer>`
+    <footer style="padding:2em 4em;text-align:center;color:var(--muted)">${m.addr}${m.phoneSep}</footer>`
   });
 }
 
@@ -737,6 +779,6 @@ function layoutModernClinic(biz, p, t, c) {
         <li>Senior bloodwork <small>$185</small></li>
       </ul>
     </section>
-    <footer style="padding:2em 3em;text-align:center;color:var(--muted)">${m.addr} · ${m.phone}</footer>`
+    <footer style="padding:2em 3em;text-align:center;color:var(--muted)">${m.addr}${m.phoneSep}</footer>`
   });
 }

← 14cfcfd yolo: iter 3 trivial patches from claude-codex debate  ·  back to Animals  ·  [morning-review] animals: escape biz fields, validate websit b9336a0 →