This commit is contained in:
null 2026-05-31 15:52:50 -05:00
parent 31bafb0e55
commit 67ce59db50
8 changed files with 154 additions and 32 deletions

View File

@ -9,9 +9,11 @@ NODE_ENV=production
# ── CSRF Cookie httpOnly Setting ──────────────────────────────────────────────
# CSRF cookie httpOnly setting (default: true)
# Set CSRF_HTTP_ONLY=false to allow JavaScript access for SPA CSRF patterns
# CSRF_HTTP_ONLY: "true" (secure, default - cookie not readable by JS)
# CSRF_HTTP_ONLY: "false" (SPA mode - allows JavaScript to read cookie)
# The SPA fetches the token from GET /api/auth/csrf-token and stores it in
# memory — JavaScript does not need to read the cookie directly. httpOnly=true
# removes the token from the XSS-accessible cookie surface.
# CSRF_HTTP_ONLY: "true" (default — cookie not readable by document.cookie)
# CSRF_HTTP_ONLY: "false" (legacy — only if a custom client reads document.cookie)
#
# ── CSRF Cookie sameSite Setting ──────────────────────────────────────────────
# CSRF cookie sameSite setting (default: strict)
@ -37,6 +39,18 @@ NODE_ENV=production
# DB_PATH=/opt/bill-tracker/data/db/bills.db
# BACKUP_PATH=/opt/bill-tracker/data/backups
# ── Encryption key ────────────────────────────────────────────────────────────
# AES-256-GCM key used to encrypt secrets at rest (SimpleFIN tokens, SMTP passwords).
# Must be at least 32 bytes. Any printable string works; a random hex string is best.
#
# Generate one with: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
#
# If not set, Bill Tracker auto-generates a key and stores it in the database
# next to the encrypted data — anyone with database read access can decrypt.
# Set this variable in production to keep the key separate from the data.
#
# TOKEN_ENCRYPTION_KEY=replace-with-a-long-random-string-at-least-32-chars
# ── Bank Sync (SimpleFIN) ─────────────────────────────────────────────────────
# Enable/disable bank sync from the Admin panel. Users connect their own
# SimpleFIN Bridge from the Data page. No environment config required.

View File

@ -1,5 +1,31 @@
# Bill Tracker — Changelog
## v0.36.0
### 🔧 Changed
- **Bump**`0.35.0``0.36.0`
### 🔒 Security
- **CSRF token moved out of readable cookie** — The CSRF cookie previously defaulted to `httpOnly: false` so the SPA could read it from `document.cookie`. Any XSS vulnerability could steal the token from there and bypass CSRF protection entirely. The cookie is now `httpOnly: true` by default, removing it from the XSS-accessible cookie surface. The SPA instead fetches the token once from `GET /api/auth/csrf-token` on startup and stores it in a module-level memory cache; all mutations continue to send it in the `x-csrf-token` header unchanged. The server-side double-submit validation (`header == cookie`) is identical. `CSRF_HTTP_ONLY=false` remains available in `.env` for compatibility, but is no longer the default.
---
## v0.35.0
### 🔧 Changed
- **Bump**`0.34.3``0.35.0`
### 🔒 Security
- **TOKEN_ENCRYPTION_KEY deployment guidance** — When `TOKEN_ENCRYPTION_KEY` is not set, `encryptionService.js` auto-generates a random key and persists it in the `user_settings` table — placing the key and the ciphertext in the same SQLite file. Anyone with database read access has everything needed to decrypt. The threat is bounded (filesystem access = game over regardless), but is now explicitly surfaced: a one-time `console.warn` fires on first use of the auto-generated key path, directing operators to set the env var. `.env.example` gains a `TOKEN_ENCRYPTION_KEY` section with a generation command and a plain-English explanation of the trade-off.
- **HKDF key derivation with automatic migration**`encryptionService.js` previously derived the AES-256-GCM key from raw input via `SHA-256(ikm)`, which lacks domain separation and offers no protection if a low-entropy passphrase is supplied. Replaced with **HKDF-SHA-256** (RFC 5869) using info label `bill-tracker-token-encryption-v1`. New ciphertext carries a `v2:` prefix; `decryptSecret` uses it to choose the correct derivation path, so legacy and new ciphertext coexist transparently. DB migration `v0.78` re-encrypts all existing secrets (`data_sources.encrypted_secret` and `notify_smtp_password`) to the v2 format on first startup — no manual action required.
---
## v0.34.3
### 🔧 Changed

View File

@ -1,16 +1,20 @@
// Read CSRF token from cookie
function getCsrfToken() {
if (typeof document === 'undefined') return '';
const name = 'bt_csrf_token';
const match = document.cookie.match(new RegExp(name + '=([^;]+)'));
return match ? match[1] : '';
// Fetch CSRF token from the server once and cache in memory.
// The cookie is httpOnly so document.cookie cannot access it directly.
let _csrfFetch = null;
async function getCsrfToken() {
if (!_csrfFetch) {
_csrfFetch = fetch('/api/auth/csrf-token', { credentials: 'include' })
.then(r => r.json())
.then(d => d.token || '');
}
return _csrfFetch;
}
async function _fetch(method, path, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' }, credentials: 'include' };
// Add CSRF token header for state-changing methods
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
const csrfToken = getCsrfToken();
const csrfToken = await getCsrfToken();
if (csrfToken) {
opts.headers['x-csrf-token'] = csrfToken;
}
@ -100,10 +104,11 @@ export const api = {
};
},
importAdminBackup: async (file) => {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/admin/backups/import', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': getCsrfToken() },
headers: { 'Content-Type': 'application/octet-stream', 'x-csrf-token': csrfToken },
body: file,
});
const data = await res.json();
@ -267,12 +272,13 @@ export const api = {
if (options.defaultYear) params.set('year', String(options.defaultYear));
if (options.defaultMonth) params.set('month', String(options.defaultMonth));
const qs = params.toString();
const csrfToken = await getCsrfToken();
const res = await fetch(`/api/import/spreadsheet/preview${qs ? `?${qs}` : ''}`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
'x-csrf-token': getCsrfToken(),
'x-csrf-token': csrfToken,
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
@ -290,12 +296,13 @@ export const api = {
},
applySpreadsheetImport: (data) => post('/import/spreadsheet/apply', data),
previewCsvTransactionImport: async (file) => {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/import/csv/preview', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'text/csv',
'x-csrf-token': getCsrfToken(),
'x-csrf-token': csrfToken,
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,
@ -344,12 +351,13 @@ export const api = {
// User SQLite import
previewUserDbImport: async (file) => {
const csrfToken = await getCsrfToken();
const res = await fetch('/api/import/user-db/preview', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/octet-stream',
'x-csrf-token': getCsrfToken(),
'x-csrf-token': csrfToken,
...(file.name ? { 'X-Filename': file.name } : {}),
},
body: file,

View File

@ -2583,6 +2583,42 @@ function runMigrations() {
console.warn('[v0.77] SMTP password encryption migration failed:', err.message);
}
}
},
{
version: 'v0.78',
description: 're-encrypt secrets from SHA-256 to HKDF key derivation',
dependsOn: ['v0.77'],
run: function() {
try {
const { decryptSecret, encryptSecret } = require('../services/encryptionService');
// Re-encrypt SimpleFIN tokens in data_sources
const sources = db.prepare(
"SELECT id, encrypted_secret FROM data_sources WHERE encrypted_secret IS NOT NULL AND encrypted_secret NOT LIKE 'v2:%'"
).all();
const updateSource = db.prepare('UPDATE data_sources SET encrypted_secret = ? WHERE id = ?');
for (const row of sources) {
try {
updateSource.run(encryptSecret(decryptSecret(row.encrypted_secret)), row.id);
} catch (err) {
console.warn(`[v0.78] Could not re-encrypt data_source id=${row.id}:`, err.message);
}
}
// Re-encrypt SMTP password
const smtp = db.prepare("SELECT value FROM settings WHERE key = 'notify_smtp_password'").get();
if (smtp?.value && !smtp.value.startsWith('v2:')) {
try {
db.prepare("UPDATE settings SET value = ?, updated_at = datetime('now') WHERE key = 'notify_smtp_password'")
.run(encryptSecret(decryptSecret(smtp.value)));
} catch (err) {
console.warn('[v0.78] Could not re-encrypt SMTP password:', err.message);
}
}
} catch (err) {
console.warn('[v0.78] HKDF re-encryption migration failed:', err.message);
}
}
}
];

View File

@ -10,13 +10,11 @@ const { logAudit } = require('../services/auditService');
const CSRF_HEADER_NAME = 'x-csrf-token';
// CSRF cookie httpOnly setting - configurable via environment variable
// 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.
// Do not enable CSRF_HTTP_ONLY for this SPA unless token delivery changes away
// from document.cookie. Server-rendered apps can use httpOnly CSRF cookies when
// they deliver the token through another trusted channel.
const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY === 'true'; // defaults to false for SPA
// Default: true — the SPA fetches the token from GET /api/auth/csrf-token and stores
// it in memory, so JavaScript does not need direct access to document.cookie.
// httpOnly=true removes the token from the XSS-accessible cookie surface while
// preserving the double-submit validation on the server.
const CSRF_HTTP_ONLY = process.env.CSRF_HTTP_ONLY !== 'false'; // defaults to true
// CSRF cookie sameSite setting - configurable via environment variable
// Options: 'lax', 'strict', 'none'

View File

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

View File

@ -11,6 +11,7 @@ function getAppVersion() {
const { getDb, getSetting, setSetting } = require('../db/database');
const { login, logout, hashPassword, cookieOpts, COOKIE_NAME, rotateSessionId, invalidateOtherSessions, recordLogin } = require('../services/authService');
const { getCsrfToken } = require('../middleware/csrf');
const { requireAuth, requireAdmin } = require('../middleware/requireAuth');
const { getPublicOidcInfo } = require('../services/oidcService');
const { ValidationError, formatError } = require('../utils/apiError');
@ -57,6 +58,15 @@ router.post('/login', (req, res, next) => {
}
});
// GET /api/auth/csrf-token
// Public — returns the CSRF token from the httpOnly cookie so the SPA can
// store it in memory and send it in the x-csrf-token header for mutations.
// Cross-origin access is prevented by the same-origin fetch policy (no CORS).
router.get('/csrf-token', (req, res) => {
const token = getCsrfToken(req, res);
res.json({ token });
});
// POST /api/auth/logout
router.post('/logout', requireAuth, (req, res) => {
logout(req.cookies?.[COOKIE_NAME]);

View File

@ -2,19 +2,32 @@
const crypto = require('crypto');
const ALGORITHM = 'aes-256-gcm';
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const TAG_BYTES = 16;
// Domain-separation label for HKDF. Changing this invalidates all stored v2 secrets.
const HKDF_INFO = 'bill-tracker-token-encryption-v1';
// Prefix that identifies ciphertext produced with HKDF key derivation.
const V2_PREFIX = 'v2:';
// Returns a stable 256-bit key. Prefers TOKEN_ENCRYPTION_KEY env var (power-user
// override); otherwise auto-generates a random key on first startup and persists
// it in the settings table so it survives restarts without any manual config.
function getKey() {
let _warnedAutoKey = false;
// Returns the raw key material (IKM) without derivation — shared by both paths.
function getIkm() {
const envRaw = process.env.TOKEN_ENCRYPTION_KEY || '';
if (envRaw) {
const buf = Buffer.from(envRaw, 'utf8');
if (buf.length < 32) throw new Error('TOKEN_ENCRYPTION_KEY must be at least 32 bytes');
return crypto.createHash('sha256').update(buf).digest();
return buf;
}
if (!_warnedAutoKey) {
_warnedAutoKey = true;
console.warn(
'[security] TOKEN_ENCRYPTION_KEY is not set. Using an auto-generated key ' +
'stored in the database alongside the encrypted data. Set TOKEN_ENCRYPTION_KEY ' +
'as an environment variable to keep the key separate from the data it protects.',
);
}
// Lazy-require to avoid circular dependency at module load time
@ -24,26 +37,43 @@ function getKey() {
stored = crypto.randomBytes(48).toString('hex');
setSetting('_auto_encryption_key', stored);
}
return crypto.createHash('sha256').update(stored, 'utf8').digest();
return Buffer.from(stored, 'utf8');
}
// Current derivation: HKDF-SHA-256 (RFC 5869). Used for all new encryptions.
function deriveKey(ikm) {
return Buffer.from(crypto.hkdfSync('sha256', ikm, /* salt */ '', HKDF_INFO, 32));
}
// Legacy derivation: raw SHA-256. Used only to decrypt pre-v0.78 ciphertext.
function deriveLegacyKey(ikm) {
return crypto.createHash('sha256').update(ikm).digest();
}
function getKey() { return deriveKey(getIkm()); }
function getLegacyKey() { return deriveLegacyKey(getIkm()); }
// No-op now that the key is always available — kept for call-site compatibility
function assertEncryptionReady() {}
// Always produces v2-format ciphertext (HKDF key derivation).
function encryptSecret(plaintext) {
const key = getKey();
const iv = crypto.randomBytes(IV_BYTES);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`;
return `${V2_PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`;
}
// Decrypts both v2 (HKDF) and legacy (SHA-256) ciphertext transparently.
function decryptSecret(stored) {
const parts = stored.split(':');
const isV2 = stored.startsWith(V2_PREFIX);
const payload = isV2 ? stored.slice(V2_PREFIX.length) : stored;
const parts = payload.split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted secret format');
const [ivHex, tagHex, ctHex] = parts;
const key = getKey();
const key = isV2 ? getKey() : getLegacyKey();
const iv = Buffer.from(ivHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const ct = Buffer.from(ctHex, 'hex');