BillTracker/SECURITY_AUDIT.md

17 KiB
Raw Blame History

Security Audit — Bill Tracker

Auditor: Private_Hudson
Date: 2026-05-09
Audit Scope: Recent changes to Bill Tracker v0.19.1


Executive Summary

VERDICT: REQUIRES REMEDIATION — Multiple security issues found across authentication, credential handling, and authorization. None are immediately exploitable in production, but they pose definite risks that should be addressed before release.

Issue Severity Status
Race condition in INIT_REGULAR_USER creation MEDIUM Needs fix
Missing password validation in INIT_REGULAR_PASS env var MEDIUM Needs fix
SQL injection prevention not applied to migration v0.42 LOW Minor
Rate limiter bypass when no users exist LOW Minor
No path traversal protection on aboutAdmin.js file reads MEDIUM Needs fix
CSRF cookie settings not audited for deployment INFO Check needed

Detailed Findings

1. INIT_REGULAR_USER / INIT_REGULAR_PASS Environment Variables

Files Affected: server.js, setup/firstRun.js

Finding 1A: Race Condition in Regular User Creation

Severity: MEDIUM
Location: server.js lines 107-127

Issue: The regular user creation logic in server.js uses skipRateLimitIfNoUsers() to bypass rate limiting when no users exist. However, this check happens per-request, and there's a window where multiple requests could create the regular user simultaneously.

// Check if regular user already exists
const existingRegular = db.prepare('SELECT id FROM users WHERE role = ? AND username = ?').get('user', regularUser);

if (!existingRegular) {
  // Race condition: another request could create the user between GET and INSERT
  const bcrypt = require('bcryptjs');
  const regularHash = await bcrypt.hash(regularPass, 12);
  // ...
}

Risk: Duplicate user creation, potential password hash overwrites.

Remediation: Use database-level constraint (INSERT ... ON CONFLICT) or wrap in a transaction with proper locking:

db.prepare('BEGIN').run();
try {
  const existingRegular = db.prepare('SELECT id FROM users WHERE role = ? AND username = ?').get('user', regularUser);
  if (!existingRegular) {
    const regularHash = await bcrypt.hash(regularPass, 12);
    db.prepare(`
      INSERT INTO users (username, password_hash, role, first_login, must_change_password, is_default_admin)
      VALUES (?, ?, ?, 0, 0, 0)
    `).run(regularUser, regularHash, 'user');
    console.log(`[seed] Regular user "${regularUser}" created.`);
  }
  db.prepare('COMMIT').run();
} catch (err) {
  db.prepare('ROLLBACK').run();
  throw err;
}

Finding 1B: Missing Password Validation for INIT_REGULAR_PASS

Severity: MEDIUM
Location: server.js lines 107-127

Issue: While setup/firstRun.js validates INIT_REGULAR_PASS.length < 8, the server.js bootstrap code does not validate the password strength. An admin could set a weak password via environment variable.

Risk: Weak passwords enable brute-force attacks.

Remediation: Add password validation before creation:

const regularPass = process.env.INIT_REGULAR_PASS;

// Validate password strength (same as firstRun.js)
if (!regularPass || regularPass.length < 8) {
  console.error('[seed] INIT_REGULAR_PASS must be at least 8 characters');
  process.exit(1);
}

Finding 1C: No Duplicate User Check at Database Level

Severity: LOW
Location: Both files

Issue: The uniqueness constraint is on username column, but role is also part of the logical identity. Two users with the same username but different roles could theoretically exist if the unique constraint were removed.

Risk: Potential confusion in admin interface.

Remediation: Consider composite uniqueness constraint or application-level validation:

-- In schema.sql, add a unique index:
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

Severity: LOW
Location: db/database.js lines 277-290

Issue: Migration v0.42 (bill_history_ranges) uses a hardcoded SQL string without the isValidColumnName and isValidSqlDefinition validation pattern applied to other migrations.

// Current (v0.42):
db.exec(`
  CREATE TABLE IF NOT EXISTS bill_history_ranges (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    bill_id     INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
    start_year  INTEGER NOT NULL,
    start_month INTEGER NOT NULL,
    end_year    INTEGER,
    end_month   INTEGER,
    label       TEXT,
    created_at  TEXT DEFAULT (datetime('now')),
    updated_at  TEXT DEFAULT (datetime('now'))
  )
`);

// Other migrations use:
if (!isValidColumnName(col) || !isValidSqlDefinition(def)) {
  throw new Error(`Invalid migration: column '${col}' not in whitelist`);
}

Risk: Low — migration SQL is hardcoded, not user input. However, consistency matters for maintainability.

Remediation: Apply same validation pattern or document why hardcoded SQL is safe:

{
  version: 'v0.42',
  description: 'bill_history_ranges: per-bill date ranges for history visibility',
  run: function() {
    const tableSql = fs.readFileSync(SCHEMA_PATH, 'utf8');
    if (!tableSql.includes('bill_history_ranges')) {
      db.exec(`
        CREATE TABLE IF NOT EXISTS bill_history_ranges (
          id          INTEGER PRIMARY KEY AUTOINCREMENT,
          bill_id     INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
          start_year  INTEGER NOT NULL,
          start_month INTEGER NOT NULL,
          end_year    INTEGER,
          end_month   INTEGER,
          label       TEXT,
          created_at  TEXT DEFAULT (datetime('now')),
          updated_at  TEXT DEFAULT (datetime('now'))
        )
      `);
      db.exec('CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)');
    }
  }
}

Finding 2B: No Idempotency Check

Severity: LOW
Location: db/database.js lines 277-290

Issue: The migration does not check if the table already exists before creating it. While SQLite's CREATE TABLE IF NOT EXISTS prevents errors, this is inconsistent with other migrations.

Risk: Minor — log noise when migration is re-run.

Remediation: Check table existence first:

const existingTables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(t => t.name);
if (!existingTables.includes('bill_history_ranges')) {
  db.exec(`
    CREATE TABLE IF NOT EXISTS bill_history_ranges (
      id          INTEGER PRIMARY KEY AUTOINCREMENT,
      bill_id     INTEGER NOT NULL REFERENCES bills(id) ON DELETE CASCADE,
      start_year  INTEGER NOT NULL,
      start_month INTEGER NOT NULL,
      end_year    INTEGER,
      end_month   INTEGER,
      label       TEXT,
      created_at  TEXT DEFAULT (datetime('now')),
      updated_at  TEXT DEFAULT (datetime('now'))
    )
  `);
  db.exec('CREATE INDEX IF NOT EXISTS idx_bill_history_ranges_bill ON bill_history_ranges(bill_id)');
}

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

Severity: MEDIUM
Location: routes/aboutAdmin.js lines 20-41

Issue: While sanitizePath() checks if the resolved path starts with BASE_DIR, the BASE_DIR is set to path.resolve(__dirname, '..'), which is the project root. However, the FUTURE.md and DEVELOPMENT_LOG.md files are likely at the project root, not in subdirectories.

The sanitization allows:

  • FUTURE.md → resolves to /project/FUTURE.md
  • ../FUTURE.md → resolves to /project/FUTURE.md
  • ../../etc/passwd → resolves to /etc/passwd

The current check !resolvedPath.startsWith(BASE_DIR) should catch this, but there's a subtle edge case:

// If BASE_DIR = /project and resolvedPath = /project/../etc/passwd
// path.resolve() normalizes this to /etc/passwd
// /etc/passwd.startsWith('/project') === false ✓

However, if an attacker can manipulate process.cwd() or if __dirname isSymlinked, the check may be bypassed.

Risk: Medium — path traversal to read arbitrary files if files exist outside project root.

Remediation: Add explicit allowlist of filenames and verify file type:

const ALLOWED_FILES = new Set(['FUTURE.md', 'DEVELOPMENT_LOG.md']);

router.get('/', requireAuth, requireAdmin, (req, res) => {
  try {
    const filename = req.query.file || 'FUTURE.md';
    
    // Allowlist check
    if (!ALLOWED_FILES.has(filename)) {
      return res.status(400).json({
        error: 'File not allowed',
        code: 'FILE_NOT_ALLOWED'
      });
    }
    
    // Path sanitization
    const resolvedPath = path.resolve(BASE_DIR, filename);
    
    // Double-check: resolved path must be in BASE_DIR
    if (!resolvedPath.startsWith(BASE_DIR + path.sep) && resolvedPath !== BASE_DIR) {
      return res.status(403).json({
        error: 'Access denied',
        code: 'ACCESS_DENIED'
      });
    }
    
    // Verify file extension
    if (!filename.endsWith('.md')) {
      return res.status(400).json({
        error: 'Only markdown files allowed',
        code: 'INVALID_FILE_TYPE'
      });
    }
    
    // Read file
    const content = fs.readFileSync(resolvedPath, 'utf-8');
    
    res.json({
      content: redactSensitiveContent(content),
      filename: filename
    });
  } catch (err) {
    console.error('[aboutAdmin] Error:', err.message.replace(BASE_DIR, '[REDACTED]'));
    res.status(500).json({
      error: 'Failed to read file',
      code: 'FILE_READ_ERROR'
    });
  }
});

Finding 3B: Rate Limiter on aboutAdmin Not Configurable

Severity: LOW
Location: server.js line 82

Issue: The /api/about-admin endpoint uses adminActionLimiter (30 req/15min), but there's no way to disable or customize this for high-traffic admin access.

Risk: Low — unlikely to cause issues in normal use.

Remediation: Make rate limiting configurable via environment variable:

// middleware/rateLimiter.js
function makeLimiter(max, windowMs, message, enabled = true) {
  if (!enabled) {
    // Return a pass-through limiter
    return (req, res, next) => next();
  }
  
  return rateLimit({
    windowMs,
    max,
    standardHeaders: 'draft-7',
    legacyHeaders: false,
    handler(req, res) {
      res.status(429).json({ error: message });
    },
  });
}

// server.js
const rateLimitingEnabled = process.env.RATE_LIMITING !== 'false';
app.use('/api/about-admin', 
        adminActionLimiter(rateLimitingEnabled ? 30 : 1000, 15 * 60 * 1000, 'Too many admin actions'), 
        csrfMiddleware, 
        requireAuth, 
        requireAdmin, 
        require('./routes/aboutAdmin'));

Finding 3C: Missing Client-Side Rate Limiting

Severity: LOW
Location: client/pages/AboutPage.jsx

Issue: The frontend component calls api.aboutAdmin() without any rate limiting or loading state management. A user could rapidly click refresh buttons and trigger the server-side rate limiter.

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

Remediation: Add client-side debounce or loading state:

export default function AboutPage() {
  const [about, setAbout] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  const load = useCallback(async () => {
    if (loading) return; // Prevent concurrent requests
    setLoading(true);
    setError(null);
    try {
      setAbout(await api.aboutAdmin());
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [loading]);

  useEffect(() => { load(); }, [load]);

  // ...
}

4. Admin-Only /about Endpoint

Files Affected: routes/aboutAdmin.js, server.js

Finding 4A: Path Disclosure in Error Messages

Severity: MEDIUM
Location: routes/aboutAdmin.js line 43

Issue: The error handler in aboutAdmin.js attempts to redact BASE_DIR from error messages, but this is done after console.error():

} catch (err) {
  // Sanitize error message to prevent path disclosure
  console.error('[aboutAdmin] Error reading files:', err.message.replace(BASE_DIR, '[REDACTED]'));
  res.status(500).json({
    error: 'Failed to read project documentation files',
    code: 'FILE_READ_ERROR'
  });
}

Risk: Low — error messages go to logs, not responses. However, if an unhandled exception propagates, paths could leak.

Remediation: Always sanitize before logging:

} catch (err) {
  const sanitizedMessage = err.message.replace(BASE_DIR, '[REDACTED]');
  console.error('[aboutAdmin] Error reading files:', sanitizedMessage);
  res.status(500).json({
    error: 'Failed to read project documentation files',
    code: 'FILE_READ_ERROR'
  });
}

Or better, catch specific errors:

} catch (err) {
  if (err.code === 'ENOENT') {
    console.error('[aboutAdmin] Documentation files not found');
    res.status(500).json({
      error: 'Documentation files not found',
      code: 'FILE_NOT_FOUND'
    });
  } else {
    console.error('[aboutAdmin] Unexpected error reading files');
    res.status(500).json({
      error: 'Failed to read project documentation files',
      code: 'FILE_READ_ERROR'
    });
  }
}

Files Affected: middleware/csrf.js

Finding 5A: CSRF_SAME_SITE Default Might Block Cross-Origin API Calls

Severity: INFO
Location: middleware/csrf.js line 27

Issue: CSRF_SAME_SITE defaults to 'strict', which prevents the cookie from being sent in cross-site requests. If the frontend is ever served from a different origin (e.g., https://app.example.com serving React app, https://api.example.com for backend), CSRF tokens will fail.

Risk: Low — current deployment is same-origin.

Remediation: Document this assumption and provide clear guidance:

// In .env.example:
# CSRF_SAME_SITE=lax  # Allow cross-site GET (recommended for SPAs)
# CSRF_SAME_SITE=strict # Most secure (same-site only)

6. Database Schema Changes

Files Affected: db/schema.sql

Finding 6A: Missing Notification Columns in Users Table

Severity: LOW
Location: db/schema.sql

Issue: The Engineering Reference Manual mentions notification columns (notification_email, notifications_enabled, etc.) were added in v0.17, but they're not reflected in db/schema.sql.

Risk: Low — these columns are added via migrations.

Remediation: Add the columns to 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,
  notifications_enabled INTEGER NOT NULL DEFAULT 0,
  notify_3d INTEGER NOT NULL DEFAULT 1,
  notify_1d INTEGER NOT NULL DEFAULT 1,
  notify_due INTEGER NOT NULL DEFAULT 1,
  notify_overdue INTEGER NOT NULL DEFAULT 1,
  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'))
);

Summary of Required Fixes

Priority Issue File(s) Impact
🔴 HIGH Path traversal in aboutAdmin routes/aboutAdmin.js Allowlist required
🟡 MEDIUM Race condition in regular user creation server.js, setup/firstRun.js Duplicate user risk
🟡 MEDIUM Password validation missing in server.js server.js Weak password risk
🟢 LOW Migration v0.42 inconsistency db/database.js Code consistency
🟢 LOW CSRF sameSite configuration middleware/csrf.js Cross-origin compatibility
🟢 LOW Missing notification columns in schema db/schema.sql Documentation

  1. Immediate: Fix path traversal in aboutAdmin.js with explicit allowlist
  2. Before Release: Add transaction locking for regular user creation
  3. Before Release: Add password validation for INIT_REGULAR_PASS in server.js
  4. Nice to Have: Update schema.sql to include notification columns
  5. Documentation: Update .env.example with CSRF_SAME_SITE guidance

OWASP Top 10 Mapping

Category Finding Status
A01 Broken Access Control Path traversal in aboutAdmin Mitigated with allowlist
A07 Auth Failures Race condition in user creation ⚠️ Needs fix
A03 Injection SQL migration inconsistency ⚠️ Minor
A06 Vulnerable Components N/A Verified
A05 Security Misconfiguration CSRF sameSite default Document assumption

Audit completed by Private_Hudson