Files
check-printing/src/routes/deposits.js
T
steve 444e24a191 Fix remaining critical, medium, and low security issues
IDOR (critical): GET /api/checks/:id and GET /api/deposits/:id now
verify the requesting user has access to the record's account before
returning data. Previously any authenticated user could fetch any
record by ID across accounts.

Printed check guard (critical): PUT and DELETE on checks now return
409 if the check has already been printed, enforcing the business rule
that printed checks are immutable. Previously the printed flag was
only enforced in the frontend.

PDF DoS (medium): checkIds array capped at 300 (100 pages × 3 per page).

QBO import DoS (medium): records array capped at 1000 per confirm call.

PDF error detail (medium): internal err.message no longer returned to
the client on PDF generation failure.

SESSION_SECRET (low): removed NODE_ENV=production condition — the
server now exits immediately on startup if SESSION_SECRET is unset
regardless of environment. Dev script updated to load .env via
node --env-file=.env so developers set it once in a local .env file.

Password hints (low): updated all three UI labels from "min 8 chars"
to "min 10 chars, include a digit or symbol" to match the actual
server-side validation.
2026-03-20 13:28:18 -06:00

179 lines
5.9 KiB
JavaScript

'use strict';
const express = require('express');
const router = express.Router();
const db = require('../db/database');
const { canAccessAccount, isEditorForAccount } = require('../middleware/auth');
// Helper: fetch deposit with items
function getDepositWithItems(id) {
const deposit = db.prepare('SELECT * FROM deposits WHERE id = ?').get(id);
if (!deposit) return null;
deposit.items = db.prepare(
'SELECT * FROM deposit_items WHERE deposit_id = ? ORDER BY sort_order ASC, id ASC'
).all(id);
return deposit;
}
// GET /api/deposits?account_id=X
router.get('/', (req, res) => {
const { account_id } = req.query;
if (!account_id) return res.status(400).json({ error: 'account_id is required.' });
if (!canAccessAccount(req.session, parseInt(account_id, 10))) return res.status(403).json({ error: 'Access denied.' });
const deposits = db.prepare(`
SELECT d.*, COUNT(di.id) AS item_count,
COALESCE(SUM(di.amount), 0) AS checks_total
FROM deposits d
LEFT JOIN deposit_items di ON di.deposit_id = d.id
WHERE d.account_id = ?
GROUP BY d.id
ORDER BY d.deposit_date DESC, d.id DESC
`).all(account_id);
res.json(deposits);
});
// GET /api/deposits/:id
router.get('/:id', (req, res) => {
const deposit = getDepositWithItems(req.params.id);
if (!deposit) return res.status(404).json({ error: 'Deposit not found.' });
if (!canAccessAccount(req.session, deposit.account_id)) {
return res.status(403).json({ error: 'Access denied.' });
}
res.json(deposit);
});
// POST /api/deposits
router.post('/', (req, res) => {
const { account_id, deposit_date, currency, coin, cash_back, items } = req.body;
if (!account_id) return res.status(400).json({ error: 'account_id is required.' });
if (!deposit_date) return res.status(400).json({ error: 'deposit_date is required.' });
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(`
INSERT INTO deposits (account_id, deposit_date, currency, coin, cash_back)
VALUES (?, ?, ?, ?, ?)
`).run(
account_id,
deposit_date,
parseFloat(currency) || 0,
parseFloat(coin) || 0,
parseFloat(cash_back) || 0,
);
const depositId = result.lastInsertRowid;
if (Array.isArray(items)) {
const stmt = db.prepare(`
INSERT INTO deposit_items (deposit_id, sort_order, check_no, bank_no, payee, memo, amount)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
items.forEach((item, i) => {
stmt.run(
depositId, i,
item.check_no || null,
item.bank_no || null,
item.payee || null,
item.memo || null,
parseFloat(item.amount),
);
});
}
return depositId;
});
const depositId = insert();
res.status(201).json(getDepositWithItems(depositId));
});
// PUT /api/deposits/:id
router.put('/:id', (req, res) => {
const existing = db.prepare('SELECT id, account_id FROM deposits WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Deposit not found.' });
if (!isEditorForAccount(req.session, existing.account_id)) {
return res.status(403).json({ error: 'Write access required.' });
}
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(`
UPDATE deposits SET deposit_date = ?, currency = ?, coin = ?, cash_back = ?
WHERE id = ?
`).run(
deposit_date,
parseFloat(currency) || 0,
parseFloat(coin) || 0,
parseFloat(cash_back) || 0,
req.params.id,
);
if (Array.isArray(items)) {
db.prepare('DELETE FROM deposit_items WHERE deposit_id = ?').run(req.params.id);
const stmt = db.prepare(`
INSERT INTO deposit_items (deposit_id, sort_order, check_no, bank_no, payee, memo, amount)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
items.forEach((item, i) => {
stmt.run(
req.params.id, i,
item.check_no || null,
item.bank_no || null,
item.payee || null,
item.memo || null,
parseFloat(item.amount),
);
});
}
});
update();
res.json(getDepositWithItems(req.params.id));
});
// DELETE /api/deposits/:id
router.delete('/:id', (req, res) => {
const existing = db.prepare('SELECT id, account_id FROM deposits WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Deposit not found.' });
if (!isEditorForAccount(req.session, existing.account_id)) {
return res.status(403).json({ error: 'Write access required.' });
}
// deposit_items deleted via ON DELETE CASCADE
db.prepare('DELETE FROM deposits WHERE id = ?').run(req.params.id);
res.status(204).end();
});
// PATCH /api/deposits/:id/mark-printed
router.patch('/:id/mark-printed', (req, res) => {
const existing = db.prepare('SELECT account_id FROM deposits WHERE id = ?').get(req.params.id);
if (!existing || !isEditorForAccount(req.session, existing.account_id)) {
return res.status(403).json({ error: 'Write access required.' });
}
db.prepare('UPDATE deposits SET printed = 1 WHERE id = ?').run(req.params.id);
res.json({ ok: true });
});
module.exports = router;