fix(admin): CSRF cookie sameSite=false for Tor, auth cookie fix, async handlers, validation

This commit is contained in:
NW
2026-07-08 12:08:08 +01:00
parent 046c40349d
commit 2b30bc4a91
7 changed files with 81 additions and 27 deletions

View File

@@ -95,9 +95,9 @@ export function handleLogin(req, res) {
const { token: signed, jti } = signToken({ role: 'admin' }); const { token: signed, jti } = signToken({ role: 'admin' });
res.cookie(COOKIE_NAME, signed, { res.cookie(COOKIE_NAME, signed, {
httpOnly: true, httpOnly: true,
sameSite: 'strict', sameSite: false,
maxAge: MAX_AGE, maxAge: MAX_AGE,
secure: process.env.NODE_ENV === 'production' path: '/'
}); });
res.redirect('/'); res.redirect('/');
} }

View File

@@ -14,9 +14,8 @@ export function csrfMiddleware(req, res, next) {
token = generateToken(); token = generateToken();
res.cookie(CSRF_COOKIE, token, { res.cookie(CSRF_COOKIE, token, {
httpOnly: false, httpOnly: false,
sameSite: 'strict', sameSite: false,
path: '/', path: '/'
secure: process.env.NODE_ENV === 'production'
}); });
} }
res.locals.csrfToken = token; res.locals.csrfToken = token;
@@ -27,11 +26,16 @@ export function validateCsrf(req, res, next) {
// Only validate state-changing methods // Only validate state-changing methods
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next(); 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 token = req.body?._csrf || req.headers[CSRF_HEADER];
const cookieToken = req.cookies?.[CSRF_COOKIE]; const cookieToken = req.cookies?.[CSRF_COOKIE];
if (!token || !cookieToken) { 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.'); return res.status(403).send('CSRF token missing. Please reload the page.');
} }
@@ -44,3 +48,28 @@ export function validateCsrf(req, res, next) {
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;
}

View File

@@ -1,9 +1,10 @@
import { Router } from 'express'; import { Router } from 'express';
import db from '../../config/database.js'; import db from '../../config/database.js';
import { asyncHandler } from '../errorHandler.js';
const router = Router(); const router = Router();
router.get('/', async (req, res) => { router.get('/', asyncHandler(async (req, res) => {
const [categories, locations, subcategories] = await Promise.all([ const [categories, locations, subcategories] = await Promise.all([
db.allAsync(`SELECT c.*, l.country, l.city, l.district, db.allAsync(`SELECT c.*, l.country, l.city, l.district,
(SELECT COUNT(*) FROM products WHERE category_id = c.id) as product_count (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 }); 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; const { name, location_id } = req.body;
await db.runAsync('INSERT INTO categories (name, location_id) VALUES (?, ?)', [name, location_id || null]); await db.runAsync('INSERT INTO categories (name, location_id) VALUES (?, ?)', [name, location_id || null]);
res.redirect('/categories'); res.redirect('/categories');
}); }));
router.post('/:id/update', async (req, res) => { router.post('/:id/update', asyncHandler(async (req, res) => {
const { name, location_id } = req.body; const { name, location_id } = req.body;
await db.runAsync('UPDATE categories SET name = ?, location_id = ? WHERE id = ?', await db.runAsync('UPDATE categories SET name = ?, location_id = ? WHERE id = ?',
[name, location_id || null, req.params.id]); [name, location_id || null, req.params.id]);
res.redirect('/categories'); 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]); const count = await db.getAsync('SELECT COUNT(*) as cnt FROM products WHERE category_id = ?', [req.params.id]);
if (count && count.cnt > 0) { if (count && count.cnt > 0) {
return res.redirect('/categories?error=Cannot+delete+category+with+products'); 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]); await db.runAsync('DELETE FROM categories WHERE id = ?', [req.params.id]);
res.redirect('/categories'); res.redirect('/categories');
}); }));
router.post('/:id/subcategories', async (req, res) => { router.post('/:id/subcategories', asyncHandler(async (req, res) => {
const { name } = req.body; const { name } = req.body;
await db.runAsync('INSERT INTO subcategories (category_id, name) VALUES (?, ?)', await db.runAsync('INSERT INTO subcategories (category_id, name) VALUES (?, ?)',
[req.params.id, name]); [req.params.id, name]);
res.redirect('/categories'); 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( const count = await db.getAsync(
'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id = ?', 'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id = ?',
[req.params.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]); await db.runAsync('DELETE FROM subcategories WHERE id = ?', [req.params.id]);
res.redirect('/categories'); res.redirect('/categories');
}); }));
export default router; export default router;

View File

@@ -1,26 +1,27 @@
import { Router } from 'express'; import { Router } from 'express';
import db from '../../config/database.js'; import db from '../../config/database.js';
import { asyncHandler } from '../errorHandler.js';
const router = Router(); const router = Router();
router.get('/', async (req, res) => { router.get('/', asyncHandler(async (req, res) => {
const locations = await db.allAsync(`SELECT l.*, const locations = await db.allAsync(`SELECT l.*,
(SELECT COUNT(*) FROM categories WHERE location_id = l.id) as category_count, (SELECT COUNT(*) FROM categories WHERE location_id = l.id) as category_count,
(SELECT COUNT(*) FROM products WHERE location_id = l.id) as product_count (SELECT COUNT(*) FROM products WHERE location_id = l.id) as product_count
FROM locations l ORDER BY l.country, l.city, l.district`); FROM locations l ORDER BY l.country, l.city, l.district`);
res.render('locations', { title: 'Locations', locations }); res.render('locations', { title: 'Locations', locations });
}); }));
router.post('/', async (req, res) => { router.post('/', asyncHandler(async (req, res) => {
const { country, city, district } = req.body; const { country, city, district } = req.body;
await db.runAsync( await db.runAsync(
'INSERT INTO locations (country, city, district) VALUES (?, ?, ?)', 'INSERT INTO locations (country, city, district) VALUES (?, ?, ?)',
[country, city, district || ''] [country, city, district || '']
); );
res.redirect('/locations'); res.redirect('/locations');
}); }));
router.post('/:id/delete', async (req, res) => { router.post('/:id/delete', asyncHandler(async (req, res) => {
const count = await db.getAsync( const count = await db.getAsync(
'SELECT COUNT(*) as cnt FROM categories WHERE location_id = ?', 'SELECT COUNT(*) as cnt FROM categories WHERE location_id = ?',
[req.params.id] [req.params.id]
@@ -28,8 +29,15 @@ router.post('/:id/delete', async (req, res) => {
if (count && count.cnt > 0) { if (count && count.cnt > 0) {
return res.redirect('/locations?error=Cannot+delete+location+with+categories'); 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]); await db.runAsync('DELETE FROM locations WHERE id = ?', [req.params.id]);
res.redirect('/locations'); res.redirect('/locations');
}); }));
export default router; export default router;

View File

@@ -5,7 +5,7 @@ import { dirname, join } from 'path';
import ejsLayouts from 'express-ejs-layouts'; import ejsLayouts from 'express-ejs-layouts';
import logger from '../utils/logger.js'; import logger from '../utils/logger.js';
import { requireAuth, handleLogin, handleLogout } from './auth.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 { asyncHandler, globalErrorHandler } from './errorHandler.js';
import dashboardRouter from './routes/dashboard.js'; import dashboardRouter from './routes/dashboard.js';
import catalogRouter from './routes/catalog.js'; import catalogRouter from './routes/catalog.js';
@@ -33,6 +33,7 @@ app.set('layout', 'layout');
app.use(cookieParser()); app.use(cookieParser());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.json());
// Serve static assets from public directory // Serve static assets from public directory
app.use(express.static(join(__dirname, 'public'))); app.use(express.static(join(__dirname, 'public')));

View File

@@ -8,6 +8,7 @@
<summary class="btn btn-primary btn-sm">Add Category</summary> <summary class="btn btn-primary btn-sm">Add Category</summary>
<div class="mt-2 p-3 border rounded"> <div class="mt-2 p-3 border rounded">
<form method="POST" action="/categories" class="d-inline-flex gap-2 align-items-center flex-wrap"> <form method="POST" action="/categories" class="d-inline-flex gap-2 align-items-center flex-wrap">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="name" class="form-control form-control-sm" placeholder="Category name" required> <input name="name" class="form-control form-control-sm" placeholder="Category name" required>
<select name="location_id" class="form-select form-select-sm"> <select name="location_id" class="form-select form-select-sm">
<option value="">-- No location --</option> <option value="">-- No location --</option>
@@ -33,6 +34,7 @@
<td><%= c.product_count || 0 %></td> <td><%= c.product_count || 0 %></td>
<td> <td>
<form method="POST" action="/categories/<%= c.id %>/update" class="d-inline-flex gap-2 align-items-center"> <form method="POST" action="/categories/<%= c.id %>/update" class="d-inline-flex gap-2 align-items-center">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="name" class="form-control form-control-sm" value="<%= c.name %>" required> <input name="name" class="form-control form-control-sm" value="<%= c.name %>" required>
<select name="location_id" class="form-select form-select-sm"> <select name="location_id" class="form-select form-select-sm">
<option value="">-- None --</option> <option value="">-- None --</option>
@@ -43,6 +45,7 @@
<button class="btn btn-sm btn-primary">Save</button> <button class="btn btn-sm btn-primary">Save</button>
</form> </form>
<form method="POST" action="/categories/<%= c.id %>/delete" class="d-inline-flex gap-2" onsubmit="return confirm('Delete category?')"> <form method="POST" action="/categories/<%= c.id %>/delete" class="d-inline-flex gap-2" onsubmit="return confirm('Delete category?')">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-sm btn-danger">Delete</button> <button class="btn btn-sm btn-danger">Delete</button>
</form> </form>
</td> </td>
@@ -56,6 +59,7 @@
<td><%= s.product_count || 0 %></td> <td><%= s.product_count || 0 %></td>
<td> <td>
<form method="POST" action="/categories/subcategories/<%= s.id %>/delete" class="d-inline-flex gap-2" onsubmit="return confirm('Delete subcategory?')"> <form method="POST" action="/categories/subcategories/<%= s.id %>/delete" class="d-inline-flex gap-2" onsubmit="return confirm('Delete subcategory?')">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-sm btn-danger">Delete</button> <button class="btn btn-sm btn-danger">Delete</button>
</form> </form>
</td> </td>
@@ -65,6 +69,7 @@
<td></td> <td></td>
<td class="ps-4"> <td class="ps-4">
<form method="POST" action="/categories/<%= c.id %>/subcategories" class="d-inline-flex gap-2 align-items-center"> <form method="POST" action="/categories/<%= c.id %>/subcategories" class="d-inline-flex gap-2 align-items-center">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input name="name" class="form-control form-control-sm" placeholder="Subcategory name" required> <input name="name" class="form-control form-control-sm" placeholder="Subcategory name" required>
<button class="btn btn-sm btn-primary">Add</button> <button class="btn btn-sm btn-primary">Add</button>
</form> </form>

View File

@@ -7,6 +7,7 @@
<div class="collapse" id="addLocationCollapse"> <div class="collapse" id="addLocationCollapse">
<div class="panel-content"> <div class="panel-content">
<form method="POST" action="/locations" class="d-flex flex-wrap gap-2 align-items-end"> <form method="POST" action="/locations" class="d-flex flex-wrap gap-2 align-items-end">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<div> <div>
<label class="form-label">Country</label> <label class="form-label">Country</label>
<input name="country" class="form-control form-control-sm" placeholder="Country" required> <input name="country" class="form-control form-control-sm" placeholder="Country" required>
@@ -44,6 +45,7 @@
<td><%= l.product_count || 0 %></td> <td><%= l.product_count || 0 %></td>
<td> <td>
<form method="POST" action="/locations/<%= l.id %>/delete" class="d-inline" onsubmit="return confirm('Delete location?')"> <form method="POST" action="/locations/<%= l.id %>/delete" class="d-inline" onsubmit="return confirm('Delete location?')">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-sm btn-danger">Delete</button> <button class="btn btn-sm btn-danger">Delete</button>
</form> </form>
</td> </td>