Files
HealthLog/src/lib/documents/upload-policy.ts
T
Marc-André Bombeck bd64b78e5d feat(documents): upload policy layer and binary content codec
The policy module (src/lib/documents/upload-policy.ts) is the one source of
truth for what the vault accepts and how it serves: Class A (PDF/JPEG/PNG/
WebP/GIF) renders inline; Class B (Office OOXML+CFB, text/CSV/Markdown/RTF,
TIFF, HEIC/HEIF, XML/JSON) is stored verbatim and leaves the origin only as
an opaque attachment download; executables, HTML/XHTML/SVG, and generic
archives are denied. Detection is magic-bytes first — the wire Content-Type
is never trusted; containers (ZIP → OOXML, CFB → legacy Office) and
extension-only text formats gate on the filename plus a no-NUL heuristic.
resolveDocumentLimits() reads the admin-tunable per-file cap (clamped to
the hard 100 MiB ceiling — single-shot GCM and bounded in-memory reads are
load-bearing past that) and the quota (user override ?? instance default).

crypto.ts gains the binary2 codec: encryptBytes/decryptBytes do AES-256-GCM
over raw bytes with a versioned key-id header, skipping the base64 detour
(−33 % at rest, one fewer copy). Same fail-closed discipline as the string
codec. The store dispatches per row on the explicit contentCodec column:
new uploads write binary2, pre-existing base64v1 rows keep decoding
byte-identically, and an unknown codec throws instead of guessing.

Exhaustive unit coverage: every accept class from real magic-byte fixtures,
every deny case (incl. the PDF-with-HTML-head, SVG-as-image, and
ZIP-renamed-docx polyglots), header-matrix source of truth, limit clamping,
codec round-trips both ways, crossed-codec and unknown-codec fail-closed.

Tasks: W1-T3, W1-T4.
2026-07-07 11:04:51 +02:00

371 lines
12 KiB
TypeScript

/**
* Document-vault upload policy: the accept matrix, magic-byte sniffers, the
* serving-class function, and the admin-tunable limits resolver.
*
* Three classes, closed deny-list, serving posture as the security boundary:
*
* - Class A (inline): PDF, JPEG, PNG, WebP, GIF — browser-native passive
* formats the serve route renders `Content-Disposition: inline` (PDF
* additionally under a `Content-Security-Policy: sandbox` response
* header).
* - Class B (attachment-only): Office (OOXML + legacy CFB), text/CSV/RTF/
* Markdown, TIFF, HEIC/HEIF, XML/JSON — stored verbatim, served ONLY as
* `application/octet-stream` + `Content-Disposition: attachment` +
* `nosniff`. A misclassified Class B file therefore cannot execute in
* the app origin.
* - Deny: executables, scripts, HTML/XHTML/SVG (the only classes whose
* inline render executes script — zero medical-artefact value), generic
* archives / disk images, anything unidentifiable.
*
* The wire Content-Type is NEVER trusted anywhere — detection is magic-bytes
* first, extension only where a container format is ambiguous (ZIP → OOXML,
* CFB → legacy Office) or where no magic exists (plain-text formats). We
* never parse, rewrite, or "sanitise" stored documents: the original is the
* artefact; safety comes from what the serve route will and won't serve
* inline.
*
* The labs OCR path (`src/lib/labs/ocr-upload.ts`) keeps its own 12 MiB cap
* and narrow allowlist unchanged; this module only reuses its bounded-read
* primitive at the route layer.
*/
import { detectAvatarMimeType } from "@/lib/avatar";
import { prisma } from "@/lib/db";
// ─── Limits (§3.3) ──────────────────────────────────────────────────────────
/** Default per-file cap: 25 MiB (a ~50-page 300-dpi scan PDF, any phone photo). */
export const DOCUMENT_DEFAULT_MAX_FILE_BYTES = 26_214_400;
/**
* Hard server ceiling for the admin-tunable per-file cap: 100 MiB. Single-shot
* AES-256-GCM + bounded in-memory reads are load-bearing; past this the
* architecture (chunking, streaming crypto) would have to change, so the
* setting cannot be pushed there.
*/
export const DOCUMENT_HARD_MAX_FILE_BYTES = 104_857_600;
/** Default per-user quota: 1 GiB. */
export const DOCUMENT_DEFAULT_QUOTA_BYTES = 1_073_741_824;
export interface DocumentLimits {
/** Per-file upload cap in bytes (admin-tunable, clamped to the ceiling). */
maxFileBytes: number;
/** Per-user storage quota in bytes (user override ?? instance default). */
quotaBytes: number;
}
/**
* Resolve the effective limits for a user: `AppSettings.documentMaxFileBytes`
* clamped to the hard ceiling, and quota = `User.documentQuotaBytes` ??
* `AppSettings.documentQuotaBytes`. Missing rows fall back to the defaults.
*/
export async function resolveDocumentLimits(
userId: string,
): Promise<DocumentLimits> {
const [settings, user] = await Promise.all([
prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { documentMaxFileBytes: true, documentQuotaBytes: true },
}),
prisma.user.findUnique({
where: { id: userId },
select: { documentQuotaBytes: true },
}),
]);
const rawMax =
settings?.documentMaxFileBytes ?? DOCUMENT_DEFAULT_MAX_FILE_BYTES;
const maxFileBytes = Math.min(
Math.max(rawMax, 1),
DOCUMENT_HARD_MAX_FILE_BYTES,
);
const quotaBytes = Number(
user?.documentQuotaBytes ??
settings?.documentQuotaBytes ??
DOCUMENT_DEFAULT_QUOTA_BYTES,
);
return { maxFileBytes, quotaBytes };
}
// ─── Accept matrix (§3.1) ───────────────────────────────────────────────────
export type DocumentServingClass = "inline" | "attachment";
/** Class A — browser-native passive formats served inline. */
const CLASS_A_MIMES: ReadonlySet<string> = new Set([
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]);
/**
* The one source of truth the serve route uses: Class A renders inline with
* its true Content-Type; EVERYTHING else stored leaves the origin only as an
* opaque attachment download.
*/
export function servingClassFor(mimeType: string): DocumentServingClass {
return CLASS_A_MIMES.has(mimeType) ? "inline" : "attachment";
}
/**
* Extensions the upload UI's `accept` attribute mirrors (Class A + B minus
* HEIC — its exclusion makes the iOS picker auto-transcode camera photos to
* JPEG so the common path stays inline-previewable). Server-side policy
* remains the enforcement; this list is advisory for pickers.
*/
export const DOCUMENT_ACCEPTED_EXTENSIONS: readonly string[] = [
".pdf",
".jpg",
".jpeg",
".png",
".webp",
".gif",
".docx",
".xlsx",
".pptx",
".doc",
".xls",
".ppt",
".txt",
".md",
".csv",
".rtf",
".tif",
".tiff",
".xml",
".json",
];
/**
* Extension deny-list: executables and scripts (regardless of content),
* HTML/XHTML/SVG (inline render executes script), generic archives and disk
* images. A ZIP container is allowed ONLY under an Office extension.
*/
const DENY_EXTENSIONS: ReadonlySet<string> = new Set([
".exe",
".dll",
".bat",
".cmd",
".sh",
".ps1",
".js",
".mjs",
".jar",
".apk",
".html",
".htm",
".xhtml",
".svg",
".zip",
".7z",
".rar",
".tar",
".gz",
".iso",
".img",
".dmg",
]);
/** OOXML extensions → stored MIME (ZIP magic + Office extension required). */
const OOXML_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
[
".docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
],
[
".xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
],
[
".pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
],
]);
/** Legacy Office extensions → stored MIME (CFB magic + extension required). */
const CFB_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
[".doc", "application/msword"],
[".xls", "application/vnd.ms-excel"],
[".ppt", "application/vnd.ms-powerpoint"],
]);
/** Text-format extensions → stored MIME (heuristic-gated, no magic bytes). */
const TEXT_BY_EXTENSION: ReadonlyMap<string, string> = new Map([
[".txt", "text/plain"],
[".md", "text/plain"],
[".csv", "text/csv"],
[".xml", "application/xml"],
[".json", "application/json"],
]);
/** Lower-cased extension (with dot) of a filename, or "" when absent. */
function extensionOf(filename: string | null | undefined): string {
if (!filename) return "";
const idx = filename.lastIndexOf(".");
if (idx <= 0 || idx === filename.length - 1) return "";
return filename.slice(idx).toLowerCase();
}
function startsWith(buffer: Buffer, bytes: readonly number[]): boolean {
if (buffer.length < bytes.length) return false;
for (let i = 0; i < bytes.length; i++) {
if (buffer[i] !== bytes[i]) return false;
}
return true;
}
/** `%PDF-` */
function isPdf(buffer: Buffer): boolean {
return startsWith(buffer, [0x25, 0x50, 0x44, 0x46, 0x2d]);
}
/** `GIF87a` / `GIF89a` */
function isGif(buffer: Buffer): boolean {
return (
startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
startsWith(buffer, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
);
}
/** ZIP local-file header `PK\x03\x04` (OOXML containers; also generic ZIPs). */
function isZip(buffer: Buffer): boolean {
return startsWith(buffer, [0x50, 0x4b, 0x03, 0x04]);
}
/** Compound File Binary (legacy Office) `D0 CF 11 E0 A1 B1 1A E1`. */
function isCfb(buffer: Buffer): boolean {
return startsWith(buffer, [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]);
}
/** RTF `{\rtf` */
function isRtf(buffer: Buffer): boolean {
return startsWith(buffer, [0x7b, 0x5c, 0x72, 0x74, 0x66]);
}
/** TIFF `II*\0` (little-endian) or `MM\0*` (big-endian). */
function isTiff(buffer: Buffer): boolean {
return (
startsWith(buffer, [0x49, 0x49, 0x2a, 0x00]) ||
startsWith(buffer, [0x4d, 0x4d, 0x00, 0x2a])
);
}
/** HEIC/HEIF — ISO-BMFF `ftyp` box with a HEIF-family brand. */
const HEIF_BRANDS: ReadonlySet<string> = new Set([
"heic",
"heix",
"hevc",
"mif1",
"msf1",
]);
function isHeif(buffer: Buffer): boolean {
if (buffer.length < 12) return false;
if (buffer.toString("ascii", 4, 8) !== "ftyp") return false;
const major = buffer.toString("ascii", 8, 12).toLowerCase();
if (HEIF_BRANDS.has(major)) return true;
// Compatible-brand scan inside the ftyp box (bounded by the box length).
const boxLen = Math.min(buffer.readUInt32BE(0), buffer.length);
for (let off = 16; off + 4 <= boxLen; off += 4) {
const brand = buffer.toString("ascii", off, off + 4).toLowerCase();
if (HEIF_BRANDS.has(brand)) return true;
}
return false;
}
/** Executable magic: MZ (PE), ELF, Mach-O (32/64, both endians, fat). */
function isExecutable(buffer: Buffer): boolean {
if (startsWith(buffer, [0x4d, 0x5a])) return true; // MZ
if (startsWith(buffer, [0x7f, 0x45, 0x4c, 0x46])) return true; // ELF
const machO = [
[0xfe, 0xed, 0xfa, 0xce],
[0xfe, 0xed, 0xfa, 0xcf],
[0xce, 0xfa, 0xed, 0xfe],
[0xcf, 0xfa, 0xed, 0xfe],
[0xca, 0xfe, 0xba, 0xbe],
] as const;
return machO.some((m) => startsWith(buffer, m));
}
/**
* Plain-text heuristic for the extension-gated text formats: no NUL byte in
* the first 8 KiB and the sample decodes as UTF-8 or Latin-1 (any byte
* sequence is valid Latin-1, so the effective gate is "no NUL" — a binary
* payload smuggled under `.txt` almost always carries NULs; if one does not,
* it is still stored Class B and can only ever leave as an opaque download).
*/
function looksLikeText(buffer: Buffer): boolean {
if (buffer.length === 0) return false;
const sample = buffer.subarray(0, 8192);
return !sample.includes(0x00);
}
export interface DetectedDocumentType {
/** The MIME type the row stores (sniffed, never the wire Content-Type). */
mimeType: string;
/** Derived serving class (same as `servingClassFor(mimeType)`). */
servingClass: DocumentServingClass;
}
/**
* Classify an upload from its bytes (+ the claimed filename, used only where
* a container/text format needs an extension gate). Returns null for every
* deny case: executables, script/markup extensions, generic archives, ZIPs
* without an Office extension, and anything unidentifiable.
*/
export function detectDocumentType(
buffer: Buffer,
filename: string | null | undefined,
): DetectedDocumentType | null {
const ext = extensionOf(filename);
// Hard denies first: content-level executables, then the extension
// deny-list (an .exe named payload is refused even if its bytes look like
// text; HTML/SVG are refused regardless of content).
if (isExecutable(buffer)) return null;
if (DENY_EXTENSIONS.has(ext)) return null;
// ── Class A (magic bytes only — extension is irrelevant) ──
if (isPdf(buffer)) {
return { mimeType: "application/pdf", servingClass: "inline" };
}
if (isGif(buffer)) {
return { mimeType: "image/gif", servingClass: "inline" };
}
const image = detectAvatarMimeType(buffer);
if (image) {
return { mimeType: image, servingClass: "inline" };
}
// ── Class B (magic where it exists; containers need the extension) ──
if (isZip(buffer)) {
// A ZIP container is acceptable ONLY as an OOXML Office file; a generic
// archive (any other extension, incl. none) is denied.
const ooxml = OOXML_BY_EXTENSION.get(ext);
return ooxml ? { mimeType: ooxml, servingClass: "attachment" } : null;
}
if (isCfb(buffer)) {
const legacy = CFB_BY_EXTENSION.get(ext);
return legacy ? { mimeType: legacy, servingClass: "attachment" } : null;
}
if (isRtf(buffer)) {
return { mimeType: "application/rtf", servingClass: "attachment" };
}
if (isTiff(buffer)) {
return { mimeType: "image/tiff", servingClass: "attachment" };
}
if (isHeif(buffer)) {
return { mimeType: "image/heic", servingClass: "attachment" };
}
// ── Text formats (extension + heuristic; no magic bytes exist) ──
const text = TEXT_BY_EXTENSION.get(ext);
if (text && looksLikeText(buffer)) {
return { mimeType: text, servingClass: "attachment" };
}
// Unidentifiable → deny.
return null;
}