← 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 →