Fix high and medium security vulnerabilities

CSRF: upgrade session cookie sameSite from 'lax' to 'strict'.

Rate limiting: login endpoint now blocks an IP after 10 failed attempts
in a 15-minute window; resets on success. In-memory, no new dependency.

SESSION_SECRET: server exits at startup when NODE_ENV=production and
SESSION_SECRET is unset. docker-compose.yml updated to pass it via env;
.env.example added with generation instructions.

Security headers: add X-Content-Type-Options, X-Frame-Options, and
Referrer-Policy to all responses.

Sensitive data: routing_number and account_number are now omitted from
GET /api/account/:id responses for non-admin users.

Image size: logo upload capped at 512 KB in the account PUT handler.

Amount validation: checks (POST/PUT) and deposit items (POST/PUT) now
reject non-finite and non-positive amounts.

QBO import: uploaded file is rejected if its MIME type is not text or
a known CSV variant.
This commit is contained in:
2026-03-20 08:21:23 -06:00
parent 0f00624e61
commit 2939bfa608
8 changed files with 127 additions and 13 deletions
+54 -2
View File
@@ -5,6 +5,45 @@ const router = express.Router();
const bcrypt = require('bcryptjs');
const db = require('../db/database');
// ── Login rate limiter ────────────────────────────────────────────────────────
// Tracks failed login attempts per IP. After 10 failures within 15 minutes,
// further attempts are blocked until the window resets.
const loginAttempts = new Map(); // ip -> { count, resetAt }
const RATE_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
const RATE_MAX_FAILS = 10;
function checkLoginRate(ip) {
const now = Date.now();
const entry = loginAttempts.get(ip);
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 0, resetAt: now + RATE_WINDOW_MS });
return true; // allow
}
return entry.count < RATE_MAX_FAILS;
}
function recordLoginFailure(ip) {
const now = Date.now();
const entry = loginAttempts.get(ip);
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
} else {
entry.count++;
}
}
function clearLoginFailures(ip) {
loginAttempts.delete(ip);
}
// Purge stale entries every 30 minutes to prevent unbounded memory growth
setInterval(() => {
const now = Date.now();
for (const [ip, entry] of loginAttempts) {
if (now > entry.resetAt) loginAttempts.delete(ip);
}
}, 30 * 60 * 1000).unref();
// GET /api/auth/setup-needed — true when no users exist (first-run)
router.get('/setup-needed', (req, res) => {
const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get();
@@ -34,15 +73,28 @@ router.post('/setup', async (req, res) => {
// POST /api/auth/login
router.post('/login', async (req, res) => {
const ip = req.ip || req.socket.remoteAddress || 'unknown';
if (!checkLoginRate(ip)) {
return res.status(429).json({ error: 'Too many failed login attempts. Please try again later.' });
}
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Username and password required.' });
const user = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(username.trim());
if (!user) return res.status(401).json({ error: 'Invalid username or password.' });
if (!user) {
recordLoginFailure(ip);
return res.status(401).json({ error: 'Invalid username or password.' });
}
const match = await bcrypt.compare(password, user.password_hash);
if (!match) return res.status(401).json({ error: 'Invalid username or password.' });
if (!match) {
recordLoginFailure(ip);
return res.status(401).json({ error: 'Invalid username or password.' });
}
clearLoginFailures(ip);
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = user.role;
+14 -2
View File
@@ -54,6 +54,10 @@ router.post('/', (req, res) => {
if (!account_id || !payee || !amount || !check_date) {
return res.status(400).json({ error: 'account_id, payee, amount, and check_date are required' });
}
const parsedAmount = parseFloat(amount);
if (!isFinite(parsedAmount) || parsedAmount <= 0) {
return res.status(400).json({ error: 'Amount must be a positive number.' });
}
if (!isEditorForAccount(req.session, parseInt(account_id, 10))) {
return res.status(403).json({ error: 'Write access required.' });
}
@@ -75,7 +79,7 @@ router.post('/', (req, res) => {
const transaction = db.transaction(() => {
const result = insertCheck.run(
account_id, checkNo, payee, parseFloat(amount), check_date,
account_id, checkNo, payee, parsedAmount, check_date,
memo || null, note1 || null, note2 || null,
payee_address1 || null, payee_address2 || null,
payee_address3 || null, payee_address4 || null
@@ -99,6 +103,14 @@ router.put('/:id', (req, res) => {
const { payee, amount, check_date, memo, note1, note2,
payee_address1, payee_address2, payee_address3, payee_address4 } = req.body;
let parsedAmount = check.amount;
if (amount !== undefined) {
parsedAmount = parseFloat(amount);
if (!isFinite(parsedAmount) || parsedAmount <= 0) {
return res.status(400).json({ error: 'Amount must be a positive number.' });
}
}
db.prepare(`
UPDATE checks SET
payee = ?, amount = ?, check_date = ?, memo = ?, note1 = ?, note2 = ?,
@@ -106,7 +118,7 @@ router.put('/:id', (req, res) => {
WHERE id = ?
`).run(
payee ?? check.payee,
amount !== undefined ? parseFloat(amount) : check.amount,
parsedAmount,
check_date ?? check.check_date,
memo ?? check.memo,
note1 ?? check.note1,
+18 -2
View File
@@ -49,6 +49,14 @@ router.post('/', (req, res) => {
if (!isEditorForAccount(req.session, parseInt(account_id, 10))) {
return res.status(403).json({ error: 'Write access required.' });
}
if (Array.isArray(items)) {
for (const item of items) {
const a = parseFloat(item.amount);
if (!isFinite(a) || a <= 0) {
return res.status(400).json({ error: 'Each deposit item amount must be a positive number.' });
}
}
}
const insert = db.transaction(() => {
const result = db.prepare(`
@@ -76,7 +84,7 @@ router.post('/', (req, res) => {
item.bank_no || null,
item.payee || null,
item.memo || null,
parseFloat(item.amount) || 0,
parseFloat(item.amount),
);
});
}
@@ -98,6 +106,14 @@ router.put('/:id', (req, res) => {
const { deposit_date, currency, coin, cash_back, items } = req.body;
if (!deposit_date) return res.status(400).json({ error: 'deposit_date is required.' });
if (Array.isArray(items)) {
for (const item of items) {
const a = parseFloat(item.amount);
if (!isFinite(a) || a <= 0) {
return res.status(400).json({ error: 'Each deposit item amount must be a positive number.' });
}
}
}
const update = db.transaction(() => {
db.prepare(`
@@ -124,7 +140,7 @@ router.put('/:id', (req, res) => {
item.bank_no || null,
item.payee || null,
item.memo || null,
parseFloat(item.amount) || 0,
parseFloat(item.amount),
);
});
}
+8
View File
@@ -262,12 +262,20 @@ function confirmDeposits(db, records, account_id) {
// POST /api/qbo-import/parse
router.post('/parse', upload.single('file'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded.' });
const type = req.body.type;
if (type !== 'checks' && type !== 'deposits') {
fs.unlink(req.file.path, () => {});
return res.status(400).json({ error: 'Invalid type. Must be "checks" or "deposits".' });
}
// Reject non-text MIME types — only CSV/plain text is expected
const mime = (req.file.mimetype || '').toLowerCase();
if (!mime.startsWith('text/') && mime !== 'application/csv' && mime !== 'application/vnd.ms-excel') {
fs.unlink(req.file.path, () => {});
return res.status(400).json({ error: 'File must be a CSV text file.' });
}
let text;
try {
text = fs.readFileSync(req.file.path, 'utf8');