fix(api): close authz gap and tighten input validation
- Require account access on POST /api/pdf/preview: any authenticated user could previously render any account's MICR line (routing and account number) regardless of role - Re-validate QBO import records server-side on confirm (date format, positive amounts, integer check numbers) instead of trusting client JSON - Make PUT /api/users/:id atomic: validate all fields up front, then apply every change in a single transaction (a validation failure could previously leave a half-applied update) - Block demoting the last remaining admin - Validate routing numbers as exactly 9 digits on account setup and update - Cap upload sizes (50 MB .mdb, 10 MB CSV) and add a JSON error handler so oversized uploads and bad JSON return clean 4xx errors instead of HTML stack traces - Disable the X-Powered-By header
This commit is contained in:
+34
-4
@@ -4,7 +4,6 @@ const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { execFileSync } = require('child_process');
|
||||
const multer = require('multer');
|
||||
const session = require('express-session');
|
||||
@@ -14,7 +13,14 @@ const { seedLayoutFields } = require('./db/database');
|
||||
const { requireAuth, requireAdmin, canAccessAccount, isEditorForAccount } = require('./middleware/auth');
|
||||
|
||||
const app = express();
|
||||
const upload = multer({ dest: os.tmpdir() });
|
||||
app.disable('x-powered-by');
|
||||
const upload = multer({ dest: os.tmpdir(), limits: { fileSize: 50 * 1024 * 1024 } });
|
||||
|
||||
// US ABA routing numbers are exactly 9 digits (spaces/dashes tolerated on input)
|
||||
function normalizeRoutingNumber(value) {
|
||||
const digits = String(value || '').replace(/[\s-]/g, '');
|
||||
return /^\d{9}$/.test(digits) ? digits : null;
|
||||
}
|
||||
|
||||
// ── Session store (SQLite-backed, no extra packages) ──────────────────────────
|
||||
const SessionStore = require('./lib/SessionStore');
|
||||
@@ -120,6 +126,10 @@ app.put('/api/account/:id', requireAdmin, (req, res) => {
|
||||
if (!company1 || !routing_number || !account_number) {
|
||||
return res.status(400).json({ error: 'Organization name, routing number, and account number are required.' });
|
||||
}
|
||||
const normalizedRouting = normalizeRoutingNumber(routing_number);
|
||||
if (!normalizedRouting) {
|
||||
return res.status(400).json({ error: 'Routing number must be exactly 9 digits.' });
|
||||
}
|
||||
const MAX_IMAGE_BYTES = 512 * 1024; // 512 KB base64 limit
|
||||
if (logo_data && Buffer.byteLength(logo_data, 'utf8') > MAX_IMAGE_BYTES) {
|
||||
return res.status(400).json({ error: 'Logo image must be smaller than 512 KB.' });
|
||||
@@ -141,7 +151,7 @@ app.put('/api/account/:id', requireAdmin, (req, res) => {
|
||||
`).run(
|
||||
company1 || null, company2 || null, company3 || null, company4 || null,
|
||||
bank_name || '', bank_info1 || null, bank_info2 || null, bank_info3 || null, transit_code || null,
|
||||
routing_number, account_number,
|
||||
normalizedRouting, account_number,
|
||||
parseFloat(offset_left) || 0, parseFloat(offset_right) || 0,
|
||||
parseFloat(offset_up) || 0, parseFloat(offset_down) || 0,
|
||||
second_signature ? 1 : 0, resolvedPosition,
|
||||
@@ -214,6 +224,10 @@ app.post('/api/account/setup', requireAdmin, (req, res) => {
|
||||
if (!company1 || !routing_number || !account_number || !start_check_no) {
|
||||
return res.status(400).json({ error: 'Organization name, routing number, account number, and starting check number are required.' });
|
||||
}
|
||||
const normalizedRouting = normalizeRoutingNumber(routing_number);
|
||||
if (!normalizedRouting) {
|
||||
return res.status(400).json({ error: 'Routing number must be exactly 9 digits.' });
|
||||
}
|
||||
const checkNo = parseInt(start_check_no, 10);
|
||||
if (isNaN(checkNo) || checkNo < 1) {
|
||||
return res.status(400).json({ error: 'Starting check number must be a positive integer.' });
|
||||
@@ -234,7 +248,7 @@ app.post('/api/account/setup', requireAdmin, (req, res) => {
|
||||
bank_info1: bank_info1 || null,
|
||||
bank_info2: bank_info2 || null,
|
||||
transit_code: transit_code || null,
|
||||
routing_number,
|
||||
routing_number: normalizedRouting,
|
||||
account_number,
|
||||
start_check_no: checkNo,
|
||||
current_check_no: checkNo,
|
||||
@@ -312,6 +326,22 @@ app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../public/index.html'));
|
||||
});
|
||||
|
||||
// JSON error handler — keeps stack traces out of responses
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ error: 'Uploaded file is too large.' });
|
||||
}
|
||||
if (err.type === 'entity.too.large') {
|
||||
return res.status(413).json({ error: 'Request body is too large.' });
|
||||
}
|
||||
if (err.type === 'entity.parse.failed') {
|
||||
return res.status(400).json({ error: 'Invalid JSON in request body.' });
|
||||
}
|
||||
console.error('[error]', err);
|
||||
res.status(500).json({ error: 'Internal server error.' });
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`ezcheck running on http://localhost:${PORT}`);
|
||||
|
||||
Reference in New Issue
Block a user