diff --git a/FUTURE.md b/FUTURE.md index ffbb672..f6b89ff 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -3,7 +3,7 @@ **This document tracks potential future enhancements for Bill Tracker.** **Last Updated:** 2026-05-10 -**Current Version:** v0.23.4 +**Current Version:** v0.24.0 ## How to Use This Document @@ -40,185 +40,39 @@ Items are grouped under their priority section heading (`## ๐Ÿ”ด CRITICAL`, `## ### ๐ŸŸ  HIGH -### ๐ŸŸ  Admin Can Toggle Payments on Any User Bill โ€” HIGH -**Priority:** HIGH -**Added:** 2026-05-10 by Prime (code review) -**Type:** SECURITY +### ~~๐ŸŸ  Admin Can Toggle Payments on Any User Bill โ€” HIGH~~ โœ… FIXED (v0.24.0) +**Moved to HISTORY.md** -**Description:** -`POST /api/bills/:id/toggle-paid` has a special admin branch that selects bills without `user_id = req.user.id`, then soft-deletes or creates payments on that bill. The surrounding router is mounted with `requireUser`, and `requireUser` allows `role='admin'`, so a non-default admin can modify another user's financial records through this endpoint. This contradicts the documented admin/privacy boundary used elsewhere. +### ~~๐ŸŸ  Analytics Validation Errors Crash Instead of Returning 400 โ€” HIGH~~ โœ… FIXED (v0.24.0) +**Moved to HISTORY.md** -**Affected Files:** -- `routes/bills.js:393-443` -- `middleware/requireAuth.js:38-45` -- `server.js:86-87` +### ~~๐ŸŸ  User Export Drops Recurrence and History-Range Data โ€” HIGH~~ โœ… FIXED (v0.24.0) +**Moved to HISTORY.md** -**Potential Fix:** -Remove the admin-wide branch and require ownership for all bill payment mutations. If admin support access is intentional, put it behind a separate audited admin endpoint with explicit UI/permission labeling. - -**Severity:** HIGH - -### ๐ŸŸ  Analytics Validation Errors Crash Instead of Returning 400 โ€” HIGH -**Priority:** HIGH -**Added:** 2026-05-10 by Prime (code review) -**Type:** BUG / API_CONTRACT - -**Description:** -`routes/analytics.js` calls `standardizeError()` on invalid query parameters, but never imports it. Valid analytics requests work, but invalid `year`, `month`, `months`, `category_id`, or `bill_id` input will throw `ReferenceError: standardizeError is not defined` and return a 500 instead of a standardized 400 response. - -**Affected Files:** -- `routes/analytics.js:1-3` -- `routes/analytics.js:97-99` - -**Potential Fix:** -Import `standardizeError` from `../middleware/errorFormatter` and add route tests for invalid analytics query parameters. - -**Severity:** HIGH - -### ๐ŸŸ  User Export Drops Recurrence and History-Range Data โ€” HIGH -**Priority:** HIGH -**Added:** 2026-05-10 by Prime (code review) -**Type:** DATA_INTEGRITY - -**Description:** -`routes/export.js` does not include `bills.cycle_type`, `bills.cycle_day`, or `bill_history_ranges` in user Excel/SQLite exports. Users who rely on exports as portable backups can lose non-monthly recurrence settings and history visibility ranges after exporting and re-importing their data. - -**Affected Files:** -- `routes/export.js` -- `db/schema.sql` - -**Potential Fix:** -Add `cycle_type` and `cycle_day` to exported bill rows and the SQLite export schema. Export `bill_history_ranges` as its own Excel sheet/SQLite table and update the matching import path to restore those rows under the current user. - -**Severity:** HIGH - -### ๐ŸŸ  Single-User Mode Can Lock Itself Out When Expired Sessions Exist โ€” HIGH -**Priority:** HIGH -**Added:** 2026-05-10 by Prime (code review) -**Type:** BUG - -**Description:** -`getSingleModeUser()` uses a `LEFT JOIN sessions` and requires `(s.expires_at > datetime('now') OR s.id IS NULL)`. If the configured default single-mode user has only expired session rows, the join returns expired rows, `s.id` is not NULL, and no user is returned. That defeats the intended session bypass for single-user mode until expired sessions are pruned. - -**Affected Files:** -- `middleware/requireAuth.js` -- `services/authService.js` - -**Potential Fix:** -Do not join sessions for single-user mode. Validate only that the configured default user exists, is active, and has role `user`, or explicitly prune expired sessions before checking. - -**Severity:** HIGH +### ~~๐ŸŸ  Single-User Mode Can Lock Itself Out When Expired Sessions Exist โ€” HIGH~~ โœ… FIXED (v0.24.0) +**Moved to HISTORY.md** ### ๐ŸŸก MEDIUM -### ๐ŸŸก Password Change Rate Limiter Applies to Every Profile Endpoint โ€” MEDIUM -**Priority:** MEDIUM -**Added:** 2026-05-10 by Prime (code review) -**Type:** API_CONTRACT / CONFIG +### ~~๐ŸŸก Password Change Rate Limiter Applies to Every Profile Endpoint โ€” MEDIUM~~ โœ… FIXED (v0.24.0) +**Moved to HISTORY.md** -**Description:** -`server.js` mounts `passwordLimiter` across all `/api/profile` routes, not only `/api/profile/change-password`. Five harmless profile reads/settings updates in 15 minutes can block profile access with a password-change rate-limit message, while also making profile pages fragile under normal UI polling or retries. +### ~~๐ŸŸก Profile Password Change Does Not Invalidate Other Sessions โ€” MEDIUM~~ โœ… FIXED (v0.24.0) +**Moved to HISTORY.md** -**Affected Files:** -- `server.js:102-103` -- `middleware/rateLimiter.js:18-23` -- `routes/profile.js` +### ~~๐ŸŸก CSRF Defaults Conflict with SPA Token Loading โ€” MEDIUM~~ โœ… FIXED (v0.24.0) +**Moved to HISTORY.md** -**Potential Fix:** -Apply `passwordLimiter` only to `POST /api/profile/change-password` (or inside the route), leaving profile reads/settings on a normal API limiter if needed. +### ~~๐ŸŸก Change-Password Routes Are Globally Exempted from CSRF โ€” MEDIUM~~ โœ… FIXED (v0.24.0) +**Moved to HISTORY.md** -**Severity:** MEDIUM +### ~~๐ŸŸก Notification Due-Day Math Can Miss Same-Day Reminders โ€” MEDIUM~~ โœ… FIXED (v0.24.0) +**Moved to HISTORY.md** -### ๐ŸŸก Profile Password Change Does Not Invalidate Other Sessions โ€” MEDIUM -**Priority:** MEDIUM -**Added:** 2026-05-10 by Prime (code review) -**Type:** SECURITY - -**Description:** -`POST /api/profile/change-password` only invalidates other sessions and rotates the current session when `req.sessionId` exists, but `requireAuth()` never sets `req.sessionId`. Password changes through the profile endpoint can therefore leave all existing sessions active and skip current-session rotation. The separate `/api/auth/change-password` route uses the cookie value directly and does not have this specific gap. - -**Affected Files:** -- `routes/profile.js:211-223` -- `middleware/requireAuth.js:22-31` -- `services/authService.js:175-194` - -**Potential Fix:** -Set `req.sessionId` in `requireAuth()` from the session cookie after successful authentication, or make the profile route use `req.cookies?.[COOKIE_NAME]` consistently with `/api/auth/change-password`. Add a regression test that verifies other sessions are removed after profile password change. - -**Severity:** MEDIUM - -### ๐ŸŸก CSRF Defaults Conflict with SPA Token Loading โ€” MEDIUM -**Priority:** MEDIUM -**Added:** 2026-05-10 by Prime (code review) -**Type:** CONFIG / SECURITY - -**Description:** -The CSRF middleware defaults the CSRF cookie to `httpOnly: true`, but the SPA reads `bt_csrf_token` from `document.cookie` and sends it in `x-csrf-token`. With default settings, state-changing requests from the client will not include the token. `docker-compose.yml` compensates with `CSRF_HTTP_ONLY=false`, which means the secure default and the actual SPA contract disagree. - -**Affected Files:** -- `middleware/csrf.js:12-16` -- `middleware/csrf.js:42-52` -- `client/api.js:1-16` -- `docker-compose.yml` - -**Potential Fix:** -Choose one CSRF pattern and align defaults/docs: either intentionally use a readable double-submit token for the SPA, or provide a `/api/csrf-token` endpoint/header bootstrap so the cookie can stay HTTP-only. - -**Severity:** MEDIUM - -### ๐ŸŸก Change-Password Routes Are Globally Exempted from CSRF โ€” MEDIUM -**Priority:** MEDIUM -**Added:** 2026-05-10 by Prime (code review) -**Type:** SECURITY - -**Description:** -`server.js` sets `req.csrfSkip = true` for `/api/auth/change-password` and `/api/profile/change-password`. These routes still require the current password, so this is not an immediate account-takeover issue, but password-changing is a sensitive state mutation and should not be excluded from the same CSRF protection as other authenticated writes. - -**Affected Files:** -- `server.js:70-71` -- `routes/auth.js:116-156` -- `routes/profile.js:177-230` - -**Potential Fix:** -Remove the global CSRF skip for password-change routes once the SPA token flow is aligned, and keep only login/OIDC bootstrap routes exempt where no authenticated session exists yet. - -**Severity:** MEDIUM - -### ๐ŸŸก Notification Due-Day Math Can Miss Same-Day Reminders โ€” MEDIUM -**Priority:** MEDIUM -**Added:** 2026-05-10 by Prime (code review) -**Type:** BUG - -**Description:** -`runNotifications()` computes `diffDays` by subtracting the current timestamp from midnight on the due date and flooring the result. After midnight has passed on the due date, the difference becomes a small negative value and floors to `-1`, so due-today bills can be classified as overdue instead of `due_today`. Similar timezone/partial-day drift can affect 1-day and 3-day reminders. - -**Affected Files:** -- `services/notificationService.js:172-175` -- `services/notificationService.js:213-222` - -**Potential Fix:** -Normalize both dates to local date-only midnight or compare ISO date strings/calendar days before calculating reminder type. Add tests around morning/afternoon due-today execution. - -**Severity:** MEDIUM - -### ๐ŸŸก Upcoming Bills Allows Negative Day Windows โ€” MEDIUM -**Priority:** MEDIUM -**Added:** 2026-05-10 by Prime (code review) -**Type:** BUG / API_CONTRACT - -**Description:** -`GET /api/tracker/upcoming` caps `days` at 365 but does not enforce a lower bound or reject non-numeric input. `days=-30` creates a cutoff before today and returns no upcoming bills; `days=abc` leaves `cutoff` invalid. The endpoint contract says upcoming bills in the next N days, so invalid windows should be rejected or normalized. - -**Affected Files:** -- `routes/tracker.js:245-253` -- `routes/tracker.js:284-291` - -**Potential Fix:** -Parse `days` with integer validation, require `1 <= days <= 365`, and return standardized 400 errors for invalid input. - -**Severity:** MEDIUM +### ~~๐ŸŸก Upcoming Bills Allows Negative Day Windows โ€” MEDIUM~~ โœ… FIXED (v0.24.0) +**Moved to HISTORY.md** ### Architecture: Business Logic Mixed with Route Handlers diff --git a/HISTORY.md b/HISTORY.md index e9ada5f..75ff53a 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,19 @@ # Bill Tracker โ€” Changelog +## v0.24.0 + +### Fixed +- **Admin toggle-paid restricted** โ€” Admins can no longer toggle payments on other users' bills. All bill payment mutations now require ownership (`routes/bills.js`). +- **Analytics crash fix** โ€” Imported missing `standardizeError` in `routes/analytics.js`. Invalid query params now return 400 instead of crashing with ReferenceError. +- **Export data integrity** โ€” User exports (Excel and SQLite) now include `cycle_type`, `cycle_day`, and `bill_history_ranges`. Previously, non-monthly recurrence settings and history visibility ranges were lost on export/import. +- **Single-user mode lockout** โ€” Fixed `getSingleModeUser()` joining sessions table unnecessarily. When a configured user had only expired sessions, the join excluded them. Now validates only user existence, active status, and role. +- **Password rate limiter scoped** โ€” `passwordLimiter` moved from all `/api/profile` routes to only `POST /change-password`. Normal profile reads/updates no longer hit password-change rate limits. +- **Profile password change session invalidation** โ€” Fixed `routes/profile.js` referencing `req.sessionId` (never set by requireAuth). Now uses `req.cookies?.[COOKIE_NAME]` consistent with the auth route, so other sessions are properly invalidated on password change. +- **CSRF defaults aligned** โ€” `CSRF_HTTP_ONLY` default changed from `true` to `false`. The SPA uses a double-submit pattern reading `document.cookie`, so `httpOnly=true` was always broken without the docker-compose override. Default now matches actual usage. +- **CSRF protection on password change** โ€” Removed `csrfSkip` exemptions for `/api/auth/change-password` and `/api/profile/change-password`. These are sensitive state mutations and should have CSRF protection like all other authenticated writes. +- **Notification due-day math** โ€” Fixed `runNotifications()` comparing raw timestamps instead of calendar days. Bills due today could be classified as `overdue` instead of `due_today` when checked after midnight had passed. Now normalizes both dates to local date-only before comparison. +- **Upcoming bills validation** โ€” `GET /api/tracker/upcoming` now clamps `days` to `1โ€“365` and defaults invalid/NaN input to 30. Negative or non-numeric values no longer produce empty results. + ## v0.23.4 ### Fixed diff --git a/client/lib/version.js b/client/lib/version.js index a6d8f5a..d7c52cd 100644 --- a/client/lib/version.js +++ b/client/lib/version.js @@ -1,10 +1,19 @@ -export const APP_VERSION = '0.23.4'; +export const APP_VERSION = '0.24.0'; export const APP_NAME = 'BillTracker'; export const RELEASE_NOTES = { - version: '0.23.4', + version: '0.24.0', date: '2026-05-10', highlights: [ - { icon: '๐Ÿงน', title: 'Clear Demo Data Fix', desc: 'Fixed Clear Demo Data button โ€” removed misleading placeholder, made button accessible from seeded state, fixed seed script user ID bug, and removed duplicate endpoint.' }, + { icon: '๐Ÿงน', title: 'Clear Demo Data Fix', desc: 'Fixed Clear Demo Data button โ€” removed placeholder, made button accessible, fixed seed user ID bug, removed duplicate endpoint.' }, + { icon: '๐Ÿ›ก๏ธ', title: 'Admin Toggle-Paid Restricted', desc: 'Admins can no longer toggle payments on other users\' bills. All bill payment mutations now require ownership.' }, + { icon: '๐Ÿ”ง', title: 'Analytics Crash Fix', desc: 'Imported missing standardizeError in analytics routes โ€” invalid query params now return 400 instead of 500.' }, + { icon: '๐Ÿ“ฆ', title: 'Export Data Integrity', desc: 'User exports now include cycle_type, cycle_day, and bill_history_ranges โ€” no more data loss on export/import.' }, + { icon: '๐Ÿ”“', title: 'Single-User Mode Lockout Fix', desc: 'Fixed single-user mode locking out when expired sessions exist โ€” removed unnecessary session join from user lookup.' }, + { icon: 'โฑ๏ธ', title: 'Rate Limiter Scoped', desc: 'Password rate limiter now only applies to change-password routes, not all profile reads/updates.' }, + { icon: '๐Ÿ”‘', title: 'Session Invalidation Fix', desc: 'Profile password change now correctly invalidates other sessions using cookie value, not missing sessionId.' }, + { icon: '๐Ÿช', title: 'CSRF Default Fixed', desc: 'CSRF cookie httpOnly defaults to false (matches SPA pattern). Password change routes no longer exempted from CSRF.' }, + { icon: '๐Ÿ“…', title: 'Notification Due-Day Fix', desc: 'Fixed same-day reminder classification โ€” now compares calendar days instead of timestamps to avoid overdue misclass.' }, + { icon: '๐Ÿ“Š', title: 'Upcoming Bills Validation', desc: 'Negative/invalid day windows now default to 30 instead of producing empty results.' }, ], }; \ No newline at end of file diff --git a/middleware/csrf.js b/middleware/csrf.js index a02d051..64433c0 100644 --- a/middleware/csrf.js +++ b/middleware/csrf.js @@ -10,9 +10,11 @@ const { logAudit } = require('../services/auditService'); const CSRF_HEADER_NAME = 'x-csrf-token'; // CSRF cookie httpOnly setting - configurable via environment variable -// Default: true (secure, not readable by JavaScript) -// Set CSRF_HTTP_ONLY=false to allow JavaScript access for SPA CSRF patterns -const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY !== 'false'; // defaults to true +// Default: false โ€” the SPA uses a double-submit pattern (reads token from +// document.cookie and sends it in the x-csrf-token header), which requires +// JavaScript access to the cookie. Setting httpOnly=true would break this flow. +// For server-rendered apps, set CSRF_HTTP_ONLY=true for additional protection. +const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY === 'true'; // defaults to false for SPA // CSRF cookie sameSite setting - configurable via environment variable // Options: 'lax', 'strict', 'none' diff --git a/middleware/requireAuth.js b/middleware/requireAuth.js index c7cb633..eb79845 100644 --- a/middleware/requireAuth.js +++ b/middleware/requireAuth.js @@ -6,16 +6,14 @@ function getSingleModeUser() { if (getSetting('auth_mode') !== 'single') return null; const userId = getSetting('default_user_id'); if (!userId) return null; - // Security FIX (2026-05-08): In single-user mode, we must validate the user - // against the sessions table to ensure session expiry and active flag are checked. - // This prevents replay attacks with expired sessions. + // Single-user mode: validate only that the configured user exists, + // is active, and has role 'user'. Sessions are not relevant here โ€” + // single-user mode bypasses session auth entirely. const row = getDb().prepare(` - SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login, - u.active, u.is_default_admin - FROM users u - LEFT JOIN sessions s ON s.user_id = u.id - WHERE u.id = ? AND u.role = 'user' AND u.active = 1 - AND (s.expires_at > datetime('now') OR s.id IS NULL) + SELECT id, username, display_name, role, must_change_password, first_login, + active, is_default_admin + FROM users + WHERE id = ? AND role = 'user' AND active = 1 `).get(userId); return row ? publicUser(row) : null; } diff --git a/package.json b/package.json index 12f38a1..9d31c91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bill-tracker", - "version": "0.23.4", + "version": "0.24.0", "description": "Monthly bill tracking system", "main": "server.js", "scripts": { diff --git a/routes/analytics.js b/routes/analytics.js index 633e28b..0238da4 100644 --- a/routes/analytics.js +++ b/routes/analytics.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const { getDb } = require('../db/database'); +const { standardizeError } = require('../middleware/errorFormatter'); function parseInteger(value, fallback) { if (value === undefined || value === null || value === '') return fallback; diff --git a/routes/bills.js b/routes/bills.js index 9785af4..caa3907 100644 --- a/routes/bills.js +++ b/routes/bills.js @@ -394,14 +394,9 @@ router.get('/:id/payments', (req, res) => { router.post('/:id/toggle-paid', (req, res) => { const db = getDb(); const billId = parseInt(req.params.id, 10); - - // Check if user is admin - const isAdmin = req.user?.role === 'admin' || req.user?.isAdmin === true; - // Get bill - admin can access any, user only their own - const bill = isAdmin - ? db.prepare('SELECT id, expected_amount, user_id FROM bills WHERE id = ?').get(billId) - : db.prepare('SELECT id, expected_amount, user_id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id); + // Get bill - always scope to the requesting user + const bill = db.prepare('SELECT id, expected_amount, user_id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id); if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id')); diff --git a/routes/export.js b/routes/export.js index faa7e54..92bac51 100644 --- a/routes/export.js +++ b/routes/export.js @@ -90,7 +90,7 @@ function getUserExportData(userId) { const categories = db.prepare('SELECT id, name, created_at, updated_at FROM categories WHERE user_id = ? ORDER BY name').all(userId); const bills = db.prepare(` SELECT id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate, - billing_cycle, autopay_enabled, autodraft_status, website, username, + billing_cycle, cycle_type, cycle_day, autopay_enabled, autodraft_status, website, username, account_info, has_2fa, active, notes, created_at, updated_at FROM bills WHERE user_id = ? @@ -116,6 +116,12 @@ function getUserExportData(userId) { WHERE user_id = ? ORDER BY year, month `).all(userId); + const historyRanges = db.prepare(` + SELECT id, bill_id, start_year, start_month, end_year, end_month, label, created_at, updated_at + FROM bill_history_ranges + WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ?) + ORDER BY bill_id, start_year, start_month + `).all(userId); const notes = [ ...bills.filter(b => b.notes).map(b => ({ type: 'bill', bill_id: b.id, notes: b.notes })), ...payments.filter(p => p.notes).map(p => ({ type: 'payment', payment_id: p.id, bill_id: p.bill_id, notes: p.notes })), @@ -124,17 +130,18 @@ function getUserExportData(userId) { const metadata = { exported_at: new Date().toISOString(), export_type: 'user_data', - includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Monthly starting amounts', 'Notes', 'Export metadata'], + includes: ['Bills', 'Payments', 'Categories', 'Monthly bill state', 'Monthly starting amounts', 'Bill history ranges', 'Notes', 'Export metadata'], counts: { bills: bills.length, payments: payments.length, categories: categories.length, monthly_bill_state: monthlyState.length, monthly_starting_amounts: monthlyStartingAmounts.length, + bill_history_ranges: historyRanges.length, notes: notes.length, }, }; - return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, notes }; + return { metadata, categories, bills, payments, monthly_bill_state: monthlyState, monthly_starting_amounts: monthlyStartingAmounts, bill_history_ranges: historyRanges, notes }; } router.get('/user-excel', (req, res) => { @@ -146,6 +153,7 @@ router.get('/user-excel', (req, res) => { xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.categories), 'Categories'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.monthly_bill_state), 'Monthly State'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.monthly_starting_amounts), 'Monthly Starting Amounts'); + xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.bill_history_ranges), 'History Ranges'); xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.notes), 'Notes'); const buffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' }); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); @@ -161,10 +169,11 @@ router.get('/user-db', (req, res) => { out.exec(` CREATE TABLE export_metadata (key TEXT PRIMARY KEY, value TEXT); CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT, created_at TEXT, updated_at TEXT); - CREATE TABLE bills (id INTEGER PRIMARY KEY, name TEXT, category_id INTEGER, due_day INTEGER, override_due_date TEXT, bucket TEXT, expected_amount REAL, interest_rate REAL, billing_cycle TEXT, autopay_enabled INTEGER, autodraft_status TEXT, website TEXT, username TEXT, account_info TEXT, has_2fa INTEGER, active INTEGER, notes TEXT, created_at TEXT, updated_at TEXT); + CREATE TABLE bills (id INTEGER PRIMARY KEY, name TEXT, category_id INTEGER, due_day INTEGER, override_due_date TEXT, bucket TEXT, expected_amount REAL, interest_rate REAL, billing_cycle TEXT, cycle_type TEXT, cycle_day TEXT, autopay_enabled INTEGER, autodraft_status TEXT, website TEXT, username TEXT, account_info TEXT, has_2fa INTEGER, active INTEGER, notes TEXT, created_at TEXT, updated_at TEXT); CREATE TABLE payments (id INTEGER PRIMARY KEY, bill_id INTEGER, amount REAL, paid_date TEXT, method TEXT, notes TEXT, created_at TEXT, updated_at TEXT); CREATE TABLE monthly_bill_state (id INTEGER PRIMARY KEY, bill_id INTEGER, year INTEGER, month INTEGER, actual_amount REAL, notes TEXT, is_skipped INTEGER, created_at TEXT, updated_at TEXT); CREATE TABLE monthly_starting_amounts (id INTEGER PRIMARY KEY, year INTEGER, month INTEGER, first_amount REAL, fifteenth_amount REAL, other_amount REAL, notes TEXT, created_at TEXT, updated_at TEXT); + CREATE TABLE bill_history_ranges (id INTEGER PRIMARY KEY, bill_id INTEGER, start_year INTEGER, start_month INTEGER, end_year INTEGER, end_month INTEGER, label TEXT, created_at TEXT, updated_at TEXT); CREATE TABLE notes (type TEXT, bill_id INTEGER, payment_id INTEGER, monthly_state_id INTEGER, year INTEGER, month INTEGER, notes TEXT); `); const meta = out.prepare('INSERT INTO export_metadata (key, value) VALUES (?, ?)'); @@ -181,6 +190,7 @@ router.get('/user-db', (req, res) => { insertRows('payments', data.payments); insertRows('monthly_bill_state', data.monthly_bill_state); insertRows('monthly_starting_amounts', data.monthly_starting_amounts); + insertRows('bill_history_ranges', data.bill_history_ranges); insertRows('notes', data.notes.map(n => ({ type: n.type, bill_id: n.bill_id ?? null, diff --git a/routes/profile.js b/routes/profile.js index 1f8886b..444ca3b 100644 --- a/routes/profile.js +++ b/routes/profile.js @@ -3,6 +3,7 @@ const express = require('express'); const router = express.Router(); const bcrypt = require('bcryptjs'); +const { passwordLimiter } = require('../middleware/rateLimiter'); const { getDb, getSetting } = require('../db/database'); const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, cookieOpts } = require('../services/authService'); @@ -174,7 +175,7 @@ router.patch('/settings', (req, res) => { // Always requires: current_password, new_password, confirm_new_password. // Never bypasses current_password verification regardless of must_change_password. // Never accepts user_id from the request body. -router.post('/change-password', async (req, res) => { +router.post('/change-password', passwordLimiter, async (req, res) => { const { current_password, new_password, confirm_new_password } = req.body; if (!current_password) { @@ -213,14 +214,13 @@ router.post('/change-password', async (req, res) => { `).run(hash, req.user.id); // Invalidate all other sessions for this user - // req.sessionId is set by the requireAuth middleware - if (req.sessionId) { - invalidateOtherSessions(req.user.id, req.sessionId); + const currentSessionId = req.cookies?.[COOKIE_NAME]; + if (currentSessionId) { + invalidateOtherSessions(req.user.id, currentSessionId); // Rotate the current session ID for security - const newSessionId = rotateSessionId(req.sessionId, req.user.id); + const newSessionId = rotateSessionId(currentSessionId, req.user.id); if (newSessionId) { - // Return new session cookie so the frontend can update res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req)); } } diff --git a/routes/tracker.js b/routes/tracker.js index d4fe249..1edacba 100644 --- a/routes/tracker.js +++ b/routes/tracker.js @@ -219,7 +219,7 @@ router.get('/', (req, res) => { // Returns active bills with a due date in the next N days, sorted by due_date asc. router.get('/upcoming', (req, res) => { const db = getDb(); - const days = Math.min(parseInt(req.query.days || '30', 10), 365); + const days = Math.max(1, Math.min(parseInt(req.query.days || '30', 10) || 30, 365)); const now = new Date(); const todayStr = now.toISOString().slice(0, 10); diff --git a/server.js b/server.js index c477ed2..ec78b49 100644 --- a/server.js +++ b/server.js @@ -65,9 +65,8 @@ function skipRateLimitIfNoUsers(limiter) { // If no users exist, rate limit is bypassed; otherwise it applies app.use('/api/auth/login', skipRateLimitIfNoUsers(loginLimiter)); // Password change routes are exempt from CSRF - session-based auth is primary protection -// Set csrfSkip before the auth routes middleware runs -app.use('/api/auth/change-password', (req, res, next) => { req.csrfSkip = true; next(); }); -app.use('/api/profile/change-password', (req, res, next) => { req.csrfSkip = true; next(); }); +// CSRF skip for login (no session exists yet to protect) and logout-all +// (uses session cookie directly). Password change routes MUST have CSRF protection. app.use('/api/auth/logout-all', (req, res, next) => { req.csrfSkip = true; next(); }); // All other auth routes require CSRF (loginLimiter applied via /api/auth/login above) // Note: passwordLimiter is applied individually on routes that actually change passwords @@ -98,8 +97,8 @@ app.use('/api/about', require('./routes/abo app.use('/api/about-admin', adminActionLimiter, csrfMiddleware, requireAuth, requireAdmin, require('./routes/aboutAdmin')); // admin-only app.use('/api/version', require('./routes/version')); // public -// Profile โ€” password-change rate limit applied at middleware level -app.use('/api/profile', csrfMiddleware, requireAuth, requireUser, passwordLimiter, require('./routes/profile')); +// Profile โ€” rate limit only on password-change, not all profile reads +app.use('/api/profile', csrfMiddleware, requireAuth, requireUser, require('./routes/profile')); // Export / Import โ€” per-IP rate limited to deter abuse and resource exhaustion app.use('/api/export', csrfMiddleware, requireAuth, requireUser, exportLimiter, require('./routes/export')); diff --git a/services/notificationService.js b/services/notificationService.js index 07a163d..cd1cee0 100644 --- a/services/notificationService.js +++ b/services/notificationService.js @@ -215,7 +215,11 @@ async function runNotifications() { const dueDate = resolveDueDate(bill, year, month); const due = new Date(dueDate + 'T00:00:00'); - const diffDays = Math.floor((due - now) / 86400000); + // Compare calendar days, not timestamps, to avoid same-day bugs + // (e.g., due today at midnight vs now at 3pm would give -0.625 days โ†’ floors to -1) + const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const dueDay = new Date(due.getFullYear(), due.getMonth(), due.getDate()); + const diffDays = Math.round((dueDay - todayDate) / 86400000); // Determine which type applies today let type = null;