[object Object]

← back to Patterndesignlab

security: harden designer auth + upload

2e4f7610ed731098ae38d88ff0f97202c5bd019c · 2026-07-06 08:49:49 -0700 · Steve

- fail-closed boot if PUBLIC=1 and PDL_JWT_SECRET is unset/dev-default (cookie-forgery/account-takeover risk)
- cookie now Secure in PUBLIC mode (httpOnly+sameSite already present)
- upload restricted to raster allowlist (png/jpg/webp/gif); SVG dropped (served same-origin -> stored-XSS vector); extension derived from mimetype not client filename; slug sanitized in stored filename
- multer errors (size/type) return clean JSON instead of HTML stack trace

Files touched

Diff

commit 2e4f7610ed731098ae38d88ff0f97202c5bd019c
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jul 6 08:49:49 2026 -0700

    security: harden designer auth + upload
    
    - fail-closed boot if PUBLIC=1 and PDL_JWT_SECRET is unset/dev-default (cookie-forgery/account-takeover risk)
    - cookie now Secure in PUBLIC mode (httpOnly+sameSite already present)
    - upload restricted to raster allowlist (png/jpg/webp/gif); SVG dropped (served same-origin -> stored-XSS vector); extension derived from mimetype not client filename; slug sanitized in stored filename
    - multer errors (size/type) return clean JSON instead of HTML stack trace
---
 server.js | 38 +++++++++++++++++++++++++++++---------
 1 file changed, 29 insertions(+), 9 deletions(-)

diff --git a/server.js b/server.js
index 60a9c92..4422ad1 100644
--- a/server.js
+++ b/server.js
@@ -25,21 +25,34 @@ const bcrypt = require('bcryptjs');
 const jwt = require('jsonwebtoken');
 const multer = require('multer');
 const cookieLib = require('cookie');
-const JWT_SECRET = process.env.PDL_JWT_SECRET || 'pdl-designer-dev-secret-change-me';
+const JWT_DEV_SECRET = 'pdl-designer-dev-secret-change-me';
+const JWT_SECRET = process.env.PDL_JWT_SECRET || JWT_DEV_SECRET;
+// Fail-closed: the dev-default secret is publicly known — anyone could forge a
+// designer cookie (account takeover). Refuse to boot in PUBLIC mode without a
+// real PDL_JWT_SECRET. Local/basic-auth dev (PUBLIC=0) is unaffected.
+if (PUBLIC && JWT_SECRET === JWT_DEV_SECRET) {
+  console.error('FATAL: PUBLIC=1 but PDL_JWT_SECRET is unset/default — refusing to boot (cookie-forgery risk).');
+  process.exit(1);
+}
 const DCOOKIE = 'pdl_designer';
 const UP_DIR = path.join(__dirname, 'public', 'assets', 'designers', 'uploads');
 fs.mkdirSync(UP_DIR, { recursive: true });
+const uploadKind = (req) => (req.query.type === 'logo' ? 'logo' : 'avatar');
+// Raster-only allowlist. SVG is deliberately excluded: uploads are served from
+// this origin, and an SVG can carry inline <script> → stored XSS. Extension is
+// derived from the allowed mimetype, never trusted from the client filename.
+const ALLOWED_IMG = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp', 'image/gif': '.gif' };
 const upload = multer({
   storage: multer.diskStorage({
     destination: (req, file, cb) => cb(null, UP_DIR),
     filename: (req, file, cb) => {
-      const ext = (file.originalname.match(/\.[a-z0-9]+$/i) || ['.png'])[0].toLowerCase();
-      const kind = req.query.type === 'logo' ? 'logo' : 'avatar';
-      cb(null, `${req.designer ? req.designer.slug : 'd'}-${kind}-${Date.now()}${ext}`);
+      const ext = ALLOWED_IMG[file.mimetype] || '.png';
+      const slug = (req.designer && req.designer.slug) ? req.designer.slug.replace(/[^a-z0-9._-]/gi, '') : 'd';
+      cb(null, `${slug}-${uploadKind(req)}-${Date.now()}${ext}`);
     },
   }),
   limits: { fileSize: 6 * 1024 * 1024 },
-  fileFilter: (req, file, cb) => cb(null, /^image\//.test(file.mimetype)),
+  fileFilter: (req, file, cb) => cb(null, Object.prototype.hasOwnProperty.call(ALLOWED_IMG, file.mimetype)),
 });
 function readDesignerCookie(req) {
   try { const t = cookieLib.parse(req.headers.cookie || '')[DCOOKIE]; return t ? jwt.verify(t, JWT_SECRET) : null; }
@@ -208,7 +221,7 @@ app.post('/api/designer/login', async (req, res) => {
     if (!r.rowCount || !r.rows[0].password_hash) return res.status(401).json({ error: 'invalid credentials' });
     if (!(await bcrypt.compare(password, r.rows[0].password_hash))) return res.status(401).json({ error: 'invalid credentials' });
     const token = jwt.sign({ id: r.rows[0].id, slug: r.rows[0].slug }, JWT_SECRET, { expiresIn: '30d' });
-    res.setHeader('Set-Cookie', cookieLib.serialize(DCOOKIE, token, { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30 }));
+    res.setHeader('Set-Cookie', cookieLib.serialize(DCOOKIE, token, { httpOnly: true, secure: PUBLIC, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30 }));
     res.json({ ok: true, slug: r.rows[0].slug });
   } catch (e) { console.error('login', e.message); res.status(500).json({ error: 'login failed' }); }
 });
@@ -231,10 +244,17 @@ app.patch('/api/designer/me', requireDesigner, async (req, res) => {
   } catch (e) { console.error('patch me', e.message); res.status(500).json({ error: 'update failed' }); }
 });
 // image upload — 'file' field, ?type=avatar|logo. (URL mode = PATCH avatar/logo directly; generated fallback = seeded portrait.)
-app.post('/api/designer/upload', requireDesigner, upload.single('file'), async (req, res) => {
+app.post('/api/designer/upload', requireDesigner, (req, res, next) => {
+  // Wrap multer so its errors (size limit / rejected type) return clean JSON
+  // instead of falling through to Express's default HTML stack-trace handler.
+  upload.single('file')(req, res, (err) => {
+    if (err) return res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'image too large (max 6 MB)' : 'upload rejected' });
+    next();
+  });
+}, async (req, res) => {
   try {
-    if (!req.file) return res.status(400).json({ error: 'no image uploaded' });
-    const kind = req.query.type === 'logo' ? 'logo' : 'avatar';
+    if (!req.file) return res.status(400).json({ error: 'no image uploaded (png/jpg/webp/gif only)' });
+    const kind = uploadKind(req);
     const url = `/assets/designers/uploads/${req.file.filename}`;
     await pool.query(`UPDATE designers SET ${kind}=$1, updated_at=now() WHERE id=$2`, [url, req.designer.id]);
     res.json({ ok: true, type: kind, url });

← a4e12d5 designer accounts: bcrypt login + JWT cookie, self-edit endp  ·  back to Patterndesignlab  ·  5x: verify security-hardened PDL — 2 clean sweeps, 0 real de d5dbf02 →