Files
HealthLog/src/lib/documents/upload-policy.ts
T
Marc-André Bombeck 55a32ff7d1 feat(documents): add a document upload token for imports from other systems
A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.

Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.

The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.

Refs #1038, #938
2026-09-25 21:48:04 +02:00

434 lines
14 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;
/** In-memory admission bounds for body accumulation in one server process. */
export const DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY = 4;
export const DOCUMENT_UPLOAD_PER_USER_CONCURRENCY = 2;
let activeDocumentUploads = 0;
const activeDocumentUploadsByUser = new Map<string, number>();
/**
* Reserve one process-local document upload slot. The returned release is
* idempotent so a defensive second cleanup cannot corrupt the counters.
*/
export function acquireDocumentUploadSlot(userId: string): (() => void) | null {
const activeForUser = activeDocumentUploadsByUser.get(userId) ?? 0;
if (
activeDocumentUploads >= DOCUMENT_UPLOAD_GLOBAL_CONCURRENCY ||
activeForUser >= DOCUMENT_UPLOAD_PER_USER_CONCURRENCY
) {
return null;
}
activeDocumentUploads += 1;
activeDocumentUploadsByUser.set(userId, activeForUser + 1);
let released = false;
return () => {
if (released) return;
released = true;
activeDocumentUploads -= 1;
const remainingForUser = (activeDocumentUploadsByUser.get(userId) ?? 1) - 1;
if (remainingForUser === 0) {
activeDocumentUploadsByUser.delete(userId);
} else {
activeDocumentUploadsByUser.set(userId, remainingForUser);
}
};
}
/**
* Hourly upload ceiling for a narrow `documents:write` token, per token.
*
* Its own bucket rather than the person's: an overnight import running at this
* rate must never use up the allowance the web app and the phone draw on. The
* default is twice the cookie bucket because an importer is strictly serial
* and is the reason the token exists. Operator-tunable via
* `DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, clamped to 1-1000 the way
* `DOCUMENT_AI_LIMIT_PER_HOUR` is, so a typo can neither shut the door nor
* remove the cap. The per-user quota, not this number, is the disk guard.
*/
const DOCUMENT_UPLOAD_TOKEN_LIMIT_DEFAULT = 120;
const DOCUMENT_UPLOAD_TOKEN_LIMIT_MIN = 1;
const DOCUMENT_UPLOAD_TOKEN_LIMIT_MAX = 1000;
export function resolveDocumentUploadLimitPerHour(): number {
const raw = process.env.DOCUMENT_UPLOAD_LIMIT_PER_HOUR;
if (!raw) return DOCUMENT_UPLOAD_TOKEN_LIMIT_DEFAULT;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed)) return DOCUMENT_UPLOAD_TOKEN_LIMIT_DEFAULT;
return Math.min(
DOCUMENT_UPLOAD_TOKEN_LIMIT_MAX,
Math.max(DOCUMENT_UPLOAD_TOKEN_LIMIT_MIN, parsed),
);
}
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;
}