[object Object]

← back to Interiordesignershowroom

security: full script-src 'self' CSP on public (delegated all img onerror incl client JS) + fix /admin path-guard prefix collision (Cody-hardened)

27b177528a920fc4dc245bb0bc2642ce8ee49d29 · 2026-08-03 12:12:52 -0700 · Steve Abrams

Files touched

Diff

commit 27b177528a920fc4dc245bb0bc2642ce8ee49d29
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 3 12:12:52 2026 -0700

    security: full script-src 'self' CSP on public (delegated all img onerror incl client JS) + fix /admin path-guard prefix collision (Cody-hardened)
---
 lib/render.js            |  3 ++-
 public/js/build.js       |  4 ++--
 public/js/cart.js        |  2 +-
 public/js/imgfallback.js | 19 +++++++++++++++++++
 public/js/moodboard.js   |  4 ++--
 server.js                | 10 ++++++++--
 6 files changed, 34 insertions(+), 8 deletions(-)

diff --git a/lib/render.js b/lib/render.js
index ffa2d61..a869375 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -90,7 +90,7 @@ function productCard(p) {
 
   // Image: use img with explicit width/height for aspect-ratio + graceful broken-img handling
   const imgHtml = p.image_url
-    ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}" onerror="this.style.display='none';this.parentElement.classList.add('noimg-fallback')">`
+    ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}">`
     : '';
 
   // "Added to the store" date+time: NEW badge over the image (recent items) plus a
@@ -282,6 +282,7 @@ function layout({ title, description, canonical, jsonld, image, body, activeNav,
 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
 <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Serif+Display&display=swap">
 <link rel="stylesheet" href="${v('/css/site.css')}">
+<script src="${v('/js/imgfallback.js')}"></script>
 ${(Array.isArray(jsonld) ? jsonld : [jsonld]).filter(Boolean).map((j) => `<script type="application/ld+json">${JSON.stringify(j).replace(/</g, '\\u003c')}</script>`).join('')}
 </head>
 <body>
diff --git a/public/js/build.js b/public/js/build.js
index 6e0687a..e31198f 100644
--- a/public/js/build.js
+++ b/public/js/build.js
@@ -21,7 +21,7 @@
         var price = (p.sale_price != null ? money(p.sale_price) : money(p.price));
         var meta = [price, (p.advertiser || p.brand || '')].filter(Boolean).join(' · ');
         return '<a class="rbp shop" href="/go/' + p.id + '" target="_blank" rel="nofollow sponsored" aria-label="Shop ' + (p.title || '').slice(0, 40).replace(/"/g, '') + '">' +
-          (p.image_url ? '<img loading="lazy" src="' + p.image_url + '" alt="" onerror="this.style.display=\'none\'">' : '<div class="noimg"></div>') +
+          (p.image_url ? '<img loading="lazy" src="' + p.image_url + '" alt="" data-hide-on-error="1">' : '<div class="noimg"></div>') +
           '<div class="rbp-t">' + (p.title || '').slice(0, 50) + '</div><div class="rbp-m">' + meta + '</div></a>';
       }).join('');
   }
@@ -57,7 +57,7 @@
     var priceStr = (p.sale_price != null ? money(p.sale_price) : money(p.price));
     var metaStr = [priceStr, (p.advertiser || p.brand || '')].filter(Boolean).join(' · ');
     var imgHtml = p.image_url
-      ? '<img loading="lazy" src="' + p.image_url + '" alt="' + (p.title || '').replace(/"/g, '') + '" onerror="this.style.display=\'none\'">'
+      ? '<img loading="lazy" src="' + p.image_url + '" alt="' + (p.title || '').replace(/"/g, '') + '" data-hide-on-error="1">'
       : '<div class="noimg"></div>';
     return '<div class="rbp" data-id="' + p.id + '" draggable="true" tabindex="0" role="button" aria-label="Add ' + (p.title || '').slice(0, 40).replace(/"/g, '') + '">' +
       imgHtml +
diff --git a/public/js/cart.js b/public/js/cart.js
index 062c376..564515a 100644
--- a/public/js/cart.js
+++ b/public/js/cart.js
@@ -74,7 +74,7 @@
     var c = load(), items = drawer.querySelector('.cart-items'), foot = drawer.querySelector('.cart-foot');
     if (!c.length) { items.innerHTML = '<p class="cart-empty">Your list is empty.<br>Add pieces you love, then open them all to shop at each store.</p>'; foot.innerHTML = ''; return; }
     items.innerHTML = c.map(function (x) {
-      return '<div class="cart-item"><img src="' + esc(x.image) + '" alt="" onerror="this.style.visibility=\'hidden\'">'
+      return '<div class="cart-item"><img src="' + esc(x.image) + '" alt="" data-hide-on-error="1">'
         + '<div class="cart-item-b"><div class="cart-item-t">' + esc(x.title) + '</div>'
         + '<div class="cart-item-m">' + esc(x.price || '') + (x.advertiser ? ' · ' + esc(x.advertiser) : '') + '</div>'
         + '<a class="cart-item-open" href="' + esc(x.url) + '" target="_blank" rel="nofollow sponsored">Open →</a></div>'
diff --git a/public/js/imgfallback.js b/public/js/imgfallback.js
new file mode 100644
index 0000000..0084912
--- /dev/null
+++ b/public/js/imgfallback.js
@@ -0,0 +1,19 @@
+// Delegated <img> load-failure handler — replaces the inline `onerror=` on product
+// card images so a strict `script-src 'self'` CSP (no 'unsafe-inline') can ship on the
+// public storefront. Loaded SYNCHRONOUSLY in <head>, capture phase, on purpose:
+// `error` events do NOT bubble and fire while the page is still loading, so the listener
+// must be registered before any <body> image starts fetching or the early failures are
+// missed. Scope + effect exactly mirror the old inline handler (img directly inside
+// `.card-img` → hide it and mark the parent for the CSS fallback).
+document.addEventListener('error', function (e) {
+  var el = e.target;
+  if (!el || el.tagName !== 'IMG') return;
+  // server-rendered product card: hide + mark parent for the CSS fallback
+  if (el.parentElement && el.parentElement.classList.contains('card-img')) {
+    el.style.display = 'none';
+    el.parentElement.classList.add('noimg-fallback');
+    return;
+  }
+  // JS-rendered thumbs (build/moodboard/cart) opt in with data-hide-on-error
+  if (el.hasAttribute('data-hide-on-error')) el.style.display = 'none';
+}, true);
diff --git a/public/js/moodboard.js b/public/js/moodboard.js
index 98893c8..63a6def 100644
--- a/public/js/moodboard.js
+++ b/public/js/moodboard.js
@@ -209,7 +209,7 @@
           return '<div class="mb-tile' + (x.primary ? ' is-primary' : '') + '" data-id="' + esc(x.id) + '">'
             + '<button class="mb-star" type="button" data-star="' + esc(x.id) + '" aria-label="Make this the primary piece">' + (x.primary ? '★ Primary' : '☆ Set primary') + '</button>'
             + '<button class="mb-tile-rm" type="button" data-rm="' + esc(x.id) + '" aria-label="Remove from mood board">✕</button>'
-            + '<img src="' + esc(x.image) + '" alt="" onerror="this.style.visibility=\'hidden\'">'
+            + '<img src="' + esc(x.image) + '" alt="" data-hide-on-error="1">'
             + '<div class="mb-tile-t">' + esc(x.title) + '</div></div>';
         }).join('') + '</div>'
       : '<p class="mb-empty">This project is empty.<br>While you shop, tap <strong>◇ Mood Board</strong> on any piece to collect it here.</p>';
@@ -255,7 +255,7 @@
     }
     var pieces = (r.products || []).map(function (p) {
       return '<a class="mb-piece" href="/go/' + esc(p.id) + '" target="_blank" rel="nofollow sponsored">'
-        + '<img src="' + esc(p.image_url) + '" alt="" onerror="this.style.visibility=\'hidden\'">'
+        + '<img src="' + esc(p.image_url) + '" alt="" data-hide-on-error="1">'
         + '<span>' + esc(p.title) + (p.advertiser ? ' — ' + esc(p.advertiser) : '') + '</span></a>';
     }).join('');
     scroll.innerHTML = '<button class="mb-back" type="button">← Back to board</button>'
diff --git a/server.js b/server.js
index fd9aee3..2a6abcc 100644
--- a/server.js
+++ b/server.js
@@ -19,12 +19,18 @@ const PORT = process.env.PORT || 9820;
 // non-breaking and close the obvious gaps (MIME sniffing, clickjacking, referrer leakage,
 // unused powerful features). Also stop advertising the framework.
 app.disable('x-powered-by');
-app.use((_req, res, next) => {
+app.use((req, res, next) => {
   res.set('X-Content-Type-Options', 'nosniff');
   res.set('X-Frame-Options', 'SAMEORIGIN');
   res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
   res.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), browsing-topics=()');
-  res.set('Content-Security-Policy', "frame-ancestors 'self'; object-src 'none'; base-uri 'none'");
+  // script-src 'self' locks the PUBLIC storefront to same-origin scripts (no inline —
+  // the img onerror is now delegated in /js/imgfallback.js). /admin keeps 'unsafe-inline'
+  // (behind auth, low XSS risk) so its one inline onsubmit confirm still works without
+  // surgery on the concurrently-edited admin shell. ld+json is data, unaffected by script-src.
+  const isAdmin = req.path === '/admin' || req.path.indexOf('/admin/') === 0;
+  const scriptSrc = isAdmin ? "script-src 'self' 'unsafe-inline'" : "script-src 'self'";
+  res.set('Content-Security-Policy', `${scriptSrc}; frame-ancestors 'self'; object-src 'none'; base-uri 'none'`);
   next();
 });
 

← 93f0ffc perf: content-hash asset cache-buster (?v=) + immutable css/  ·  back to Interiordesignershowroom  ·  fix(server): graceful listen error handler + honest HOST bin c686c5d →