v0.35.0
This commit is contained in:
parent
31bafb0e55
commit
67ce59db50
20
.env.example
20
.env.example
|
|
@ -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.
|
||||
|
|
|
|||
26
HISTORY.md
26
HISTORY.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bill-tracker",
|
||||
"version": "0.34.3",
|
||||
"version": "0.35.0",
|
||||
"description": "Monthly bill tracking system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue