pediatric-ai-scribe-v3/src/utils/fileType.js
Daniel cb17a12172 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.
2026-04-14 02:49:38 +02:00

57 lines
2.8 KiB
JavaScript

// ============================================================
// 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 };