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:
parent
93bc44b5e0
commit
cb17a12172
15 changed files with 332 additions and 74 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/';
|
||||
|
|
|
|||
|
|
@ -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
91
src/utils/crypto.js
Normal 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
15
src/utils/errors.js
Normal 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
57
src/utils/fileType.js
Normal 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 };
|
||||
|
|
@ -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
52
src/utils/passwords.js
Normal 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
34
src/utils/redact.js
Normal 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 };
|
||||
Loading…
Reference in a new issue