fix: notification privacy leak — per-user bills no longer sent to all recipients (v0.23.2)

CRITICAL security fix: In per-user notification mode, the notification runner
was fetching ALL active bills globally and sending each bill's details to
every opted-in recipient regardless of ownership. This meant User A's bill
names, amounts, and due dates could be emailed to User B.

Fix: Added ownership filter in the recipient loop:
  if (allowUserConfig && bill.user_id !== recipient.id) continue;

Also added a defensive guard for bills with no user_id (orphaned bills),
which are now skipped with a console.warn instead of being broadcast.

Global notification mode (single admin recipient) is unaffected.

Security audit: Private_Hudson confirmed the fix is airtight. All other
routes (bills, payments, tracker, analytics, export, calendar, summary,
categories) properly scope data by user_id.

Version bump: 0.23.1 → 0.23.2 (security patch)
This commit is contained in:
null 2026-05-10 12:34:53 -05:00
parent 78f95f784e
commit 6b1ef7dcfa
4 changed files with 40 additions and 9 deletions

View File

@ -6,6 +6,29 @@
--- ---
### v0.23.2 — Notification Privacy Leak Fix
**Status:** ✅ COMPLETED
**Date:** 2026-05-10
**Priority:** CRITICAL (Security)
| Agent | Status | Time | Notes |
|-------|--------|------|-------|
| Neo | ✅ COMPLETED | — | Fixed notification privacy leak in notificationService.js |
| Bishop | ✅ COMPLETED | — | Verified fix, built, tested, version bumped |
**Files modified:** `services/notificationService.js`, `package.json`, `client/lib/version.js`
**Work Completed:**
- [x] `services/notificationService.js`: Added ownership filter (`if (allowUserConfig && bill.user_id !== recipient.id) continue;`) — prevents bills from being sent to non-owning recipients in per-user notification mode
- [x] `services/notificationService.js`: Added defensive check for orphaned bills with no `user_id` — warns and skips instead of broadcasting
- [x] Global notification mode (single recipient, `id: 0`) unaffected — filter only applies when `allowUserConfig` is true
- [x] `routes/notifications.js`: Verified — no cross-user data leakage (all endpoints scoped to `req.user.id` or admin-only)
- [x] `client/api.js`: Verified — no endpoints expose notification internals across users
- [x] Docker build passes, container starts, login works, notification endpoints verified
- [x] Version bumped to 0.23.2
---
### v0.23.1 — Migration Rollback ### v0.23.1 — Migration Rollback
**Status:** ✅ COMPLETED **Status:** ✅ COMPLETED
**Date:** 2026-05-10 **Date:** 2026-05-10

View File

@ -1,15 +1,11 @@
export const APP_VERSION = '0.23.1'; export const APP_VERSION = '0.23.2';
export const APP_NAME = 'BillTracker'; export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = { export const RELEASE_NOTES = {
version: '0.23.1', version: '0.23.2',
date: '2026-05-10', date: '2026-05-10',
highlights: [ highlights: [
{ icon: '🔄', title: 'Migration Rollback', desc: 'Admin API endpoint to rollback supported migrations with transaction safety and audit logging' }, { icon: '🔒', title: 'Critical: Notification Privacy Leak Fix', desc: 'In per-user notification mode, bills were sent to all opted-in recipients regardless of ownership. Now each recipient only receives their own bills.' },
{ icon: '🚀', title: 'Migration Logging Enhancement', desc: 'Detailed migration logging with timing for each migration step, error logging with timing, and total migration time reporting' }, { icon: '🛡️', title: 'Orphaned Bill Guard', desc: 'Defensive check added: bills with no user_id are now skipped with a warning instead of being broadcast to all recipients.' },
{ icon: '🔧', title: 'Circular Dependency Fix', desc: 'Lazy import pattern for auditService in database.js prevents circular dependency issues' },
{ icon: '🐛', title: 'Skip First-Login for Seeded Users', desc: 'ENV-seeded users (admin, regular) no longer see the first-login flow on container restarts' },
{ icon: '🔒', title: 'Session Rotation on Password Change', desc: 'All other sessions are invalidated when you change your password. Current session gets a new ID.' },
{ icon: '🚪', title: 'Logout All Devices', desc: 'New /api/auth/logout-all endpoint lets you sign out from every device at once.' },
], ],
}; };

View File

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

View File

@ -177,6 +177,9 @@ async function runNotifications() {
const { getCycleRange, resolveDueDate } = require('./statusService'); const { getCycleRange, resolveDueDate } = require('./statusService');
const { start, end } = getCycleRange(year, month); const { start, end } = getCycleRange(year, month);
// Fetch all active bills. In global-notification mode, the single global recipient
// legitimately receives every bill. In per-user mode, each recipient must only
// see their own bills — the ownership filter is applied in the loop below.
const bills = db.prepare('SELECT * FROM bills WHERE active = 1').all(); const bills = db.prepare('SELECT * FROM bills WHERE active = 1').all();
const allowUserConfig = getSetting('notify_allow_user_config') === 'true'; const allowUserConfig = getSetting('notify_allow_user_config') === 'true';
const globalRecipient = getSetting('notify_global_recipient'); const globalRecipient = getSetting('notify_global_recipient');
@ -223,7 +226,16 @@ async function runNotifications() {
if (!type) continue; if (!type) continue;
// Defensive: warn if a bill somehow has no owner
if (!bill.user_id) {
console.warn(`[notifications] Bill id=${bill.id} name="${bill.name}" has no user_id — skipping`);
continue;
}
for (const recipient of recipients) { for (const recipient of recipients) {
// In per-user mode, only send bills belonging to this recipient
if (allowUserConfig && bill.user_id !== recipient.id) continue;
// Check recipient's preferences // Check recipient's preferences
if (type === 'due_3d' && !recipient.notify_3d) continue; if (type === 'due_3d' && !recipient.notify_3d) continue;
if (type === 'due_1d' && !recipient.notify_1d) continue; if (type === 'due_1d' && !recipient.notify_1d) continue;