[object Object]

← back to Stars of Design

security hardening: helmet (CSP/HSTS/X-Frame-Options/Referrer-Policy/Permissions-Policy/COOP), express-rate-limit (200/min/IP), JSON-LD <script> escape, javascript: URL filter, designerwallcoverings.com URL allowlist for preferred_dw, slug regex validation, q clamp to 120 chars, rel=noopener noreferrer everywhere, error message hardcoded

553c74aea2f430c8d7ed8a23201a2cf8bbb7ab1d · 2026-05-12 09:38:29 -0700 · Steve Abrams

Files touched

Diff

commit 553c74aea2f430c8d7ed8a23201a2cf8bbb7ab1d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue May 12 09:38:29 2026 -0700

    security hardening: helmet (CSP/HSTS/X-Frame-Options/Referrer-Policy/Permissions-Policy/COOP), express-rate-limit (200/min/IP), JSON-LD <script> escape, javascript: URL filter, designerwallcoverings.com URL allowlist for preferred_dw, slug regex validation, q clamp to 120 chars, rel=noopener noreferrer everywhere, error message hardcoded
---
 lib/data.js               | 29 +++++++++++++++++++++++++++--
 package-lock.json         | 38 ++++++++++++++++++++++++++++++++++++++
 package.json              |  2 ++
 routes/public.js          |  6 +++++-
 server.js                 | 37 ++++++++++++++++++++++++++++++++++++-
 views/public/about.ejs    |  4 ++--
 views/public/designer.ejs | 22 ++++++++++++++++------
 7 files changed, 126 insertions(+), 12 deletions(-)

diff --git a/lib/data.js b/lib/data.js
index 0698991..63b40fc 100644
--- a/lib/data.js
+++ b/lib/data.js
@@ -10,11 +10,35 @@ const FILE = path.join(__dirname, '..', 'data', 'designers.json');
 let _cache = null;
 let _mtime = 0;
 
+// URL safety — only allow http(s) in href fields rendered to <a> tags.
+// Blocks javascript:, data:, vbscript:, file:, mailto: (mailto would be fine for some fields but we have no use for it in this directory).
+function safeUrl(u) {
+  if (typeof u !== 'string') return null;
+  return /^https?:\/\//i.test(u) ? u : null;
+}
+
+// preferred_dw must point at designerwallcoverings.com (the directory's
+// publisher). Anything else gets rewritten to '#' so a bad data edit can't
+// silently redirect users to a phishing clone.
+function sanitizeDwUrl(u) {
+  return (typeof u === 'string' && /^https:\/\/designerwallcoverings\.com\//i.test(u)) ? u : '#';
+}
+
+function sanitizeRow(d) {
+  return {
+    ...d,
+    firm_url:      safeUrl(d.firm_url),
+    instagram_url: safeUrl(d.instagram_url),
+    preferred_dw:  (d.preferred_dw || []).map((p) => ({ label: p.label, url: sanitizeDwUrl(p.url) })),
+    sources:       (d.sources || []).map((s) => ({ ...s, url: safeUrl(s.url) || '#' })),
+  };
+}
+
 function load() {
   const stat = fs.statSync(FILE);
   if (!_cache || stat.mtimeMs !== _mtime) {
     const raw = JSON.parse(fs.readFileSync(FILE, 'utf8'));
-    _cache = raw.map((d, idx) => ({ id: idx + 1, ...d }));
+    _cache = raw.map((d, idx) => sanitizeRow({ id: idx + 1, ...d }));
     _mtime = stat.mtimeMs;
   }
   return _cache;
@@ -41,7 +65,8 @@ function filter(opts = {}) {
   if (opts.style) rows = rows.filter((d) => (d.styles || []).includes(opts.style));
   if (opts.era)   rows = rows.filter((d) => d.era === opts.era);
   if (opts.q) {
-    const q = String(opts.q).toLowerCase();
+    // Clamp q to 120 chars — nginx caps URL at 8kb, but 120 is plenty for a search box
+    const q = String(opts.q).slice(0, 120).toLowerCase();
     rows = rows.filter((d) =>
       (d.name || '').toLowerCase().includes(q) ||
       (d.city || '').toLowerCase().includes(q) ||
diff --git a/package-lock.json b/package-lock.json
index e5c1646..4a8b2f8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,8 @@
         "dotenv": "^16.6.1",
         "ejs": "^3.1.10",
         "express": "^4.21.0",
+        "express-rate-limit": "^8.5.1",
+        "helmet": "^8.1.0",
         "morgan": "^1.10.0",
         "pg": "^8.20.0"
       },
@@ -364,6 +366,24 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/express-rate-limit": {
+      "version": "8.5.1",
+      "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
+      "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ip-address": "^10.2.0"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/express-rate-limit"
+      },
+      "peerDependencies": {
+        "express": ">= 4.11"
+      }
+    },
     "node_modules/filelist": {
       "version": "1.0.6",
       "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -491,6 +511,15 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/helmet": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+      "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
     "node_modules/http-errors": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -529,6 +558,15 @@
       "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
       "license": "ISC"
     },
+    "node_modules/ip-address": {
+      "version": "10.2.0",
+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
+      "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12"
+      }
+    },
     "node_modules/ipaddr.js": {
       "version": "1.9.1",
       "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
diff --git a/package.json b/package.json
index 18828ca..dd8889d 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,8 @@
     "dotenv": "^16.6.1",
     "ejs": "^3.1.10",
     "express": "^4.21.0",
+    "express-rate-limit": "^8.5.1",
+    "helmet": "^8.1.0",
     "morgan": "^1.10.0",
     "pg": "^8.20.0"
   },
diff --git a/routes/public.js b/routes/public.js
index 4bb4c3a..5c68b47 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -52,7 +52,11 @@ router.get('/designers', (req, res, next) => {
 
 router.get('/designers/:slug', (req, res, next) => {
   try {
-    const d = data.bySlug(req.params.slug);
+    const slug = req.params.slug;
+    if (!/^[a-z0-9-]{1,80}$/.test(slug)) {
+      return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
+    }
+    const d = data.bySlug(slug);
     if (!d) return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
     res.render('public/designer', {
       title: `${d.name} — Profile, Signature Style & Wallcovering Pairings | Stars of Design`,
diff --git a/server.js b/server.js
index 5b7f73b..98befd2 100644
--- a/server.js
+++ b/server.js
@@ -3,6 +3,8 @@ require('dotenv').config();
 const express = require('express');
 const path = require('path');
 const morgan = require('morgan');
+const helmet = require('helmet');
+const rateLimit = require('express-rate-limit');
 
 const publicRoutes = require('./routes/public');
 
@@ -16,6 +18,38 @@ app.set('view engine', 'ejs');
 app.set('views', path.join(__dirname, 'views'));
 app.disable('x-powered-by');
 
+// Security headers (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
+app.use(helmet({
+  contentSecurityPolicy: {
+    directives: {
+      defaultSrc: ["'self'"],
+      scriptSrc: ["'self'", "'unsafe-inline'", 'https://www.googletagmanager.com', 'https://www.google-analytics.com'],
+      styleSrc:  ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
+      imgSrc:    ["'self'", 'data:', 'https:'],
+      fontSrc:   ["'self'", 'https://fonts.gstatic.com', 'data:'],
+      connectSrc: ["'self'", 'https://www.google-analytics.com', 'https://*.analytics.google.com', 'https://*.googletagmanager.com'],
+      frameAncestors: ["'none'"],
+      objectSrc: ["'none'"],
+      baseUri:   ["'self'"],
+      formAction: ["'self'"],
+      upgradeInsecureRequests: [],
+    },
+  },
+  strictTransportSecurity: { maxAge: 31536000, includeSubDomains: true, preload: true },
+  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
+  crossOriginEmbedderPolicy: false,
+}));
+app.use((req, res, next) => {
+  res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()');
+  next();
+});
+
+// Rate limit — 200 req/min/IP across all routes, defense in depth
+app.use(rateLimit({
+  windowMs: 60_000, max: 200, standardHeaders: 'draft-7', legacyHeaders: false,
+  message: 'Too many requests, please slow down.',
+}));
+
 app.use(morgan(IS_PROD ? 'combined' : 'dev'));
 app.use(express.json({ limit: '256kb' }));
 app.use(express.urlencoded({ extended: true, limit: '256kb' }));
@@ -42,9 +76,10 @@ app.use((req, res) => {
 // eslint-disable-next-line no-unused-vars
 app.use((err, req, res, next) => {
   console.error('[error]', err && err.stack ? err.stack : err);
+  // Always show generic message in prod — never leak internals
   res.status(500).render('public/error', {
     title: 'Server error — Stars of Design',
-    message: IS_PROD ? 'Something went wrong.' : (err && err.message) || 'Unknown error',
+    message: 'Something went wrong.',
   });
 });
 
diff --git a/views/public/about.ejs b/views/public/about.ejs
index e284832..fb0b786 100644
--- a/views/public/about.ejs
+++ b/views/public/about.ejs
@@ -36,14 +36,14 @@
         best match their visual language — Buatta gets chintz, Vervoordt gets
         plaster, Hicks gets hexagonal geometric. The link sends you to the
         relevant collection page on
-        <a href="https://designerwallcoverings.com" rel="noopener">designerwallcoverings.com</a>
+        <a href="https://designerwallcoverings.com" rel="noopener noreferrer">designerwallcoverings.com</a>
         for sampling and quotes.
       </p>
 
       <h2>Who runs this</h2>
       <p>
         Stars of Design is an editorial sister property of
-        <a href="https://designerwallcoverings.com" rel="noopener">Designer Wallcoverings</a>,
+        <a href="https://designerwallcoverings.com" rel="noopener noreferrer">Designer Wallcoverings</a>,
         a Sherman Oaks, California–based wallcovering and textile dealer. We
         do not represent a single line; we map the designers who shape
         interiors across every line.
diff --git a/views/public/designer.ejs b/views/public/designer.ejs
index c4cb2be..c8ff988 100644
--- a/views/public/designer.ejs
+++ b/views/public/designer.ejs
@@ -21,7 +21,17 @@
     });
   }
 %>
-<script type="application/ld+json"><%- JSON.stringify(jsonld) %></script>
+<%
+  // Escape </script>, HTML comment markers, and U+2028/U+2029 so the JSON-LD
+  // payload can't break out of the <script> tag if any data field ever contains
+  // those substrings. Standard OWASP XSS Prevention Rule #3.1 for inline JSON.
+  var jsonldSafe = JSON.stringify(jsonld)
+    .replace(/<\/script/gi, '<\\/script')
+    .replace(/<!--/g, '<\\!--')
+    .replace(/
/g, '\\u2028')
+    .replace(/
/g, '\\u2029');
+%>
+<script type="application/ld+json"><%- jsonldSafe %></script>
 <%- include('../partials/header') %>
 
 <main class="designer-detail">
@@ -64,7 +74,7 @@
         <p class="muted small">Curated by Designer Wallcoverings — collections that map to <%= designer.name.split(' ')[0] %>'s published interiors.</p>
         <div class="pairing-grid">
           <% designer.preferred_dw.forEach(function (p) { %>
-            <a class="pairing-card" href="<%= p.url %>" rel="noopener" target="_blank">
+            <a class="pairing-card" href="<%= p.url %>" rel="noopener noreferrer" target="_blank">
               <span class="pairing-label"><%= p.label %></span>
               <span class="pairing-arrow" aria-hidden="true">→</span>
             </a>
@@ -75,8 +85,8 @@
       <% if (designer.firm_url || designer.instagram_url) { %>
         <h2>Find the firm</h2>
         <ul class="links">
-          <% if (designer.firm_url) { %><li><a href="<%= designer.firm_url %>" rel="noopener" target="_blank">Firm website ↗</a></li><% } %>
-          <% if (designer.instagram_url) { %><li><a href="<%= designer.instagram_url %>" rel="noopener" target="_blank">Instagram ↗</a></li><% } %>
+          <% if (designer.firm_url) { %><li><a href="<%= designer.firm_url %>" rel="noopener noreferrer" target="_blank">Firm website ↗</a></li><% } %>
+          <% if (designer.instagram_url) { %><li><a href="<%= designer.instagram_url %>" rel="noopener noreferrer" target="_blank">Instagram ↗</a></li><% } %>
         </ul>
       <% } %>
 
@@ -86,7 +96,7 @@
         <ul class="sources">
           <% designer.sources.forEach(function (s) { %>
             <li>
-              <a href="<%= s.url %>" rel="noopener" target="_blank"><strong><%= s.outlet %></strong></a><% if (s.title) { %> — <em><%= s.title %></em><% } %><% if (s.year) { %> <span class="muted">(<%= s.year %>)</span><% } %>
+              <a href="<%= s.url %>" rel="noopener noreferrer" target="_blank"><strong><%= s.outlet %></strong></a><% if (s.title) { %> — <em><%= s.title %></em><% } %><% if (s.year) { %> <span class="muted">(<%= s.year %>)</span><% } %>
             </li>
           <% }); %>
         </ul>
@@ -95,7 +105,7 @@
       <p class="muted small detail-disclaim">
         Stars of Design is an editorial profile directory. We are not affiliated with <%= designer.name %> or
         their studio. Wallcovering pairings are curated based on publicly available work and are sold by
-        <a href="https://designerwallcoverings.com" rel="noopener">Designer Wallcoverings</a>, the directory's
+        <a href="https://designerwallcoverings.com" rel="noopener noreferrer">Designer Wallcoverings</a>, the directory's
         publisher. Source citations are provided in line with fair-use editorial practice; visit each outlet
         directly for the full article.
       </p>

← a7bdb25 feat(starsofdesign): DEEP George info@ recon (7-window × 8-t  ·  back to Stars of Design  ·  video gallery — 75 verified YouTube videos from AIA/ASID/AD/ 7dd41c8 →