feat(auth): add per-username rate limiter, migrate dates to local-time utils (batch 0.38.1)

This commit is contained in:
null 2026-06-10 19:42:51 -05:00
parent 38c8bbd472
commit 947fa3bdf8
17 changed files with 80 additions and 27 deletions

View File

@ -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,

View File

@ -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 });
}

View File

@ -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();

View File

@ -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 {

View File

@ -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'));
}

View File

@ -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',

View File

@ -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) {

View File

@ -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.

View File

@ -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.

View File

@ -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;

View File

@ -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(

View File

@ -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');

View File

@ -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;

View File

@ -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 };
}

View File

@ -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';

View File

@ -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');

View File

@ -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 };