diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b91232b --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Copy to .env and fill in values before starting in production. +# Generate SESSION_SECRET with: openssl rand -hex 32 + +SESSION_SECRET=replace-with-a-random-64-character-hex-string +PORT=3000 +DB_PATH=/app/data/check-printing.db diff --git a/.gitignore b/.gitignore index f42ad33..2e44ed5 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ web_modules/ # dotenv environment variable files .env .env.* +!.env.example # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/docker-compose.yml b/docker-compose.yml index 0bf4f00..40cb776 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: - NODE_ENV=production - PORT=3000 - DB_PATH=/app/data/check-printing.db + # Required in production — generate with: openssl rand -hex 32 + - SESSION_SECRET=${SESSION_SECRET} volumes: check-printing-data: diff --git a/src/app.js b/src/app.js index 15f036d..c945cd5 100644 --- a/src/app.js +++ b/src/app.js @@ -18,17 +18,29 @@ const upload = multer({ dest: os.tmpdir() }); // ── Session store (SQLite-backed, no extra packages) ────────────────────────── const SessionStore = require('./lib/SessionStore'); +if (!process.env.SESSION_SECRET && process.env.NODE_ENV === 'production') { + console.error('[fatal] SESSION_SECRET environment variable must be set in production. Exiting.'); + process.exit(1); +} const SESSION_SECRET = process.env.SESSION_SECRET || - (() => { console.warn('[warn] SESSION_SECRET not set — using random secret (sessions reset on restart)'); return crypto.randomBytes(32).toString('hex'); })(); + (() => { console.warn('[warn] SESSION_SECRET not set — using random secret (sessions will reset on restart)'); return crypto.randomBytes(32).toString('hex'); })(); app.use(session({ store: new SessionStore(db), secret: SESSION_SECRET, resave: false, saveUninitialized: false, - cookie: { httpOnly: true, sameSite: 'lax', maxAge: 7 * 24 * 60 * 60 * 1000 }, // 7 days + cookie: { httpOnly: true, sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 }, // 7 days })); +// Security headers +app.use((req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('Referrer-Policy', 'same-origin'); + next(); +}); + app.use(express.json({ limit: '10mb' })); app.use(express.static(path.join(__dirname, '../public'))); @@ -91,6 +103,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 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.' }); + } db.prepare(` UPDATE account SET @@ -121,15 +137,16 @@ app.put('/api/account/:id', requireAdmin, (req, res) => { }); // GET /api/account/:id — any authenticated user with access +// Routing/account numbers are only returned to admins (non-admins don't need them client-side) app.get('/api/account/:id', (req, res) => { if (!canAccessAccount(req.session, parseInt(req.params.id, 10))) { return res.status(403).json({ error: 'Access denied.' }); } - const account = db.prepare( - 'SELECT id, bank_name, bank_info1, bank_info2, bank_info3, transit_code, ' + - 'routing_number, account_number, current_check_no, ' + - 'company1, company2, company3, company4, check_position, second_signature FROM account WHERE id = ?' - ).get(req.params.id); + const isAdmin = req.session.role === 'admin'; + const cols = isAdmin + ? 'id, bank_name, bank_info1, bank_info2, bank_info3, transit_code, routing_number, account_number, current_check_no, company1, company2, company3, company4, check_position, second_signature' + : 'id, bank_name, bank_info1, bank_info2, bank_info3, transit_code, current_check_no, company1, company2, company3, company4, check_position, second_signature'; + const account = db.prepare(`SELECT ${cols} FROM account WHERE id = ?`).get(req.params.id); if (!account) return res.status(404).json({ error: 'Account not found.' }); res.json(account); }); diff --git a/src/routes/auth.js b/src/routes/auth.js index 3fd9806..35f4b4c 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -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; diff --git a/src/routes/checks.js b/src/routes/checks.js index 8fbd340..0bbcffa 100644 --- a/src/routes/checks.js +++ b/src/routes/checks.js @@ -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, diff --git a/src/routes/deposits.js b/src/routes/deposits.js index f0f3766..01fcc60 100644 --- a/src/routes/deposits.js +++ b/src/routes/deposits.js @@ -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), ); }); } diff --git a/src/routes/qbo-import.js b/src/routes/qbo-import.js index 7357680..454eda3 100644 --- a/src/routes/qbo-import.js +++ b/src/routes/qbo-import.js @@ -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');