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:
parent
5537ab2bd5
commit
80b3bcc17b
188
FUTURE.md
188
FUTURE.md
|
|
@ -3,7 +3,7 @@
|
||||||
**This document tracks potential future enhancements for Bill Tracker.**
|
**This document tracks potential future enhancements for Bill Tracker.**
|
||||||
|
|
||||||
**Last Updated:** 2026-05-10
|
**Last Updated:** 2026-05-10
|
||||||
**Current Version:** v0.23.4
|
**Current Version:** v0.24.0
|
||||||
|
|
||||||
## How to Use This Document
|
## How to Use This Document
|
||||||
|
|
||||||
|
|
@ -40,185 +40,39 @@ Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `##
|
||||||
|
|
||||||
### 🟠 HIGH
|
### 🟠 HIGH
|
||||||
|
|
||||||
### 🟠 Admin Can Toggle Payments on Any User Bill — HIGH
|
### ~~🟠 Admin Can Toggle Payments on Any User Bill — HIGH~~ ✅ FIXED (v0.24.0)
|
||||||
**Priority:** HIGH
|
**Moved to HISTORY.md**
|
||||||
**Added:** 2026-05-10 by Prime (code review)
|
|
||||||
**Type:** SECURITY
|
|
||||||
|
|
||||||
**Description:**
|
### ~~🟠 Analytics Validation Errors Crash Instead of Returning 400 — HIGH~~ ✅ FIXED (v0.24.0)
|
||||||
`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.
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
**Affected Files:**
|
### ~~🟠 User Export Drops Recurrence and History-Range Data — HIGH~~ ✅ FIXED (v0.24.0)
|
||||||
- `routes/bills.js:393-443`
|
**Moved to HISTORY.md**
|
||||||
- `middleware/requireAuth.js:38-45`
|
|
||||||
- `server.js:86-87`
|
|
||||||
|
|
||||||
**Potential Fix:**
|
### ~~🟠 Single-User Mode Can Lock Itself Out When Expired Sessions Exist — HIGH~~ ✅ FIXED (v0.24.0)
|
||||||
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.
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
|
|
||||||
### 🟡 MEDIUM
|
### 🟡 MEDIUM
|
||||||
|
|
||||||
|
|
||||||
### 🟡 Password Change Rate Limiter Applies to Every Profile Endpoint — MEDIUM
|
### ~~🟡 Password Change Rate Limiter Applies to Every Profile Endpoint — MEDIUM~~ ✅ FIXED (v0.24.0)
|
||||||
**Priority:** MEDIUM
|
**Moved to HISTORY.md**
|
||||||
**Added:** 2026-05-10 by Prime (code review)
|
|
||||||
**Type:** API_CONTRACT / CONFIG
|
|
||||||
|
|
||||||
**Description:**
|
### ~~🟡 Profile Password Change Does Not Invalidate Other Sessions — MEDIUM~~ ✅ FIXED (v0.24.0)
|
||||||
`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.
|
**Moved to HISTORY.md**
|
||||||
|
|
||||||
**Affected Files:**
|
### ~~🟡 CSRF Defaults Conflict with SPA Token Loading — MEDIUM~~ ✅ FIXED (v0.24.0)
|
||||||
- `server.js:102-103`
|
**Moved to HISTORY.md**
|
||||||
- `middleware/rateLimiter.js:18-23`
|
|
||||||
- `routes/profile.js`
|
|
||||||
|
|
||||||
**Potential Fix:**
|
### ~~🟡 Change-Password Routes Are Globally Exempted from CSRF — MEDIUM~~ ✅ FIXED (v0.24.0)
|
||||||
Apply `passwordLimiter` only to `POST /api/profile/change-password` (or inside the route), leaving profile reads/settings on a normal API limiter if needed.
|
**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
|
### ~~🟡 Upcoming Bills Allows Negative Day Windows — MEDIUM~~ ✅ FIXED (v0.24.0)
|
||||||
**Priority:** MEDIUM
|
**Moved to HISTORY.md**
|
||||||
**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
|
|
||||||
|
|
||||||
|
|
||||||
### Architecture: Business Logic Mixed with Route Handlers
|
### Architecture: Business Logic Mixed with Route Handlers
|
||||||
|
|
|
||||||
14
HISTORY.md
14
HISTORY.md
|
|
@ -1,5 +1,19 @@
|
||||||
# Bill Tracker — Changelog
|
# 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
|
## v0.23.4
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -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 APP_NAME = 'BillTracker';
|
||||||
|
|
||||||
export const RELEASE_NOTES = {
|
export const RELEASE_NOTES = {
|
||||||
version: '0.23.4',
|
version: '0.24.0',
|
||||||
date: '2026-05-10',
|
date: '2026-05-10',
|
||||||
highlights: [
|
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.' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -10,9 +10,11 @@ const { logAudit } = require('../services/auditService');
|
||||||
const CSRF_HEADER_NAME = 'x-csrf-token';
|
const CSRF_HEADER_NAME = 'x-csrf-token';
|
||||||
|
|
||||||
// CSRF cookie httpOnly setting - configurable via environment variable
|
// CSRF cookie httpOnly setting - configurable via environment variable
|
||||||
// Default: true (secure, not readable by JavaScript)
|
// Default: false — the SPA uses a double-submit pattern (reads token from
|
||||||
// Set CSRF_HTTP_ONLY=false to allow JavaScript access for SPA CSRF patterns
|
// document.cookie and sends it in the x-csrf-token header), which requires
|
||||||
const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY !== 'false'; // defaults to true
|
// 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
|
// CSRF cookie sameSite setting - configurable via environment variable
|
||||||
// Options: 'lax', 'strict', 'none'
|
// Options: 'lax', 'strict', 'none'
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,14 @@ function getSingleModeUser() {
|
||||||
if (getSetting('auth_mode') !== 'single') return null;
|
if (getSetting('auth_mode') !== 'single') return null;
|
||||||
const userId = getSetting('default_user_id');
|
const userId = getSetting('default_user_id');
|
||||||
if (!userId) return null;
|
if (!userId) return null;
|
||||||
// Security FIX (2026-05-08): In single-user mode, we must validate the user
|
// Single-user mode: validate only that the configured user exists,
|
||||||
// against the sessions table to ensure session expiry and active flag are checked.
|
// is active, and has role 'user'. Sessions are not relevant here —
|
||||||
// This prevents replay attacks with expired sessions.
|
// single-user mode bypasses session auth entirely.
|
||||||
const row = getDb().prepare(`
|
const row = getDb().prepare(`
|
||||||
SELECT u.id, u.username, u.display_name, u.role, u.must_change_password, u.first_login,
|
SELECT id, username, display_name, role, must_change_password, first_login,
|
||||||
u.active, u.is_default_admin
|
active, is_default_admin
|
||||||
FROM users u
|
FROM users
|
||||||
LEFT JOIN sessions s ON s.user_id = u.id
|
WHERE id = ? AND role = 'user' AND active = 1
|
||||||
WHERE u.id = ? AND u.role = 'user' AND u.active = 1
|
|
||||||
AND (s.expires_at > datetime('now') OR s.id IS NULL)
|
|
||||||
`).get(userId);
|
`).get(userId);
|
||||||
return row ? publicUser(row) : null;
|
return row ? publicUser(row) : null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bill-tracker",
|
"name": "bill-tracker",
|
||||||
"version": "0.23.4",
|
"version": "0.24.0",
|
||||||
"description": "Monthly bill tracking system",
|
"description": "Monthly bill tracking system",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { getDb } = require('../db/database');
|
const { getDb } = require('../db/database');
|
||||||
|
const { standardizeError } = require('../middleware/errorFormatter');
|
||||||
|
|
||||||
function parseInteger(value, fallback) {
|
function parseInteger(value, fallback) {
|
||||||
if (value === undefined || value === null || value === '') return fallback;
|
if (value === undefined || value === null || value === '') return fallback;
|
||||||
|
|
|
||||||
|
|
@ -395,13 +395,8 @@ router.post('/:id/toggle-paid', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const billId = parseInt(req.params.id, 10);
|
const billId = parseInt(req.params.id, 10);
|
||||||
|
|
||||||
// Check if user is admin
|
// Get bill - always scope to the requesting user
|
||||||
const isAdmin = req.user?.role === 'admin' || req.user?.isAdmin === true;
|
const bill = db.prepare('SELECT id, expected_amount, user_id FROM bills WHERE id = ? AND user_id = ?').get(billId, req.user.id);
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
if (!bill) return res.status(404).json(standardizeError('Bill not found', 'NOT_FOUND', 'bill_id'));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 categories = db.prepare('SELECT id, name, created_at, updated_at FROM categories WHERE user_id = ? ORDER BY name').all(userId);
|
||||||
const bills = db.prepare(`
|
const bills = db.prepare(`
|
||||||
SELECT id, name, category_id, due_day, override_due_date, bucket, expected_amount, interest_rate,
|
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
|
account_info, has_2fa, active, notes, created_at, updated_at
|
||||||
FROM bills
|
FROM bills
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
|
|
@ -116,6 +116,12 @@ function getUserExportData(userId) {
|
||||||
WHERE user_id = ?
|
WHERE user_id = ?
|
||||||
ORDER BY year, month
|
ORDER BY year, month
|
||||||
`).all(userId);
|
`).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 = [
|
const notes = [
|
||||||
...bills.filter(b => b.notes).map(b => ({ type: 'bill', bill_id: b.id, notes: b.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 })),
|
...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 = {
|
const metadata = {
|
||||||
exported_at: new Date().toISOString(),
|
exported_at: new Date().toISOString(),
|
||||||
export_type: 'user_data',
|
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: {
|
counts: {
|
||||||
bills: bills.length,
|
bills: bills.length,
|
||||||
payments: payments.length,
|
payments: payments.length,
|
||||||
categories: categories.length,
|
categories: categories.length,
|
||||||
monthly_bill_state: monthlyState.length,
|
monthly_bill_state: monthlyState.length,
|
||||||
monthly_starting_amounts: monthlyStartingAmounts.length,
|
monthly_starting_amounts: monthlyStartingAmounts.length,
|
||||||
|
bill_history_ranges: historyRanges.length,
|
||||||
notes: notes.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) => {
|
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.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_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.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');
|
xlsx.utils.book_append_sheet(wb, xlsx.utils.json_to_sheet(data.notes), 'Notes');
|
||||||
const buffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
const buffer = xlsx.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
|
@ -161,10 +169,11 @@ router.get('/user-db', (req, res) => {
|
||||||
out.exec(`
|
out.exec(`
|
||||||
CREATE TABLE export_metadata (key TEXT PRIMARY KEY, value TEXT);
|
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 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 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_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 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);
|
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 (?, ?)');
|
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('payments', data.payments);
|
||||||
insertRows('monthly_bill_state', data.monthly_bill_state);
|
insertRows('monthly_bill_state', data.monthly_bill_state);
|
||||||
insertRows('monthly_starting_amounts', data.monthly_starting_amounts);
|
insertRows('monthly_starting_amounts', data.monthly_starting_amounts);
|
||||||
|
insertRows('bill_history_ranges', data.bill_history_ranges);
|
||||||
insertRows('notes', data.notes.map(n => ({
|
insertRows('notes', data.notes.map(n => ({
|
||||||
type: n.type,
|
type: n.type,
|
||||||
bill_id: n.bill_id ?? null,
|
bill_id: n.bill_id ?? null,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { passwordLimiter } = require('../middleware/rateLimiter');
|
||||||
|
|
||||||
const { getDb, getSetting } = require('../db/database');
|
const { getDb, getSetting } = require('../db/database');
|
||||||
const { hashPassword, invalidateOtherSessions, rotateSessionId, COOKIE_NAME, cookieOpts } = require('../services/authService');
|
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.
|
// Always requires: current_password, new_password, confirm_new_password.
|
||||||
// Never bypasses current_password verification regardless of must_change_password.
|
// Never bypasses current_password verification regardless of must_change_password.
|
||||||
// Never accepts user_id from the request body.
|
// 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;
|
const { current_password, new_password, confirm_new_password } = req.body;
|
||||||
|
|
||||||
if (!current_password) {
|
if (!current_password) {
|
||||||
|
|
@ -213,14 +214,13 @@ router.post('/change-password', async (req, res) => {
|
||||||
`).run(hash, req.user.id);
|
`).run(hash, req.user.id);
|
||||||
|
|
||||||
// Invalidate all other sessions for this user
|
// Invalidate all other sessions for this user
|
||||||
// req.sessionId is set by the requireAuth middleware
|
const currentSessionId = req.cookies?.[COOKIE_NAME];
|
||||||
if (req.sessionId) {
|
if (currentSessionId) {
|
||||||
invalidateOtherSessions(req.user.id, req.sessionId);
|
invalidateOtherSessions(req.user.id, currentSessionId);
|
||||||
|
|
||||||
// Rotate the current session ID for security
|
// Rotate the current session ID for security
|
||||||
const newSessionId = rotateSessionId(req.sessionId, req.user.id);
|
const newSessionId = rotateSessionId(currentSessionId, req.user.id);
|
||||||
if (newSessionId) {
|
if (newSessionId) {
|
||||||
// Return new session cookie so the frontend can update
|
|
||||||
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
|
res.cookie(COOKIE_NAME, newSessionId, cookieOpts(req));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// Returns active bills with a due date in the next N days, sorted by due_date asc.
|
||||||
router.get('/upcoming', (req, res) => {
|
router.get('/upcoming', (req, res) => {
|
||||||
const db = getDb();
|
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 now = new Date();
|
||||||
const todayStr = now.toISOString().slice(0, 10);
|
const todayStr = now.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,8 @@ function skipRateLimitIfNoUsers(limiter) {
|
||||||
// If no users exist, rate limit is bypassed; otherwise it applies
|
// If no users exist, rate limit is bypassed; otherwise it applies
|
||||||
app.use('/api/auth/login', skipRateLimitIfNoUsers(loginLimiter));
|
app.use('/api/auth/login', skipRateLimitIfNoUsers(loginLimiter));
|
||||||
// Password change routes are exempt from CSRF - session-based auth is primary protection
|
// Password change routes are exempt from CSRF - session-based auth is primary protection
|
||||||
// Set csrfSkip before the auth routes middleware runs
|
// CSRF skip for login (no session exists yet to protect) and logout-all
|
||||||
app.use('/api/auth/change-password', (req, res, next) => { req.csrfSkip = true; next(); });
|
// (uses session cookie directly). Password change routes MUST have CSRF protection.
|
||||||
app.use('/api/profile/change-password', (req, res, next) => { req.csrfSkip = true; next(); });
|
|
||||||
app.use('/api/auth/logout-all', (req, res, next) => { req.csrfSkip = true; next(); });
|
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)
|
// All other auth routes require CSRF (loginLimiter applied via /api/auth/login above)
|
||||||
// Note: passwordLimiter is applied individually on routes that actually change passwords
|
// 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/about-admin', adminActionLimiter, csrfMiddleware, requireAuth, requireAdmin, require('./routes/aboutAdmin')); // admin-only
|
||||||
app.use('/api/version', require('./routes/version')); // public
|
app.use('/api/version', require('./routes/version')); // public
|
||||||
|
|
||||||
// Profile — password-change rate limit applied at middleware level
|
// Profile — rate limit only on password-change, not all profile reads
|
||||||
app.use('/api/profile', csrfMiddleware, requireAuth, requireUser, passwordLimiter, require('./routes/profile'));
|
app.use('/api/profile', csrfMiddleware, requireAuth, requireUser, require('./routes/profile'));
|
||||||
|
|
||||||
// Export / Import — per-IP rate limited to deter abuse and resource exhaustion
|
// Export / Import — per-IP rate limited to deter abuse and resource exhaustion
|
||||||
app.use('/api/export', csrfMiddleware, requireAuth, requireUser, exportLimiter, require('./routes/export'));
|
app.use('/api/export', csrfMiddleware, requireAuth, requireUser, exportLimiter, require('./routes/export'));
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,11 @@ async function runNotifications() {
|
||||||
|
|
||||||
const dueDate = resolveDueDate(bill, year, month);
|
const dueDate = resolveDueDate(bill, year, month);
|
||||||
const due = new Date(dueDate + 'T00:00:00');
|
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
|
// Determine which type applies today
|
||||||
let type = null;
|
let type = null;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue