diff --git a/middleware/rateLimiter.js b/middleware/rateLimiter.js index 7da69f9..5d2bcc6 100644 --- a/middleware/rateLimiter.js +++ b/middleware/rateLimiter.js @@ -21,6 +21,25 @@ const loginLimiter = makeLimiter( 'Too many login attempts. Please try again in 15 minutes.', ); +// 5 FAILED login attempts per 15 minutes per username — layered on top of the +// per-IP limiter so a distributed attacker (or many clients behind one NAT/proxy +// sharing an IP bucket) cannot brute-force a single account. Successful logins +// don't count toward the limit, so legitimate users are unaffected. +const loginUsernameLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + standardHeaders: 'draft-7', + legacyHeaders: false, + skipSuccessfulRequests: true, + keyGenerator: (req) => { + const username = String(req.body?.username || '').trim().toLowerCase(); + return username ? `user:${username}` : ipKeyGenerator(req); + }, + handler(req, res) { + res.status(429).json({ error: 'Too many failed login attempts for this account. Please try again in 15 minutes.' }); + }, +}); + // 5 password-change attempts per 15 minutes per IP const passwordLimiter = makeLimiter( 5, 15 * 60 * 1000, @@ -78,6 +97,7 @@ const syncLimiter = rateLimit({ // ── Export all limiters plus reset function ──────────────────────────────────── const allLimiters = [ loginLimiter, + loginUsernameLimiter, passwordLimiter, importLimiter, exportLimiter, @@ -98,6 +118,7 @@ function resetStores() { module.exports = { loginLimiter, + loginUsernameLimiter, passwordLimiter, importLimiter, exportLimiter, diff --git a/routes/bills.js b/routes/bills.js index 5275f2b..bc5b0f8 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -21,6 +21,7 @@ const { accountingActiveSql, applyBankPaymentAsSourceOfTruth, } = require('../services/paymentAccountingService'); +const { localDateString, todayLocal } = require('../utils/dates'); // ── GET /api/bills ──────────────────────────────────────────────────────────── router.get('/', (req, res) => { @@ -139,7 +140,7 @@ router.post('/:id/snooze-drift', (req, res) => { if (!bill || bill.user_id !== req.user.id) return res.status(404).json({ error: 'Not found' }); const until = new Date(); until.setDate(until.getDate() + 30); - const untilStr = until.toISOString().slice(0, 10); + const untilStr = localDateString(until); db.prepare('UPDATE bills SET drift_snoozed_until = ? WHERE id = ?').run(untilStr, id); res.json({ ok: true, drift_snoozed_until: untilStr }); }); @@ -455,7 +456,7 @@ router.put('/:id', (req, res) => { const inactiveReason = typeof req.body.inactive_reason === 'string' ? req.body.inactive_reason.trim() || null : null; const wasActive = existing.active === 1; const nowActive = normalized.active === 1; - const inactivatedAt = (!wasActive || nowActive) ? existing.inactivated_at : (inactiveReason ? new Date().toISOString().slice(0, 10) : existing.inactivated_at); + const inactivatedAt = (!wasActive || nowActive) ? existing.inactivated_at : (inactiveReason ? todayLocal() : existing.inactivated_at); db.prepare(` UPDATE bills SET @@ -726,7 +727,7 @@ router.post('/:id/toggle-paid', (req, res) => { const day = Math.min(Math.max(Number(bill.due_day), 1), daysInMonth); paidDate = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; } else if (!paidDate) { - paidDate = new Date().toISOString().slice(0, 10); + paidDate = todayLocal(); } const method = req.body.method || null; @@ -1239,7 +1240,7 @@ router.post('/:id/merchant-rules/import-historical', (req, res) => { if (dom <= 5) { const prevEnd = new Date(paid.getFullYear(), paid.getMonth(), 0); if (rules2.due_day <= prevEnd.getDate()) { - const suggested = prevEnd.toISOString().slice(0, 10); + const suggested = localDateString(prevEnd); if (insertedPayment) { lateAttributions.push({ payment_id: insertedPayment.id, bill_name: bill.name, original_date: paidDate, suggested_date: suggested, amount }); } diff --git a/routes/calendar.js b/routes/calendar.js index 904af6f..25636fb 100644 --- a/routes/calendar.js +++ b/routes/calendar.js @@ -13,6 +13,7 @@ const { regenerateToken, revokeToken, } = require('../services/calendarFeedService'); +const { localDateString } = require('../utils/dates'); function clampDay(year, month, day) { const daysInMonth = new Date(year, month, 0).getDate(); @@ -106,7 +107,7 @@ router.get('/', (req, res) => { return res.status(400).json(standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month')); } - const today = now.toISOString().slice(0, 10); + const today = localDateString(now); const userSettings = getUserSettings(req.user.id); const rowOptions = { gracePeriodDays: userSettings.grace_period_days }; const daysInMonth = new Date(year, month, 0).getDate(); diff --git a/routes/matches.js b/routes/matches.js index a78eaec..f672b83 100644 --- a/routes/matches.js +++ b/routes/matches.js @@ -7,6 +7,7 @@ const { rejectMatchSuggestion, } = require('../services/matchSuggestionService'); const { learnMerchantRuleFromMatch } = require('../services/billMerchantRuleService'); +const { todayLocal } = require('../utils/dates'); function sendMatchError(res, err, fallbackMessage = 'Match operation failed') { if (err.status) { @@ -55,7 +56,7 @@ router.post('/confirm', (req, res) => { const existing = db.prepare('SELECT id FROM payments WHERE transaction_id = ? AND deleted_at IS NULL').get(txId); if (existing) return res.status(409).json(standardizeError('A payment is already linked to this transaction', 'DUPLICATE_MATCH')); - const paidDate = tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : new Date().toISOString().slice(0, 10)); + const paidDate = tx.posted_date || (tx.transacted_at ? tx.transacted_at.slice(0, 10) : todayLocal()); const amount = Math.round(Math.abs(tx.amount)) / 100; // cents → dollars try { diff --git a/routes/payments.js b/routes/payments.js index 75b911e..6b06f5d 100644 --- a/routes/payments.js +++ b/routes/payments.js @@ -9,6 +9,7 @@ const { markProvisionalManualPaymentsOverridden, reactivatePaymentsOverriddenBy, } = require('../services/paymentAccountingService'); +const { todayLocal } = require('../utils/dates'); // 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 @@ -230,7 +231,7 @@ router.post('/quick', (req, res) => { const paymentValidation = validatePaymentInput( { amount: amount != null ? amount : bill.expected_amount, - paid_date: paid_date || new Date().toISOString().slice(0, 10), + paid_date: paid_date || todayLocal(), payment_source: payment_source ?? 'manual', }, { requireBillId: false }, @@ -267,7 +268,7 @@ router.post('/autopay-suggestions/:billId/confirm', (req, res) => { const context = getAutopaySuggestionContext(db, req.user.id, billId, ym.year, ym.month); if (context.error) return res.status(context.status).json(context.error); const { bill, dueDate, amount } = context; - if (dueDate > new Date().toISOString().slice(0, 10)) { + if (dueDate > todayLocal()) { return res.status(400).json(standardizeError('Autopay suggestion is not due yet', 'VALIDATION_ERROR', 'paid_date')); } const paymentValidation = validatePaymentInput( @@ -574,7 +575,7 @@ router.patch('/:id/attribute-to-month', (req, res) => { return res.status(400).json(standardizeError('paid_date must be YYYY-MM-DD', 'VALIDATION_ERROR', 'paid_date')); } // Validate it is a real calendar date - const newDate = new Date(paid_date + 'T00:00:00'); + const newDate = new Date(paid_date + 'T00:00:00Z'); if (isNaN(newDate.getTime()) || newDate.toISOString().slice(0, 10) !== paid_date) { return res.status(400).json(standardizeError('paid_date is not a valid calendar date', 'VALIDATION_ERROR', 'paid_date')); } diff --git a/routes/status.js b/routes/status.js index 5b03321..667ea4f 100644 --- a/routes/status.js +++ b/routes/status.js @@ -10,6 +10,7 @@ const { checkForUpdates } = require('../services/updateCheckService'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { getBankSyncConfig } = require('../services/bankSyncConfigService'); const { accountingActiveSql } = require('../services/paymentAccountingService'); +const { localDateString } = require('../utils/dates'); const startTime = Date.now(); let pkg; @@ -339,7 +340,7 @@ router.get('/', async (req, res) => { ok: true, time: now.toISOString(), now: now.toISOString(), - today: now.toISOString().slice(0, 10), + today: localDateString(now), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || process.env.TZ || null, utc_offset: -now.getTimezoneOffset() / 60, env: process.env.NODE_ENV || 'development', diff --git a/routes/transactions.js b/routes/transactions.js index 90af143..799d255 100644 --- a/routes/transactions.js +++ b/routes/transactions.js @@ -14,6 +14,7 @@ const { unmatchTransaction, } = require('../services/transactionMatchService'); const { reactivatePaymentsOverriddenBy } = require('../services/paymentAccountingService'); +const { todayLocal } = require('../utils/dates'); const MATCH_STATUSES = new Set(['unmatched', 'matched', 'ignored']); const SOURCE_TYPES = new Set(['manual', 'file_import', 'provider_sync']); @@ -28,7 +29,7 @@ const TEXT_FIELDS = { }; function todayStr() { - return new Date().toISOString().slice(0, 10); + return todayLocal(); } function cleanText(value, maxLength) { diff --git a/server.js b/server.js index 569cd4b..57d68ce 100644 --- a/server.js +++ b/server.js @@ -8,7 +8,7 @@ const { recordError } = require('./services/statusRu const { securityHeaders } = require('./middleware/securityHeaders'); const { logAudit } = require('./services/auditService'); const { errorFormatter } = require('./middleware/errorFormatter'); -const { importLimiter, exportLimiter, adminActionLimiter, oidcLimiter, loginLimiter, passwordLimiter, backupOperationLimiter } = +const { importLimiter, exportLimiter, adminActionLimiter, oidcLimiter, loginLimiter, loginUsernameLimiter, passwordLimiter, backupOperationLimiter } = require('./middleware/rateLimiter'); const { csrfMiddleware, csrfTokenProvider } = require('./middleware/csrf'); @@ -86,8 +86,12 @@ function skipRateLimitIfNoUsers(limiter) { } // Mount login router with conditional rate limiting -// If no users exist, rate limit is bypassed; otherwise it applies +// If no users exist, rate limit is bypassed; otherwise it applies. +// Two layers: per-IP (all attempts) and per-username (failed attempts only), +// so one IP can't burn 10 tries against every account, and a distributed +// attacker can't brute-force a single account from many IPs. app.use('/api/auth/login', skipRateLimitIfNoUsers(loginLimiter)); +app.use('/api/auth/login', skipRateLimitIfNoUsers(loginUsernameLimiter)); // Login skips CSRF inside routes/auth because no authenticated session exists yet. // Authenticated state-changing auth routes, including logout-all and password // changes, require the SPA's x-csrf-token header like other mutating requests. diff --git a/services/billMerchantRuleService.js b/services/billMerchantRuleService.js index 507af78..ac06a08 100644 --- a/services/billMerchantRuleService.js +++ b/services/billMerchantRuleService.js @@ -3,6 +3,7 @@ const { normalizeMerchant } = require('./subscriptionService'); const { getUserSettings } = require('./userSettings'); const { applyBankPaymentAsSourceOfTruth } = require('./paymentAccountingService'); +const { localDateString } = require('../utils/dates'); // Word-boundary merchant match — requires the rule to appear as complete word(s) // within the transaction string (or vice versa), not just as a substring. @@ -25,7 +26,7 @@ function lateAttributionCandidate(paidDateStr, dueDayOfMonth, graceDays = 5) { if (dayOfMonth > graceDays) return null; const prevMonthLastDay = new Date(paid.getFullYear(), paid.getMonth(), 0); if (dueDayOfMonth > prevMonthLastDay.getDate()) return null; - return prevMonthLastDay.toISOString().slice(0, 10); // suggested prior-month date + return localDateString(prevMonthLastDay); // suggested prior-month date } // Persist a merchant→bill rule so future synced transactions auto-match. diff --git a/services/billsService.js b/services/billsService.js index 3922205..c7dfaa3 100644 --- a/services/billsService.js +++ b/services/billsService.js @@ -1,3 +1,4 @@ +const { monthKey } = require('../utils/dates'); const VALID_VISIBILITY = ['default', 'all', 'ranges', 'none']; const TEMPLATE_FIELDS = [ @@ -478,7 +479,7 @@ function computeBalanceDelta(bill, paymentAmount) { if (!Number.isFinite(bal) || bal <= 0) return null; if (!Number.isFinite(amt) || amt <= 0) return null; - const currentMonth = new Date().toISOString().slice(0, 7); // "YYYY-MM" + const currentMonth = monthKey(); // "YYYY-MM" (local time) const applyInterest = rate > 0 && bill.interest_accrued_month !== currentMonth; const interestDelta = applyInterest ? Math.round(bal * (rate / 100 / 12) * 100) / 100 : 0; diff --git a/services/driftService.js b/services/driftService.js index 11c3c3c..e404f56 100644 --- a/services/driftService.js +++ b/services/driftService.js @@ -4,6 +4,7 @@ const { getDb } = require('../db/database'); const { getCycleRange } = require('./statusService'); const { accountingActiveSql } = require('./paymentAccountingService'); const { getUserSettings } = require('./userSettings'); +const { localDateString } = require('../utils/dates'); const MONTHS_BACK = 3; const MIN_PAID_MONTHS = 2; @@ -37,7 +38,7 @@ function getDriftReport(userId, now = new Date()) { WHERE b.user_id = ? AND b.active = 1 AND b.deleted_at IS NULL `).all(userId); - const todayStr = now.toISOString().slice(0, 10); + const todayStr = localDateString(now); const drifted = []; const mbsStmt = db.prepare( diff --git a/services/notificationService.js b/services/notificationService.js index d0e6c78..0497c45 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -7,6 +7,7 @@ const { markNotificationSuccess, markNotificationTestSuccess, } = require('./statusRuntime'); +const { localDateString } = require('../utils/dates'); // ── Push notification channels ──────────────────────────────────────────────── @@ -279,7 +280,7 @@ async function runNotifications() { const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; - const today = now.toISOString().slice(0, 10); + const today = localDateString(now); const { getCycleRange, resolveDueDate } = require('./statusService'); @@ -315,7 +316,7 @@ async function runNotifications() { // and the per-bill cycle check happens in memory below. const billIds = bills.map(b => b.id); const monthStart = `${year}-${String(month).padStart(2, '0')}-01`; - const monthEnd = new Date(year, month, 0).toISOString().slice(0, 10); + const monthEnd = localDateString(new Date(year, month, 0)); const paidMap = new Map(); if (billIds.length > 0) { const placeholders = billIds.map(() => '?').join(','); @@ -504,7 +505,7 @@ async function runDriftNotifications() { const now = new Date(); const year = now.getFullYear(); const month = now.getMonth() + 1; - const today = now.toISOString().slice(0, 10); + const today = localDateString(now); const allowUserConfig = getSetting('notify_allow_user_config') === 'true'; const globalRecipient = getSetting('notify_global_recipient'); diff --git a/services/simplefinService.js b/services/simplefinService.js index fa82fa6..93eed1c 100644 --- a/services/simplefinService.js +++ b/services/simplefinService.js @@ -1,5 +1,6 @@ 'use strict'; + // SimpleFIN consumer client. // // This module handles the protocol-level work: claiming tokens, fetching @@ -170,6 +171,10 @@ function normalizeTransaction(rawTx, localAccountId, dataSourceId, userId, accou const amount = Math.round(parseFloat(rawTx.amount) * 100); // Pending transactions report posted = 0 (or omit it) until they settle. const isPending = rawTx.pending === true || rawTx.pending === 1; + // NOTE: deliberately a UTC slice, not localDateString(). SimpleFIN encodes + // the posted *date* as an epoch at UTC midnight, so the UTC calendar day IS + // the bank's posting date; converting to server-local time would shift it + // back a day for any timezone west of UTC. const postedDate = (rawTx.posted && !isPending) ? new Date(rawTx.posted * 1000).toISOString().slice(0, 10) : null; diff --git a/services/spendingService.js b/services/spendingService.js index f245253..099c07e 100644 --- a/services/spendingService.js +++ b/services/spendingService.js @@ -1,6 +1,7 @@ 'use strict'; const { normalizeMerchant } = require('./subscriptionService'); +const { localDateString } = require('../utils/dates'); // Spending = unmatched outflows (amount < 0) that haven't been ignored. // Bill-matched transactions are excluded so there's no double-counting. @@ -13,7 +14,7 @@ const SPENDING_WHERE = ` function monthRange(year, month) { const start = `${year}-${String(month).padStart(2, '0')}-01`; - const end = new Date(year, month, 0).toISOString().slice(0, 10); // last day + const end = localDateString(new Date(year, month, 0)); // last day return { start, end }; } diff --git a/services/subscriptionService.js b/services/subscriptionService.js index c22e21c..2d2d8b3 100644 --- a/services/subscriptionService.js +++ b/services/subscriptionService.js @@ -1,6 +1,7 @@ 'use strict'; const { billingCycleForCycleType, insertBill, validateBillData } = require('./billsService'); +const { localDateString, todayLocal } = require('../utils/dates'); const SUBSCRIPTION_TYPES = [ 'streaming', 'software', 'cloud', 'music', 'news', @@ -488,7 +489,7 @@ function nextDueDate(bill, now = new Date()) { date = new Date(date.getFullYear(), date.getMonth() + step, dueDay); } } - return date.toISOString().slice(0, 10); + return localDateString(date); } function decorateSubscription(bill) { @@ -1024,7 +1025,7 @@ function searchSubscriptionTransactions(db, userId, query = {}) { } function createSubscriptionFromRecommendation(db, userId, payload = {}) { - const seenDate = payload.last_seen_date || new Date().toISOString().slice(0, 10); + const seenDate = payload.last_seen_date || todayLocal(); const source = payload.catalog_match ? 'catalog_match' : 'simplefin_recommendation'; diff --git a/services/trackerService.js b/services/trackerService.js index c978d47..efd3805 100644 --- a/services/trackerService.js +++ b/services/trackerService.js @@ -7,6 +7,7 @@ const { computeBalanceDelta, applyBalanceDelta } = require('./billsService'); const { computeAmountSuggestion } = require('./amountSuggestionService'); const { accountingActiveSql } = require('./paymentAccountingService'); const { normalizeMerchant } = require('./subscriptionService'); +const { localDateString } = require('../utils/dates'); const DEFAULT_PENDING_DAYS = 3; @@ -414,7 +415,7 @@ function getTracker(userId, query = {}, now = new Date()) { const db = getDb(); const { year, month } = parsed; - const todayStr = now.toISOString().slice(0, 10); + const todayStr = localDateString(now); const userSettings = getUserSettings(userId); const rowOptions = { gracePeriodDays: userSettings.grace_period_days }; const { start, end } = getCycleRange(year, month); @@ -593,14 +594,14 @@ function getTracker(userId, query = {}, now = new Date()) { function getUpcomingBills(userId, query = {}, now = new Date()) { const db = getDb(); const days = Math.max(1, Math.min(parseInt(query.days || '30', 10) || 30, 365)); - const todayStr = now.toISOString().slice(0, 10); + const todayStr = localDateString(now); const userSettings = getUserSettings(userId); const rowOptions = { gracePeriodDays: userSettings.grace_period_days }; const bills = fetchActiveBills(db, userId, 'id'); const cutoff = new Date(now); cutoff.setDate(cutoff.getDate() + days); - const cutoffStr = cutoff.toISOString().slice(0, 10); + const cutoffStr = localDateString(cutoff); const upcoming = []; const seen = new Set(); const monthCount = (cutoff.getFullYear() - now.getFullYear()) * 12 @@ -645,7 +646,7 @@ function getUpcomingBills(userId, query = {}, now = new Date()) { function getOverdueCount(userId, now = new Date()) { const db = getDb(); - const todayStr = now.toISOString().slice(0, 10); + const todayStr = localDateString(now); const year = now.getFullYear(); const month = now.getMonth() + 1; const monthStr = String(month).padStart(2, '0'); diff --git a/utils/dates.js b/utils/dates.js index ba7bf78..f3078a8 100644 --- a/utils/dates.js +++ b/utils/dates.js @@ -30,4 +30,14 @@ function localDateStringDaysAgo(days, from = new Date()) { return localDateString(d); } -module.exports = { localDateString, localYearMonth, localDateStringDaysAgo }; +/** Today as YYYY-MM-DD in local time. Alias of localDateString(). */ +function todayLocal() { + return localDateString(new Date()); +} + +/** YYYY-MM month key in local time (e.g. "2026-06"). */ +function monthKey(date = new Date()) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; +} + +module.exports = { localDateString, localYearMonth, localDateStringDaysAgo, todayLocal, monthKey };