fix(admin): CSRF cookie sameSite=false for Tor, auth cookie fix, async handlers, validation
This commit is contained in:
@@ -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('/');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user