← 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
M lib/data.jsM package-lock.jsonM package.jsonM routes/public.jsM server.jsM views/public/about.ejsM views/public/designer.ejs
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 →