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' });
res.cookie(COOKIE_NAME, signed, {
httpOnly: true,
sameSite: 'strict',
sameSite: false,
maxAge: MAX_AGE,
secure: process.env.NODE_ENV === 'production'
path: '/'
});
res.redirect('/');
}

View File

@@ -14,9 +14,8 @@ export function csrfMiddleware(req, res, next) {
token = generateToken();
res.cookie(CSRF_COOKIE, token, {
httpOnly: false,
sameSite: 'strict',
path: '/',
secure: process.env.NODE_ENV === 'production'
sameSite: false,
path: '/'
});
}
res.locals.csrfToken = token;
@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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')));

View File

@@ -8,6 +8,7 @@
<summary class="btn btn-primary btn-sm">Add Category</summary>
<div class="mt-2 p-3 border rounded">
<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>
<select name="location_id" class="form-select form-select-sm">
<option value="">-- No location --</option>
@@ -33,6 +34,7 @@
<td><%= c.product_count || 0 %></td>
<td>
<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>
<select name="location_id" class="form-select form-select-sm">
<option value="">-- None --</option>
@@ -43,6 +45,7 @@
<button class="btn btn-sm btn-primary">Save</button>
</form>
<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>
</form>
</td>
@@ -56,6 +59,7 @@
<td><%= s.product_count || 0 %></td>
<td>
<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>
</form>
</td>
@@ -65,6 +69,7 @@
<td></td>
<td class="ps-4">
<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>
<button class="btn btn-sm btn-primary">Add</button>
</form>

View File

@@ -7,6 +7,7 @@
<div class="collapse" id="addLocationCollapse">
<div class="panel-content">
<form method="POST" action="/locations" class="d-flex flex-wrap gap-2 align-items-end">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<div>
<label class="form-label">Country</label>
<input name="country" class="form-control form-control-sm" placeholder="Country" required>
@@ -44,6 +45,7 @@
<td><%= l.product_count || 0 %></td>
<td>
<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>
</form>
</td>