security: rename LIVE constant to SQL_NOT_DELETED with injection safety documentation

This commit is contained in:
null 2026-06-03 22:28:46 -05:00
parent ff7ae8b3ab
commit b81b41d302
1 changed files with 12 additions and 8 deletions

View File

@ -6,7 +6,11 @@ const { computeBalanceDelta } = require('../services/billsService');
const { validatePaymentInput } = require('../services/paymentValidation');
const { getCycleRange, resolveDueDate } = require('../services/statusService');
const LIVE = 'deleted_at IS NULL'; // filter for non-deleted payments
// SQL_NOT_DELETED is a compile-time constant SQL fragment, never user-supplied.
// It cannot be a bind parameter (SQL fragments are not parameterisable — only
// values are). Interpolating a hardcoded constant like this is safe by
// construction; do NOT replace this pattern with dynamic/user-controlled input.
const SQL_NOT_DELETED = 'deleted_at IS NULL';
const TRANSACTION_MATCH_SOURCE = 'transaction_match';
function isTransactionLinkedPayment(payment) {
@ -83,7 +87,7 @@ router.get('/', (req, res) => {
}
}
let query = `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`;
let query = `SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`;
const params = [req.user.id];
if (bill_id) { query += ' AND p.bill_id = ?'; params.push(parseInt(bill_id, 10)); }
@ -104,7 +108,7 @@ router.get('/', (req, res) => {
// GET /api/payments/:id
router.get('/:id', (req, res) => {
const db = getDb();
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
res.json(payment);
});
@ -319,7 +323,7 @@ router.post('/bulk', (req, res) => {
AND p.bill_id = ?
AND p.paid_date = ?
AND p.amount = ?
AND p.${LIVE}`
AND p.${SQL_NOT_DELETED}`
);
const created = [];
@ -360,7 +364,7 @@ router.post('/bulk', (req, res) => {
// PUT /api/payments/:id
router.put('/:id', (req, res) => {
const db = getDb();
const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
const existing = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!existing) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(existing)) return rejectTransactionLinkedPayment(res);
@ -413,13 +417,13 @@ router.put('/:id', (req, res) => {
req.user.id,
);
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
});
// DELETE /api/payments/:id — soft delete (sets deleted_at)
router.delete('/:id', (req, res) => {
const db = getDb();
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
const payment = db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id);
if (!payment) return res.status(404).json(standardizeError('Payment not found', 'NOT_FOUND', 'id'));
if (isTransactionLinkedPayment(payment)) return rejectTransactionLinkedPayment(res);
@ -453,7 +457,7 @@ router.post('/:id/restore', (req, res) => {
}
db.prepare('UPDATE payments SET deleted_at = NULL WHERE id = ? AND bill_id IN (SELECT id FROM bills WHERE user_id = ? AND deleted_at IS NULL)').run(req.params.id, req.user.id);
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${LIVE} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
res.json(db.prepare(`SELECT p.* FROM payments p JOIN bills b ON b.id = p.bill_id WHERE p.id = ? AND p.${SQL_NOT_DELETED} AND b.user_id = ? AND b.deleted_at IS NULL`).get(req.params.id, req.user.id));
});
module.exports = router;