← 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
M lib/render.jsM public/js/build.jsM public/js/cart.jsA public/js/imgfallback.jsM public/js/moodboard.jsM server.js
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 →