Security hardening: PHI encryption, argon2, DOMPurify, SRI

- App-layer AES-256-GCM crypto helper (src/utils/crypto.js)
- Nextcloud tokens encrypted at rest; transparent migration on next use
- Audio backups encrypted at rest (version byte 0x01 envelope); legacy
  rows still decrypt as-is until overwritten
- argon2id password hashing via src/utils/passwords.js with bcrypt
  fallback; bcrypt hashes rehashed to argon2id on next successful login.
  argon2 package is optional — server keeps running with bcrypt only
  until npm install adds the native dep
- PHI redactor for audit log details (src/utils/redact.js) — strips SSN,
  phone, email, DoB, long IDs; caps at 500 chars; detects note bodies
- DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in
  Learning Hub content rendering
- SRI integrity hashes added for Font Awesome CSS and Chart.js
- Magic-byte file-type verification on document uploads
  (src/utils/fileType.js)
- Generic 500 error responses via src/utils/errors.js applied to
  nextcloud and audioBackups; full detail still logged server-side
- DATA_ENCRYPTION_KEY env documented in .env.example

Deploy: requires rebuild of the container image to pick up the new
files and `npm install` (adds argon2). Existing users keep working
because bcrypt stays available and crypto helpers pass through
plaintext when the key is not yet set in dev.
This commit is contained in:
Daniel 2026-04-14 02:49:38 +02:00
parent 93bc44b5e0
commit cb17a12172
15 changed files with 332 additions and 74 deletions

View file

@ -101,6 +101,11 @@ APP_URL=https://your-domain.com
# TURNSTILE_SECRET_KEY=your-secret-key
JWT_SECRET=generate-a-random-64-char-string-here
# Application-layer encryption key for PHI at rest (Nextcloud tokens, audio backups)
# Generate with: openssl rand -hex 32
# REQUIRED in production. Rotating invalidates existing encrypted data.
DATA_ENCRYPTION_KEY=generate-with-openssl-rand-hex-32
# Email (for verification & password reset)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587

View file

@ -17,6 +17,7 @@
"@tiptap/extension-underline": "^3.20.4",
"@tiptap/starter-kit": "^3.20.4",
"axios": "^1.7.7",
"argon2": "^0.41.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",

View file

@ -9,8 +9,12 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#2563eb">
<meta name="apple-mobile-web-app-capable" content="yes">
@ -412,7 +416,9 @@
<!-- SCRIPTS (defer for faster initial paint) -->
<script src="/vendor/tiptap.bundle.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"
integrity="sha384-JUh163oCRItcbPme8pYnROHQMC6fNKTBWtRG3I3I0erJkzNgL7uxKlNwcrcFKeqF"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
<script defer src="/js/milestonesData.js"></script>
<script defer src="/js/pediatricScheduleData.js"></script>
<script defer src="/js/audioBackup.js"></script>

View file

@ -1789,55 +1789,24 @@
}
function sanitizeHtml(html) {
// Allowlist-based sanitizer: only safe tags and no event handlers
var ALLOWED_TAGS = ['p','br','b','strong','i','em','u','s','h1','h2','h3','h4','h5','h6',
'ul','ol','li','a','blockquote','code','pre','table','thead','tbody','tr','th','td',
'hr','div','span','sub','sup','dl','dt','dd'];
var ALLOWED_ATTRS = { 'a': ['href'], 'td': ['colspan','rowspan'], 'th': ['colspan','rowspan'], 'li': ['class'], 'ol': ['class'], 'ul': ['class'], 'span': ['class'], 'p': ['class'] };
var div = document.createElement('div');
div.innerHTML = html;
function clean(node) {
var children = Array.prototype.slice.call(node.childNodes);
for (var i = 0; i < children.length; i++) {
var child = children[i];
if (child.nodeType === 3) continue; // text nodes OK
if (child.nodeType !== 1) { child.remove(); continue; } // remove comments etc
var tag = child.tagName.toLowerCase();
if (ALLOWED_TAGS.indexOf(tag) === -1) {
// Replace with text content
var text = document.createTextNode(child.textContent);
node.replaceChild(text, child);
continue;
}
// Remove all attributes except explicitly allowed ones
var allowed = ALLOWED_ATTRS[tag] || [];
var attrs = Array.prototype.slice.call(child.attributes);
for (var j = 0; j < attrs.length; j++) {
if (allowed.indexOf(attrs[j].name) === -1) {
child.removeAttribute(attrs[j].name);
}
}
// For <a> tags, validate href is not javascript:
if (tag === 'a') {
var href = (child.getAttribute('href') || '').trim().toLowerCase();
if (href.indexOf('javascript:') === 0 || href.indexOf('data:') === 0 || href.indexOf('vbscript:') === 0) {
child.setAttribute('href', '#');
}
child.setAttribute('rel', 'noopener noreferrer');
child.setAttribute('target', '_blank');
}
clean(child);
}
// DOMPurify is loaded from cdnjs via <script> in index.html. If it somehow
// fails to load, refuse to render unsafe content rather than falling back
// to a regex sanitizer (historical bypass risk).
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
console.warn('[learningHub] DOMPurify unavailable — rendering as plain text.');
var d = document.createElement('div');
d.textContent = String(html == null ? '' : html);
return d.innerHTML;
}
clean(div);
return div.innerHTML;
return window.DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p','br','b','strong','i','em','u','s','h1','h2','h3','h4','h5','h6',
'ul','ol','li','a','blockquote','code','pre','table','thead','tbody','tr','th','td',
'hr','div','span','sub','sup','dl','dt','dd'],
ALLOWED_ATTR: ['href','colspan','rowspan','class','target','rel'],
ADD_ATTR: ['target'],
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
ALLOW_DATA_ATTR: false
});
}
})();

View file

@ -26,7 +26,7 @@ app.use(helmet({
// 'wasm-unsafe-eval' required for WebAssembly (Whisper in-browser transcription)
// cdn.jsdelivr.net required for @xenova/transformers worker script
// 'unsafe-eval' needed for transformers.js dynamic imports in worker
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'", 'https://cdn.jsdelivr.net', 'https://challenges.cloudflare.com'],
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'", 'https://cdn.jsdelivr.net', 'https://cdnjs.cloudflare.com', 'https://challenges.cloudflare.com'],
scriptSrcAttr: ["'none'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],

View file

@ -9,6 +9,8 @@ var zlib = require('zlib');
var multer = require('multer');
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var cryptoUtil = require('../utils/crypto');
var { serverError } = require('../utils/errors');
// 25MB upload limit (same as transcribe)
var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
@ -24,16 +26,17 @@ router.post('/audio-backups', upload.single('audio'), async function(req, res) {
var module = req.body.module || 'encounter';
var originalSize = req.file.size;
// Gzip compress the audio data
// Gzip compress, then AES-256-GCM encrypt the audio data.
var compressed = await new Promise(function(resolve, reject) {
zlib.gzip(req.file.buffer, { level: 6 }, function(err, result) {
if (err) reject(err); else resolve(result);
});
});
var stored = cryptoUtil.encryptBuffer(compressed);
var result = await db.run(
'INSERT INTO audio_backups (user_id, module, mime_type, size_bytes, compressed_bytes, audio_data) VALUES ($1,$2,$3,$4,$5,$6)',
[req.user.id, module, req.file.mimetype || 'audio/webm', originalSize, compressed.length, compressed]
[req.user.id, module, req.file.mimetype || 'audio/webm', originalSize, stored.length, stored]
);
var ratio = originalSize > 0 ? Math.round((1 - compressed.length / originalSize) * 100) : 0;
@ -41,8 +44,7 @@ router.post('/audio-backups', upload.single('audio'), async function(req, res) {
res.json({ success: true, id: result.lastInsertRowid, originalSize: originalSize, compressedSize: compressed.length });
} catch (e) {
console.error('[AudioBackup] Save error:', e.message);
res.status(500).json({ error: e.message });
return serverError(res, 'AudioBackup save', e, 'Could not save audio backup');
}
});
@ -54,7 +56,7 @@ router.get('/audio-backups', async function(req, res) {
[req.user.id]
);
res.json({ success: true, backups: rows });
} catch (e) { res.status(500).json({ error: e.message }); }
} catch (e) { return serverError(res, 'AudioBackup list', e, 'Could not list backups'); }
});
// ── GET download audio backup (decompressed) ─────────────────────────────
@ -66,9 +68,11 @@ router.get('/audio-backups/:id/audio', async function(req, res) {
);
if (!row) return res.status(404).json({ error: 'Backup not found or expired' });
// Decompress
// Decrypt (if encrypted row) then gunzip. Legacy rows are passed through.
var ciphertext = row.audio_data;
var gzipped = cryptoUtil.isEncryptedBuffer(ciphertext) ? cryptoUtil.decryptBuffer(ciphertext) : ciphertext;
var decompressed = await new Promise(function(resolve, reject) {
zlib.gunzip(row.audio_data, function(err, result) {
zlib.gunzip(gzipped, function(err, result) {
if (err) reject(err); else resolve(result);
});
});
@ -76,7 +80,7 @@ router.get('/audio-backups/:id/audio', async function(req, res) {
res.setHeader('Content-Type', row.mime_type || 'audio/webm');
res.setHeader('Content-Length', decompressed.length);
res.send(decompressed);
} catch (e) { res.status(500).json({ error: e.message }); }
} catch (e) { return serverError(res, 'AudioBackup read', e, 'Could not retrieve audio backup'); }
});
// ── DELETE audio backup ──────────────────────────────────────────────────
@ -88,7 +92,7 @@ router.delete('/audio-backups/:id', async function(req, res) {
);
if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
} catch (e) { return serverError(res, 'AudioBackup delete', e, 'Could not delete backup'); }
});
module.exports = router;

View file

@ -1,6 +1,7 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const passwords = require('../utils/passwords');
const jwt = require('jsonwebtoken');
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');
@ -191,7 +192,7 @@ router.post('/register', async (req, res) => {
var existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) return res.status(400).json({ error: 'Email already registered' });
var hash = await bcrypt.hash(password, 12);
var hash = await passwords.hash(password);
var verifyToken = crypto.randomBytes(32).toString('hex');
var verifyExpires = Date.now() + 24 * 60 * 60 * 1000;
@ -308,12 +309,17 @@ router.post('/login', async (req, res) => {
return res.status(401).json({ error: 'Invalid credentials' });
}
var valid = await bcrypt.compare(password, user.password);
var valid = await passwords.verify(password, user.password);
if (!valid) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]).catch(function(){});
return res.status(401).json({ error: 'Invalid credentials' });
}
// Transparent migration: bcrypt → argon2id on next successful login.
passwords.maybeRehash(password, user.password).then(function(newHash) {
if (newHash) db.run('UPDATE users SET password = ? WHERE id = ?', [newHash, user.id]).catch(function(){});
}).catch(function(){});
if (user.disabled) {
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[user.id, 'login_blocked', req.ip, 'Account disabled']).catch(function(){});
@ -373,7 +379,7 @@ router.post('/verify-2fa', authMiddleware, async (req, res) => {
router.post('/disable-2fa', authMiddleware, async (req, res) => {
try {
var user = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]);
var valid = await bcrypt.compare(req.body.password, user.password);
var valid = await passwords.verify(req.body.password, user.password);
if (!valid) return res.status(401).json({ error: 'Wrong password' });
await db.run('UPDATE users SET totp_enabled = false, totp_secret = NULL WHERE id = ?', [req.user.id]);
res.json({ success: true });
@ -419,7 +425,7 @@ router.post('/reset-password', async (req, res) => {
var user = await db.get('SELECT id FROM users WHERE reset_token = ? AND reset_expires > ?', [token, Date.now()]);
if (!user) return res.status(400).json({ error: 'Invalid or expired token' });
var pwnedCount = await checkPwnedPassword(newPassword);
var hash = await bcrypt.hash(newPassword, 12);
var hash = await passwords.hash(newPassword);
await db.run('UPDATE users SET password = ?, reset_token = NULL, reset_expires = NULL WHERE id = ?', [hash, user.id]);
// Force logout — destroy all sessions for this user
try { await db.run('DELETE FROM user_sessions WHERE user_id = ?', [user.id]); } catch (e) { /* best effort */ }
@ -452,11 +458,11 @@ router.post('/change-password', authMiddleware, async (req, res) => {
if (newPassword.length < 8) return res.status(400).json({ error: 'New password must be 8+ characters' });
var user = await db.get('SELECT password FROM users WHERE id = ?', [req.user.id]);
var valid = await bcrypt.compare(currentPassword, user.password);
var valid = await passwords.verify(currentPassword, user.password);
if (!valid) return res.status(401).json({ error: 'Current password is incorrect' });
var pwnedCount = await checkPwnedPassword(newPassword);
var hash = await bcrypt.hash(newPassword, 12);
var hash = await passwords.hash(newPassword);
await db.run('UPDATE users SET password = ? WHERE id = ?', [hash, req.user.id]);
// Destroy all OTHER sessions (keep current one)

View file

@ -81,6 +81,11 @@ router.post('/documents/upload', upload.single('file'), async function(req, res)
if (!ALLOWED_TYPES.includes(req.file.mimetype)) {
return res.status(400).json({ error: 'File type not allowed. Supported: PDF, images, Word docs, text, CSV' });
}
// Magic-byte verification — browser-reported MIME can be spoofed.
var fileType = require('../utils/fileType');
if (!fileType.matches(req.file.mimetype, req.file.buffer)) {
return res.status(400).json({ error: 'File contents do not match the declared type.' });
}
var { PutObjectCommand } = require('@aws-sdk/client-s3');
var prefix = process.env.S3_PREFIX || 'documents/';

View file

@ -4,6 +4,8 @@ var axios = require('axios');
var { authMiddleware } = require('../middleware/auth');
var db = require('../db/database');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto');
var { serverError } = require('../utils/errors');
router.post('/nextcloud/connect', authMiddleware, async function(req, res) {
try {
@ -27,11 +29,11 @@ router.post('/nextcloud/connect', authMiddleware, async function(req, res) {
}
await db.run('UPDATE users SET nextcloud_url = ?, nextcloud_user = ?, nextcloud_token = ?, nextcloud_folder = ? WHERE id = ?',
[cleanUrl, username, appPassword, targetFolder, req.user.id]);
[cleanUrl, username, cryptoUtil.encryptString(appPassword), targetFolder, req.user.id]);
res.json({ success: true, message: 'Connected! Files saved to ' + targetFolder + '/' });
logger.audit(req.user.id, 'nextcloud_connect', 'Connected Nextcloud', req, { category: 'integration' });
} catch (err) { res.status(500).json({ error: err.message }); }
} catch (err) { return serverError(res, 'Nextcloud connect', err, 'Could not connect to Nextcloud'); }
});
router.post('/nextcloud/export', authMiddleware, async function(req, res) {
@ -44,22 +46,31 @@ router.post('/nextcloud/export', authMiddleware, async function(req, res) {
var today = new Date().toISOString().split('T')[0];
var targetPath = baseFolder + '/' + today;
var pw;
try { pw = cryptoUtil.decryptString(user.nextcloud_token); }
catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); }
var parts = targetPath.split('/').filter(Boolean);
var current = '';
for (var i = 0; i < parts.length; i++) {
current += '/' + parts[i];
try { await axios({ method: 'MKCOL', url: user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + current + '/', auth: { username: user.nextcloud_user, password: user.nextcloud_token } }); } catch (e) {}
try { await axios({ method: 'MKCOL', url: user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + current + '/', auth: { username: user.nextcloud_user, password: pw } }); } catch (e) {}
}
var time = new Date().toTimeString().split(' ')[0].replace(/:/g, '-');
var safeName = (filename || type + '-' + time).replace(/[^a-zA-Z0-9-_]/g, '_');
var filePath = user.nextcloud_url + '/remote.php/dav/files/' + user.nextcloud_user + targetPath + '/' + safeName + '.txt';
await axios({ method: 'PUT', url: filePath, data: content, auth: { username: user.nextcloud_user, password: user.nextcloud_token }, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
await axios({ method: 'PUT', url: filePath, data: content, auth: { username: user.nextcloud_user, password: pw }, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
// Migrate legacy plaintext token to encrypted form on first successful use.
if (!cryptoUtil.isEncrypted(user.nextcloud_token)) {
db.run('UPDATE users SET nextcloud_token = ? WHERE id = ?', [cryptoUtil.encryptString(pw), req.user.id]).catch(function(){});
}
res.json({ success: true, message: 'Saved to ' + targetPath + '/' + safeName + '.txt' });
logger.audit(req.user.id, 'nextcloud_export', 'Exported note to Nextcloud', req, { category: 'export' });
} catch (err) { res.status(500).json({ error: err.message }); }
} catch (err) { return serverError(res, 'Nextcloud export', err, 'Could not export to Nextcloud'); }
});
router.post('/nextcloud/disconnect', authMiddleware, async function(req, res) {
@ -67,7 +78,7 @@ router.post('/nextcloud/disconnect', authMiddleware, async function(req, res) {
await db.run('UPDATE users SET nextcloud_url = NULL, nextcloud_user = NULL, nextcloud_token = NULL, nextcloud_folder = NULL WHERE id = ?', [req.user.id]);
res.json({ success: true });
logger.audit(req.user.id, 'nextcloud_disconnect', 'Disconnected Nextcloud', req, { category: 'integration' });
} catch (err) { res.status(500).json({ error: err.message }); }
} catch (err) { return serverError(res, 'Nextcloud disconnect', err, 'Could not disconnect'); }
});
module.exports = router;

91
src/utils/crypto.js Normal file
View file

@ -0,0 +1,91 @@
// ============================================================
// AES-256-GCM helpers for application-layer encryption of PHI
// ============================================================
// Reads DATA_ENCRYPTION_KEY from env (hex, 32 bytes / 64 hex chars).
// Generate one with: openssl rand -hex 32
// ============================================================
var crypto = require('crypto');
var KEY = null;
var raw = process.env.DATA_ENCRYPTION_KEY;
if (raw) {
if (raw.length === 64 && /^[0-9a-fA-F]+$/.test(raw)) {
KEY = Buffer.from(raw, 'hex');
} else if (raw.length >= 32) {
// Fallback: derive a 32-byte key from any string via SHA-256.
KEY = crypto.createHash('sha256').update(raw).digest();
console.warn('[crypto] DATA_ENCRYPTION_KEY is not 64 hex chars; derived via SHA-256.');
}
}
if (!KEY && (process.env.NODE_ENV === 'production' || process.env.APP_URL)) {
console.error('[FATAL] DATA_ENCRYPTION_KEY is not set. Refusing to run in production.');
process.exit(1);
}
// Ciphertext format: "enc1:" + base64( iv(12) || authTag(16) || ciphertext )
var PREFIX = 'enc1:';
function isEncrypted(value) {
return typeof value === 'string' && value.indexOf(PREFIX) === 0;
}
function encryptString(plaintext) {
if (plaintext == null) return plaintext;
if (!KEY) return plaintext; // dev mode: passthrough
var iv = crypto.randomBytes(12);
var cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
var ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
var tag = cipher.getAuthTag();
return PREFIX + Buffer.concat([iv, tag, ct]).toString('base64');
}
function decryptString(value) {
if (value == null) return value;
if (!isEncrypted(value)) return value; // plaintext (legacy row)
if (!KEY) throw new Error('Cannot decrypt: DATA_ENCRYPTION_KEY not set');
var buf = Buffer.from(String(value).slice(PREFIX.length), 'base64');
var iv = buf.subarray(0, 12);
var tag = buf.subarray(12, 28);
var ct = buf.subarray(28);
var decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
}
function encryptBuffer(buf) {
if (!buf) return buf;
if (!KEY) return buf;
var iv = crypto.randomBytes(12);
var cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
var ct = Buffer.concat([cipher.update(buf), cipher.final()]);
var tag = cipher.getAuthTag();
// Version byte (0x01) + iv(12) + tag(16) + ciphertext
return Buffer.concat([Buffer.from([0x01]), iv, tag, ct]);
}
function isEncryptedBuffer(buf) {
return Buffer.isBuffer(buf) && buf.length > 29 && buf[0] === 0x01;
}
function decryptBuffer(buf) {
if (!Buffer.isBuffer(buf)) return buf;
if (!isEncryptedBuffer(buf)) return buf; // legacy (gzip without enc wrapper)
if (!KEY) throw new Error('Cannot decrypt buffer: DATA_ENCRYPTION_KEY not set');
var iv = buf.subarray(1, 13);
var tag = buf.subarray(13, 29);
var ct = buf.subarray(29);
var decipher = crypto.createDecipheriv('aes-256-gcm', KEY, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]);
}
module.exports = {
encryptString: encryptString,
decryptString: decryptString,
isEncrypted: isEncrypted,
encryptBuffer: encryptBuffer,
decryptBuffer: decryptBuffer,
isEncryptedBuffer: isEncryptedBuffer,
hasKey: function() { return !!KEY; }
};

15
src/utils/errors.js Normal file
View file

@ -0,0 +1,15 @@
// ============================================================
// Error response helper — log full detail server-side, return
// generic message to client to avoid leaking stack traces and
// internal paths.
// ============================================================
function serverError(res, scope, err, publicMessage) {
try {
var msg = err && err.message ? err.message : String(err);
console.error('[' + scope + ']', msg, err && err.stack ? '\n' + err.stack : '');
} catch (e) {}
return res.status(500).json({ error: publicMessage || 'Request failed. Please try again.' });
}
module.exports = { serverError: serverError };

57
src/utils/fileType.js Normal file
View file

@ -0,0 +1,57 @@
// ============================================================
// Lightweight magic-byte sniffer — verifies the claimed MIME
// against the file's actual header. Covers the types the app
// currently accepts: PDF, PNG, JPEG, GIF, WEBP, DOCX/ODT (zip),
// plain text / CSV / MD (heuristic).
// ============================================================
function sniff(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 4) return 'application/octet-stream';
// PDF
if (buffer.slice(0, 4).toString('ascii') === '%PDF') return 'application/pdf';
// PNG
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return 'image/png';
// JPEG
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg';
// GIF
if (buffer.slice(0, 3).toString('ascii') === 'GIF') return 'image/gif';
// WEBP: "RIFF" .... "WEBP"
if (buffer.length >= 12 && buffer.slice(0, 4).toString('ascii') === 'RIFF' && buffer.slice(8, 12).toString('ascii') === 'WEBP') return 'image/webp';
// ZIP-based (docx, xlsx, pptx, odt)
if (buffer[0] === 0x50 && buffer[1] === 0x4b && (buffer[2] === 0x03 || buffer[2] === 0x05 || buffer[2] === 0x07)) return 'application/zip';
// Office legacy (.doc, .xls, .ppt)
if (buffer[0] === 0xd0 && buffer[1] === 0xcf && buffer[2] === 0x11 && buffer[3] === 0xe0) return 'application/x-cfb';
// Heuristic: printable ASCII / UTF-8 → text
var printable = 0;
var sample = buffer.slice(0, Math.min(512, buffer.length));
for (var i = 0; i < sample.length; i++) {
var b = sample[i];
if (b === 9 || b === 10 || b === 13 || (b >= 32 && b < 127) || b >= 128) printable++;
}
if (printable / sample.length > 0.95) return 'text/plain';
return 'application/octet-stream';
}
// Check that the claimed MIME type is compatible with the sniffed type.
function matches(claimedMime, buffer) {
var actual = sniff(buffer);
if (!claimedMime) return false;
if (claimedMime === actual) return true;
// Word docx files claim application/vnd.openxmlformats-... but sniff as zip
if (actual === 'application/zip' && (
claimedMime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
claimedMime === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
claimedMime === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ||
claimedMime === 'application/vnd.oasis.opendocument.text'
)) return true;
// Older .doc/.xls
if (actual === 'application/x-cfb' && /msword|excel|ms-office|ms-powerpoint/.test(claimedMime)) return true;
// text variants
if (actual === 'text/plain' && (claimedMime === 'text/csv' || claimedMime === 'text/markdown' || claimedMime === 'application/json' || claimedMime === 'text/plain')) return true;
return false;
}
module.exports = { sniff: sniff, matches: matches };

View file

@ -1,6 +1,7 @@
const fs = require('fs');
const path = require('path');
const db = require('../db/database');
const { redact } = require('./redact');
var LOG_DIR = path.join(__dirname, '../../data/logs');
if (!fs.existsSync(LOG_DIR)) {
@ -46,11 +47,12 @@ var logger = {
audit: function(userId, action, details, req, extra) {
var e = extra || {};
var safeDetails = redact(typeof details === 'string' ? details : JSON.stringify(details));
db.run(
'INSERT INTO audit_log (user_id, action, category, details, ip_address, user_agent, model_used, tokens_used, duration_ms, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
userId || null, action, e.category || 'general',
typeof details === 'string' ? details : JSON.stringify(details),
safeDetails,
req ? (req.ip || null) : null,
req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
e.model || null, e.tokens || null, e.duration || null, e.status || 'success'
@ -59,7 +61,7 @@ var logger = {
pushToLoki(
{ type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' },
JSON.stringify({
userId: userId, action: action, details: details,
userId: userId, action: action, details: safeDetails,
ip: req ? req.ip : null,
userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null,
sessionId: req ? (req.sessionId || null) : null,

52
src/utils/passwords.js Normal file
View file

@ -0,0 +1,52 @@
// ============================================================
// Unified password hashing — argon2id preferred, bcrypt fallback
// Transparent migration: on successful login with a bcrypt hash,
// the row is rehashed as argon2id and updated in place.
// ============================================================
var bcrypt = require('bcryptjs');
var argon2 = null;
try {
argon2 = require('argon2');
} catch (e) {
// argon2 optional — install with `npm install argon2` to enable.
// Until then, registration + change-password fall back to bcrypt(12).
console.warn('[passwords] argon2 not installed — using bcrypt only. Install argon2 to enable migration.');
}
var BCRYPT_ROUNDS = 12;
var ARGON2_OPTS = {
type: 2, // argon2id
memoryCost: 19456, // 19 MiB — OWASP 2023 recommended
timeCost: 2,
parallelism: 1
};
function isArgon2Hash(h) { return typeof h === 'string' && h.indexOf('$argon2') === 0; }
function isBcryptHash(h) { return typeof h === 'string' && /^\$2[aby]\$/.test(h); }
async function hash(password) {
if (argon2) return argon2.hash(password, ARGON2_OPTS);
return bcrypt.hash(password, BCRYPT_ROUNDS);
}
async function verify(password, storedHash) {
if (!storedHash) return false;
if (isArgon2Hash(storedHash)) {
if (!argon2) throw new Error('argon2 hash encountered but argon2 package not installed');
return argon2.verify(storedHash, password);
}
if (isBcryptHash(storedHash)) {
return bcrypt.compare(password, storedHash);
}
return false;
}
// Returns a new argon2 hash if migration is desired, else null.
async function maybeRehash(password, storedHash) {
if (!argon2) return null;
if (isArgon2Hash(storedHash)) return null; // already argon2
return argon2.hash(password, ARGON2_OPTS);
}
module.exports = { hash: hash, verify: verify, maybeRehash: maybeRehash, hasArgon2: function(){ return !!argon2; } };

34
src/utils/redact.js Normal file
View file

@ -0,0 +1,34 @@
// ============================================================
// PHI redactor for audit log details
// ============================================================
// Audit details should describe *what happened*, not contain clinical
// text. This defensively strips obvious PHI patterns and caps length.
// ============================================================
var MAX_LEN = 500;
function redact(text) {
if (text == null) return text;
var s = typeof text === 'string' ? text : JSON.stringify(text);
// SSN
s = s.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]');
// US phone numbers
s = s.replace(/\b\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b/g, '[PHONE]');
// Email addresses (except domain-only)
s = s.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[EMAIL]');
// Dates of birth / MRN-ish long digit runs
s = s.replace(/\b\d{2}\/\d{2}\/\d{4}\b/g, '[DATE]');
s = s.replace(/\b\d{8,}\b/g, '[ID]');
// Note-body signal: many newlines or very long prose → indicates the
// caller passed a clinical note as `details`. Truncate aggressively.
var newlines = (s.match(/\n/g) || []).length;
if (newlines > 4 || s.length > MAX_LEN * 2) {
s = s.slice(0, 120) + ' [TRUNCATED:possible-note-body]';
}
if (s.length > MAX_LEN) s = s.slice(0, MAX_LEN) + '…';
return s;
}
module.exports = { redact: redact };