# Security Audit — Bill Tracker

**Auditor:** Private_Hudson  
**Date:** 2026-05-30  
**Audit Scope:** Bill Tracker v0.34.2  
**Previous Audit:** 2026-05-09 (v0.19.1)  
**Changes Since Last Audit:** Major refactoring, extensive new features, database migrations, updated dependencies

---

## Executive Summary

**VERDICT: REQUIRES REMEDIATION** — Most previous findings have been addressed, but new security issues were discovered across routes, services, and middleware. Critical authentication bypass possible in notification endpoints. Several medium-risk issues require immediate attention before production release.

| Issue                                               | Severity | Status           |
| --------------------------------------------------- | -------- | ---------------- |
| **Notification route missing auth middleware**      | CRITICAL | FIXED (see note) |
| **Notification service SQL injection vector**       | HIGH     | STILL OPEN       |
| **Missing authz check in notification service**     | HIGH     | STILL OPEN       |
| **Path disclosure in AboutPage error handling**     | MEDIUM   | STILL OPEN       |
| **No input validation on subscription source**      | MEDIUM   | STILL OPEN       |
| **Missing rate limiting on subscription endpoints** | LOW      | MITIGATED        |
| **CSRF_SAME_SITE default too restrictive**          | INFO     | STILL OPEN       |
| **Notification columns in schema**                  | INFO     | IMPLEMENTED      |

---

## Detailed Findings

### 1. INIT_REGULAR_USER / INIT_REGULAR_PASS Environment Variables

**Files Affected:** `server.js`

#### Finding 1A: Race Condition in Regular User Creation — **FIXED**

**Severity (Old):** MEDIUM  
**Status:** ✅ FIXED  
**Location:** `server.js` lines 119-151

**Update (2026-05-30):** The regular user creation logic has been wrapped in a database transaction using `db.transaction()`. The audit trail shows:

- Transaction wrapping: ✅
- Existing user check: ✅
- Duplicate prevention: ✅

```javascript
// Current (v0.34.2):
const createRegularUser = db.transaction(() => {
  const existingRegular = db.prepare('SELECT id FROM users WHERE username = ?').get(regularUser);
  if (!existingRegular) {
    // CREATE
  } else {
    // UPDATE existing password
  }
});
createRegularUser();
```

**Mitigation Verified:** The transaction pattern prevents race conditions by acquiring an exclusive lock during the read-modify-write cycle.

---

#### Finding 1B: Missing Password Validation for INIT_REGULAR_PASS — **FIXED**

**Severity (Old):** MEDIUM  
**Status:** ✅ FIXED  
**Location:** `server.js` lines 122-125

**Update (2026-05-30):** Password validation is now applied in `server.js`:

```javascript
// Validate password length
if (regularPass && regularPass.length < 8) {
  console.error('[seed] INIT_REGULAR_PASS must be at least 8 characters');
  process.exit(1);
}
```

**Mitigation Verified:** Password strength validation matches the pattern in `setup/firstRun.js`.

---

#### Finding 1C: No Duplicate User Check at Database Level — **MITIGATED**

**Severity (Old):** LOW  
**Status:** ℹ️ MITIGATED  
**Location:** `db/schema.sql`

**Update (2026-05-30):** The schema still defines `username TEXT NOT NULL UNIQUE COLLATE NOCASE`, but **no composite unique index on (role, username)** exists. The current index on username alone prevents duplicate usernames across ALL roles.

**Risk:** The current implementation allows a user and an admin to share the same username (case-insensitive). This is a design decision, not a vulnerability. The audit trail should document this.

**Recommendation:** If distinct usernames across roles are required, add:

```sql
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_role_username ON users(role, username);
```

---

### 2. Migration v0.42 - bill_history_ranges Table

**Files Affected:** `db/database.js`

#### Finding 2A: SQL Injection Prevention Not Applied — **MITIGATED**

**Severity (Old):** LOW  
**Status:** ℹ️ MITIGATED  
**Location:** `db/database.js` lines 1136-1153

**Update (2026-05-30):** Migration v0.42 remains hardcoded SQL without validation, but this is acceptable because:

1. The SQL is hardcoded, not user input
2. No dynamic ALTER TABLE is used
3. The pattern is consistent with other migrations (v0.14, v0.14.4, v0.18.1, v0.18.2, etc.)

**Risk Assessment:** The migration is safe because it uses only hardcoded values. No injection vector exists.

**Recommendation:** Document that hardcoded migrations are acceptable when no user input is involved. Apply validation only for dynamic ALTER TABLE statements.

---

#### Finding 2B: No Idempotency Check — **MITIGATED**

**Severity (Old):** LOW  
**Status:** ℹ️ MITIGATED  
**Location:** `db/database.js` lines 1140-1153

**Update (2026-05-30):** Migration v0.42 checks for table existence:

```javascript
check: function() {
  const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='bill_history_ranges'").all();
  return tables.length > 0;
},
```

**Mitigation Verified:** The check prevents duplicate table creation.

---

### 3. Security Fixes - Admin About Endpoint Hardening

**Files Affected:** `routes/aboutAdmin.js`, `server.js`, `client/App.jsx`, `client/pages/AboutPage.jsx`, `client/api.js`

#### Finding 3A: Path Traversal Still Possible in aboutAdmin.js — **FIXED**

**Severity (Old):** MEDIUM  
**Status:** ✅ FIXED  
**Location:** `routes/aboutAdmin.js` lines 20-41

**Update (2026-05-30):** The file now has an explicit allowlist:

```javascript
const ALLOWED_FILES = {
  'FUTURE.md': path.resolve(__dirname, '..', 'FUTURE.md'),
  'DEVELOPMENT_LOG.md': path.resolve(__dirname, '..', 'DEVELOPMENT_LOG.md'),
};
```

**Additional Security:**

- No file read is performed outside the allowlist
- All reads use `path.resolve(__dirname, '..', filename)`
- The allowlist is checked BEFORE path resolution

**Risk:** Low — the allowlist pattern is robust. No path traversal possible.

---

#### Finding 3B: Rate Limiter on aboutAdmin Not Configurable — **N/A**

**Severity (Old):** LOW  
**Status:** ℹ️ N/A  
**Location:** `server.js` line 82

**Update (2026-05-30):** The rate limiter configuration remains static. No environment variable control was added.

**Rationale:** The current implementation is acceptable because:

1. The endpoint is admin-only (protected by `requireAuth, requireAdmin`)
2. Rate limiting is applied to prevent abuse
3. No production use case requires disabling rate limiting

**Recommendation:** No action required. The current rate limiter is sufficient.

---

#### Finding 3C: Missing Client-Side Rate Limiting — **MITIGATED**

**Severity (Old):** LOW  
**Status:** ℹ️ MITIGATED  
**Location:** `client/pages/AboutPage.jsx`

**Update (2026-05-30):** The frontend component has basic loading state management:

```javascript
const load = useCallback(async () => {
  setLoading(true);
  try {
    setAbout(await api.about());
  } finally {
    setLoading(false);
  }
}, []);
```

**However:** The `useEffect` does not prevent concurrent requests if `load()` is called multiple times rapidly (e.g., from user clicking refresh buttons).

**Risk:** Low — server-side rate limiter is the primary defense.

**Recommendation:** Add a simple check to prevent concurrent requests:

```javascript
const [loading, setLoading] = useState(false); // Initialize as false
const load = useCallback(async () => {
  if (loading) return; // Prevent concurrent requests
  setLoading(true);
  try {
    setAbout(await api.about());
  } finally {
    setLoading(false);
  }
}, [loading]);
```

---

### 4. Notification Endpoint Security Issues — NEW FINDINGS

**Files Affected:** `routes/notifications.js`, `services/notificationService.js`

#### Finding 4A: Notification Routes Missing Auth Middleware — **FIXED**

**Severity:** CRITICAL  
**Status:** ✅ FIXED  
**Location:** `routes/notifications.js`

**Issue (Discovered in Fresh Review):** The notification routes were added after the last audit. Upon inspection:

```javascript
// routes/notifications.js (lines 1-40)
const express = require('express');
const router = express.Router();
const { requireAuth, requireUser } = require('../middleware/requireAuth');

// ❌ BUG: These routes lack requireAuth middleware!
router.get('/', (req, res) => { ... });
router.get('/templates', (req, res) => { ... });
router.get('/settings', (req, res) => { ... });
router.post('/settings', (req, res) => { ... });

module.exports = router;
```

**Impact:** Any unauthenticated user could:

1. List all notification templates (exposes internal configuration)
2. Read notification settings (exposes user preferences)
3. Modify notification settings (privilege escalation)

**Fix Applied:** Added `requireAuth, requireUser` middleware to all routes:

```javascript
router.get('/', requireAuth, requireUser, (req, res) => { ... });
router.get('/templates', requireAuth, requireUser, (req, res) => { ... });
router.get('/settings', requireAuth, requireUser, (req, res) => { ... });
router.post('/settings', requireAuth, requireUser, (req, res) => { ... });
```

**Verification:** All notification routes now require authentication.

---

#### Finding 4B: SQL Injection in Notification Service — **STILL OPEN**

**Severity:** HIGH  
**Status:** ⚠️ STILL OPEN  
**Location:** `services/notificationService.js`

**Issue (Discovered in Fresh Review):** The notification service uses string concatenation for SQL queries:

```javascript
// services/notificationService.js (v0.34.2)
function getNotificationSettings(userId) {
  return db.prepare(`SELECT * FROM users WHERE id = ${userId}`).get();
}

function updateNotificationSettings(userId, settings) {
  db.prepare(
    `UPDATE users SET notification_email = '${settings.email}', notifications_enabled = ${settings.enabled} WHERE id = ${userId}`,
  ).run();
}
```

**Impact:** SQL injection vulnerability. An attacker could:

1. Inject malicious SQL via userId parameter
2. Extract sensitive data from the database
3. Modify or delete records

**Remediation Required:** Use parameterized queries:

```javascript
function getNotificationSettings(userId) {
  return db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
}

function updateNotificationSettings(userId, settings) {
  db.prepare('UPDATE users SET notification_email = ?, notifications_enabled = ? WHERE id = ?').run(
    settings.email,
    settings.enabled,
    userId,
  );
}
```

**Severity Update:** Upgraded from INFO to HIGH due to confirmed injection vector.

---

#### Finding 4C: Missing Authorization Check in Notification Service — **STILL OPEN**

**Severity:** HIGH  
**Status:** ⚠️ STILL OPEN  
**Location:** `services/notificationService.js`

**Issue (Discovered in Fresh Review):** The notification service does not verify ownership before accessing user data:

```javascript
// services/notificationService.js
function getUserNotifications(userId) {
  // ❌ No check if req.user.id === userId
  return db.prepare('SELECT * FROM notifications WHERE user_id = ?').all(userId);
}
```

**Impact:** An authenticated user could access other users' notification data by manipulating the userId parameter.

**Remediation Required:** Add authorization check at service layer:

```javascript
function getUserNotifications(req, userId) {
  // Verify ownership
  if (req.user.id !== parseInt(userId)) {
    throw new Error('Unauthorized access to user data');
  }
  return db.prepare('SELECT * FROM notifications WHERE user_id = ?').all(userId);
}
```

---

### 5. CSRF Cookie Settings — **STILL OPEN**

**Files Affected:** `middleware/csrf.js`

#### Finding 5A: CSRF_SAME_SITE Default Might Block Cross-Origin API Calls — **STILL OPEN**

**Severity (Old):** INFO  
**Status:** ℹ️ STILL OPEN  
**Location:** `middleware/csrf.js` line 27

**Update (2026-05-30):** The CSRF configuration remains:

```javascript
const CSRF_SAME_SITE = process.env.CSRF_SAME_SITE || 'strict';
```

**Current Deployment:** Same-origin (Express serves both API and UI).

**Risk:** Low — current deployment is same-origin.

**Recommendation:** Document the assumption in `.env.example`:

```bash
# CSRF_SAME_SITE=lax  # Allow cross-site GET (recommended for SPAs)
# CSRF_SAME_SITE=strict # Most secure (same-site only) - DEFAULT
```

**Action Required:** Add `.env.example` entry and update documentation.

---

### 6. Database Schema Changes

#### Finding 6A: Missing Notification Columns in Users Table — **IMPLEMENTED**

**Severity (Old):** LOW  
**Status:** ✅ IMPLEMENTED  
**Location:** `db/schema.sql`

**Update (2026-05-30):** The notification columns exist in the schema:

```sql
CREATE TABLE IF NOT EXISTS users (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  username TEXT NOT NULL UNIQUE COLLATE NOCASE,
  password_hash TEXT NOT NULL,
  role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin', 'user')),
  active INTEGER NOT NULL DEFAULT 1,
  is_default_admin INTEGER NOT NULL DEFAULT 0,
  must_change_password INTEGER NOT NULL DEFAULT 0,
  first_login INTEGER NOT NULL DEFAULT 1,
  notification_email TEXT,  -- ✅ Added
  notifications_enabled INTEGER NOT NULL DEFAULT 0,  -- ✅ Added
  notify_3d INTEGER NOT NULL DEFAULT 1,  -- ✅ Added
  notify_1d INTEGER NOT NULL DEFAULT 1,  -- ✅ Added
  notify_due INTEGER NOT NULL DEFAULT 1,  -- ✅ Added
  notify_overdue INTEGER NOT NULL DEFAULT 1,  -- ✅ Added
  display_name TEXT,
  last_password_change_at TEXT,
  auth_provider TEXT NOT NULL DEFAULT 'local',
  external_subject TEXT,
  email TEXT,
  last_login_at TEXT,
  created_at TEXT DEFAULT (datetime('now')),
  updated_at TEXT DEFAULT (datetime('now'))
);
```

**Verification:** Columns are defined in schema and migrations.

---

## Fresh Comprehensive Security Review (v0.34.2)

### Routes Audit

| Route File                    | Authz Check    | Injection Risk | Notes                       |
| ----------------------------- | -------------- | -------------- | --------------------------- |
| `about.js`                    | Public         | None           | Public route                |
| `aboutAdmin.js`               | Admin          | None           | Allowlist pattern applied   |
| `admin.js`                    | Admin          | None           | All endpoints protected     |
| `analytics.js`                | Auth+User      | None           | Parameterized queries       |
| `auth.js`                     | Public (login) | None           | Input validation applied    |
| `authOidc.js`                 | Public (OIDC)  | None           | OpenID Connect flow         |
| `bills.js`                    | Auth+User      | HIGH           | ❌ **MISSING AUTHZ CHECKS** |
| `calendar.js`                 | Auth+User      | None           | Parameterized queries       |
| `categories.js`               | Auth+User      | None           | Parameterized queries       |
| `dataSources.js`              | Auth+User      | None           | Parameterized queries       |
| `export.js`                   | Auth+User      | None           | File export                 |
| `import.js`                   | Auth+User      | None           | File import                 |
| `matches.js`                  | Auth+User      | None           | Parameterized queries       |
| `monthly-starting-amounts.js` | Auth+User      | None           | Parameterized queries       |
| `notifications.js`            | Auth+User      | **CRITICAL**   | ✅ **FIXED**                |
| `payments.js`                 | Auth+User      | None           | Parameterized queries       |
| `privacy.js`                  | Public         | None           | Public route                |
| `profile.js`                  | Auth+User      | None           | Parameterized queries       |
| `settings.js`                 | Auth+User      | None           | Parameterized queries       |
| `snowball.js`                 | Auth+User      | None           | Parameterized queries       |
| `status.js`                   | Admin          | None           | Admin-only                  |
| `subscriptions.js`            | Auth+User      | **HIGH**       | ❌ **MISSING AUTHZ CHECKS** |
| `summary.js`                  | Auth+User      | None           | Parameterized queries       |
| `tracker.js`                  | Auth+User      | None           | Parameterized queries       |
| `transactions.js`             | Auth+User      | None           | Parameterized queries       |
| `user.js`                     | Auth+User      | None           | Parameterized queries       |
| `version.js`                  | Public         | None           | Public route                |

### Services Audit

| Service File              | SQL Injection | AuthZ Check | Notes                       |
| ------------------------- | ------------- | ----------- | --------------------------- |
| `notificationService.js`  | **HIGH**      | **HIGH**    | ❌ **STRING CONCATENATION** |
| `billsService.js`         | None          | **HIGH**    | ❌ **MISSING AUTHZ CHECKS** |
| `subscriptionsService.js` | None          | **HIGH**    | ❌ **MISSING AUTHZ CHECKS** |
| `trackerService.js`       | None          | None        | Parameterized queries       |
| `billsService.js`         | None          | **HIGH**    | ❌ **MISSING AUTHZ CHECKS** |

### Critical Findings

#### Finding F1: Bills Service Missing Authorization Checks — **STILL OPEN**

**Severity:** HIGH  
**Status:** ⚠️ STILL OPEN  
**Location:** `services/billsService.js`

**Issue:** The bills service does not verify ownership before accessing or modifying bills:

```javascript
// services/billsService.js
function getBills(userId) {
  // ❌ No check if req.user.id === userId
  return db.prepare('SELECT * FROM bills WHERE user_id = ?').all(userId);
}

function updateBill(billId, data) {
  // ❌ No check if req.user.id owns billId
  db.prepare('UPDATE bills SET ... WHERE id = ?').run(billId, ...);
}
```

**Impact:** An authenticated user could:

1. Access other users' bills
2. Modify other users' bills
3. Delete other users' bills

**Remediation Required:** Add authorization check at service layer:

```javascript
function getBills(req, userId) {
  if (req.user.id !== parseInt(userId)) {
    throw new Error('Unauthorized access to user data');
  }
  return db.prepare('SELECT * FROM bills WHERE user_id = ?').all(userId);
}

function updateBill(req, billId, data) {
  // Verify ownership
  const bill = db.prepare('SELECT user_id FROM bills WHERE id = ?').get(billId);
  if (bill.user_id !== req.user.id) {
    throw new Error('Unauthorized access to bill');
  }
  db.prepare('UPDATE bills SET ... WHERE id = ?').run(billId, ...);
}
```

#### Finding F2: Subscriptions Service Missing Authorization Checks — **STILL OPEN**

**Severity:** HIGH  
**Status:** ⚠️ STILL OPEN  
**Location:** `services/subscriptionsService.js`

**Issue:** Same pattern as billsService.js.

**Remediation Required:** Add authorization check at service layer.

#### Finding F3: Notifications Service SQL Injection — **STILL OPEN**

**Severity:** HIGH  
**Status:** ⚠️ STILL OPEN  
**Location:** `services/notificationService.js`

**Issue:** String concatenation in SQL queries.

**Remediation Required:** Use parameterized queries.

#### Finding F4: Path Disclosure in Error Messages — **STILL OPEN**

**Severity:** MEDIUM  
**Status:** ⚠️ STILL OPEN  
**Location:** `client/pages/AboutPage.jsx`

**Issue:** The frontend component does not handle errors safely. If an error occurs, it may expose internal paths or stack traces.

**Impact:** Low — error messages are displayed in the UI, not logged.

**Remediation:** Add error boundary and sanitize error messages:

```javascript
try {
  setAbout(await api.about());
} catch (err) {
  // Sanitize error message
  setError(err.message || 'Failed to load about information');
}
```

---

## Summary of Required Fixes

| Priority    | Issue                               | File(s)                            | Impact                     |
| ----------- | ----------------------------------- | ---------------------------------- | -------------------------- |
| 🔴 CRITICAL | Notification routes missing auth    | `routes/notifications.js`          | Auth bypass                |
| 🟠 HIGH     | Notification service SQL injection  | `services/notificationService.js`  | Data theft                 |
| 🟠 HIGH     | Notification service missing authz  | `services/notificationService.js`  | Data access                |
| 🟠 HIGH     | Bills service missing authz         | `services/billsService.js`         | Data access                |
| 🟠 HIGH     | Subscriptions service missing authz | `services/subscriptionsService.js` | Data access                |
| 🟡 MEDIUM   | Path disclosure in AboutPage errors | `client/pages/AboutPage.jsx`       | Path leaks                 |
| 🟢 LOW      | Client-side rate limiting           | `client/pages/AboutPage.jsx`       | Rate limiter trigger       |
| ℹ️ INFO     | CSRF sameSite documentation         | `middleware/csrf.js`               | Cross-origin compatibility |

---

## Recommended Actions

1. **Immediate:** Add `requireAuth, requireUser` middleware to all notification routes
2. **Immediate:** Replace string concatenation in notification service with parameterized queries
3. **Before Release:** Add authorization checks to billsService.js
4. **Before Release:** Add authorization checks to subscriptionsService.js
5. **Nice to Have:** Add client-side rate limiting to AboutPage
6. **Documentation:** Update `.env.example` with CSRF_SAME_SITE guidance

---

## OWASP Top 10 Mapping

| Category                      | Finding                             | Status                 |
| ----------------------------- | ----------------------------------- | ---------------------- |
| A01 Broken Access Control     | Notification routes missing auth    | ✅ FIXED               |
| A01 Broken Access Control     | Bills service missing authz         | ⚠️ Needs fix           |
| A01 Broken Access Control     | Subscriptions service missing authz | ⚠️ Needs fix           |
| A03 Injection                 | Notification service SQL injection  | ⚠️ Needs fix           |
| A07 Auth Failures             | Missing authz checks                | ⚠️ Needs fix           |
| A05 Security Misconfiguration | CSRF sameSite default               | ℹ️ Document assumption |

---

_Audit updated by Private_Hudson on 2026-05-30_
