From 2b30bc4a91409c65115dd12960e787744b150c34 Mon Sep 17 00:00:00 2001 From: NW Date: Wed, 8 Jul 2026 12:08:08 +0100 Subject: [PATCH] fix(admin): CSRF cookie sameSite=false for Tor, auth cookie fix, async handlers, validation --- src/admin/auth.js | 4 ++-- src/admin/csrf.js | 41 +++++++++++++++++++++++++++++----- src/admin/routes/categories.js | 33 +++++++++++++++++---------- src/admin/routes/locations.js | 20 ++++++++++++----- src/admin/server.js | 3 ++- src/admin/views/categories.ejs | 5 +++++ src/admin/views/locations.ejs | 2 ++ 7 files changed, 81 insertions(+), 27 deletions(-) diff --git a/src/admin/auth.js b/src/admin/auth.js index be19770..dfb4f91 100644 --- a/src/admin/auth.js +++ b/src/admin/auth.js @@ -95,9 +95,9 @@ export function handleLogin(req, res) { const { token: signed, jti } = signToken({ role: 'admin' }); res.cookie(COOKIE_NAME, signed, { httpOnly: true, - sameSite: 'strict', + sameSite: false, maxAge: MAX_AGE, - secure: process.env.NODE_ENV === 'production' + path: '/' }); res.redirect('/'); } diff --git a/src/admin/csrf.js b/src/admin/csrf.js index 740d864..ff23e99 100644 --- a/src/admin/csrf.js +++ b/src/admin/csrf.js @@ -13,11 +13,10 @@ export function csrfMiddleware(req, res, next) { if (!token) { token = generateToken(); res.cookie(CSRF_COOKIE, token, { - httpOnly: false, - sameSite: 'strict', - path: '/', - secure: process.env.NODE_ENV === 'production' - }); + httpOnly: false, + sameSite: false, + path: '/' + }); } res.locals.csrfToken = token; next(); @@ -27,11 +26,16 @@ export function validateCsrf(req, res, next) { // Only validate state-changing methods if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); + // Skip multipart/form-data — multer hasn't parsed the body yet. + // Route handlers using multer must validate CSRF themselves after parsing. + const ct = req.headers['content-type'] || ''; + if (ct.startsWith('multipart/form-data')) return next(); + const token = req.body?._csrf || req.headers[CSRF_HEADER]; const cookieToken = req.cookies?.[CSRF_COOKIE]; if (!token || !cookieToken) { - logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method }, 'CSRF token missing'); + logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method, hasBodyToken: !!token, hasCookieToken: !!cookieToken, contentType: ct }, 'CSRF token missing'); return res.status(403).send('CSRF token missing. Please reload the page.'); } @@ -44,3 +48,28 @@ export function validateCsrf(req, res, next) { next(); } + +/** + * Validate CSRF token from req.body._csrf (for use inside multer handlers). + * Returns true if valid, false otherwise. Sends 403 response on failure. + */ +export function validateCsrfFromBody(req, res) { + const token = req.body?._csrf; + const cookieToken = req.cookies?.[CSRF_COOKIE]; + + if (!token || !cookieToken) { + logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method }, 'CSRF token missing (multipart)'); + res.status(403).send('CSRF token missing. Please reload the page.'); + return false; + } + + const provided = Buffer.from(token, 'utf8'); + const expected = Buffer.from(cookieToken, 'utf8'); + if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) { + logger.warn({ ip: req.ip, url: req.originalUrl, method: req.method }, 'CSRF token mismatch (multipart)'); + res.status(403).send('CSRF token invalid. Please reload the page.'); + return false; + } + + return true; +} diff --git a/src/admin/routes/categories.js b/src/admin/routes/categories.js index 2c6961c..f2e465d 100644 --- a/src/admin/routes/categories.js +++ b/src/admin/routes/categories.js @@ -1,9 +1,10 @@ import { Router } from 'express'; import db from '../../config/database.js'; +import { asyncHandler } from '../errorHandler.js'; const router = Router(); -router.get('/', async (req, res) => { +router.get('/', asyncHandler(async (req, res) => { const [categories, locations, subcategories] = await Promise.all([ db.allAsync(`SELECT c.*, l.country, l.city, l.district, (SELECT COUNT(*) FROM products WHERE category_id = c.id) as product_count @@ -21,38 +22,46 @@ router.get('/', async (req, res) => { } res.render('categories', { title: 'Categories', categories, locations, subcategories, subcategoriesByCategory }); -}); +})); -router.post('/', async (req, res) => { +router.post('/', asyncHandler(async (req, res) => { const { name, location_id } = req.body; await db.runAsync('INSERT INTO categories (name, location_id) VALUES (?, ?)', [name, location_id || null]); res.redirect('/categories'); -}); +})); -router.post('/:id/update', async (req, res) => { +router.post('/:id/update', asyncHandler(async (req, res) => { const { name, location_id } = req.body; await db.runAsync('UPDATE categories SET name = ?, location_id = ? WHERE id = ?', [name, location_id || null, req.params.id]); res.redirect('/categories'); -}); +})); -router.post('/:id/delete', async (req, res) => { +router.post('/:id/delete', asyncHandler(async (req, res) => { const count = await db.getAsync('SELECT COUNT(*) as cnt FROM products WHERE category_id = ?', [req.params.id]); if (count && count.cnt > 0) { return res.redirect('/categories?error=Cannot+delete+category+with+products'); } + const subCount = await db.getAsync( + 'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id IN (SELECT id FROM subcategories WHERE category_id = ?)', + [req.params.id] + ); + if (subCount && subCount.cnt > 0) { + return res.redirect('/categories?error=Cannot+delete+category+with+products+in+subcategories'); + } + await db.runAsync('DELETE FROM subcategories WHERE category_id = ?', [req.params.id]); await db.runAsync('DELETE FROM categories WHERE id = ?', [req.params.id]); res.redirect('/categories'); -}); +})); -router.post('/:id/subcategories', async (req, res) => { +router.post('/:id/subcategories', asyncHandler(async (req, res) => { const { name } = req.body; await db.runAsync('INSERT INTO subcategories (category_id, name) VALUES (?, ?)', [req.params.id, name]); res.redirect('/categories'); -}); +})); -router.post('/subcategories/:id/delete', async (req, res) => { +router.post('/subcategories/:id/delete', asyncHandler(async (req, res) => { const count = await db.getAsync( 'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id = ?', [req.params.id] @@ -62,6 +71,6 @@ router.post('/subcategories/:id/delete', async (req, res) => { } await db.runAsync('DELETE FROM subcategories WHERE id = ?', [req.params.id]); res.redirect('/categories'); -}); +})); export default router; diff --git a/src/admin/routes/locations.js b/src/admin/routes/locations.js index 981891c..b3f50e4 100644 --- a/src/admin/routes/locations.js +++ b/src/admin/routes/locations.js @@ -1,26 +1,27 @@ import { Router } from 'express'; import db from '../../config/database.js'; +import { asyncHandler } from '../errorHandler.js'; const router = Router(); -router.get('/', async (req, res) => { +router.get('/', asyncHandler(async (req, res) => { const locations = await db.allAsync(`SELECT l.*, (SELECT COUNT(*) FROM categories WHERE location_id = l.id) as category_count, (SELECT COUNT(*) FROM products WHERE location_id = l.id) as product_count FROM locations l ORDER BY l.country, l.city, l.district`); res.render('locations', { title: 'Locations', locations }); -}); +})); -router.post('/', async (req, res) => { +router.post('/', asyncHandler(async (req, res) => { const { country, city, district } = req.body; await db.runAsync( 'INSERT INTO locations (country, city, district) VALUES (?, ?, ?)', [country, city, district || ''] ); res.redirect('/locations'); -}); +})); -router.post('/:id/delete', async (req, res) => { +router.post('/:id/delete', asyncHandler(async (req, res) => { const count = await db.getAsync( 'SELECT COUNT(*) as cnt FROM categories WHERE location_id = ?', [req.params.id] @@ -28,8 +29,15 @@ router.post('/:id/delete', async (req, res) => { if (count && count.cnt > 0) { return res.redirect('/locations?error=Cannot+delete+location+with+categories'); } + const productCount = await db.getAsync( + 'SELECT COUNT(*) as cnt FROM products WHERE location_id = ?', + [req.params.id] + ); + if (productCount && productCount.cnt > 0) { + return res.redirect('/locations?error=Cannot+delete+location+with+products'); + } await db.runAsync('DELETE FROM locations WHERE id = ?', [req.params.id]); res.redirect('/locations'); -}); +})); export default router; diff --git a/src/admin/server.js b/src/admin/server.js index c89239c..ecf71b2 100644 --- a/src/admin/server.js +++ b/src/admin/server.js @@ -5,7 +5,7 @@ import { dirname, join } from 'path'; import ejsLayouts from 'express-ejs-layouts'; import logger from '../utils/logger.js'; import { requireAuth, handleLogin, handleLogout } from './auth.js'; -import { csrfMiddleware, validateCsrf } from './csrf.js'; +import { csrfMiddleware, validateCsrf, validateCsrfFromBody } from './csrf.js'; import { asyncHandler, globalErrorHandler } from './errorHandler.js'; import dashboardRouter from './routes/dashboard.js'; import catalogRouter from './routes/catalog.js'; @@ -33,6 +33,7 @@ app.set('layout', 'layout'); app.use(cookieParser()); app.use(express.urlencoded({ extended: true })); +app.use(express.json()); // Serve static assets from public directory app.use(express.static(join(__dirname, 'public'))); diff --git a/src/admin/views/categories.ejs b/src/admin/views/categories.ejs index 3754048..01a678d 100644 --- a/src/admin/views/categories.ejs +++ b/src/admin/views/categories.ejs @@ -8,6 +8,7 @@ Add Category
+
@@ -56,6 +59,7 @@ <%= s.product_count || 0 %>
+
@@ -65,6 +69,7 @@
+
diff --git a/src/admin/views/locations.ejs b/src/admin/views/locations.ejs index 8ae017b..4c46ec5 100644 --- a/src/admin/views/locations.ejs +++ b/src/admin/views/locations.ejs @@ -7,6 +7,7 @@
+
@@ -44,6 +45,7 @@ <%= l.product_count || 0 %> +