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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 11:04:51 +02:00
1 parent 7f1cd575f4
commit bd64b78e5d
6 files changed
+938 -6

No files matched your search

Binary file not shown.
+99
View File
@@ -264,6 +264,105 @@ export function reencryptToActive(encoded: string): string {
return encrypt(decrypt(encoded));
}
// ─── Binary codec ("binary2") ────────────────────────────────────────────────
//
// AES-256-GCM over raw bytes, skipping the base64 detour the string codec
// takes (−33 % at rest, one fewer full copy in memory). Written for large
// binary payloads (the document vault's `contentEncrypted`); the consumer
// records which codec a row uses in an explicit column — the layout is never
// header-sniffed against the string codec.
//
// Layout: [version 0x02][keyIdLen u8][keyId ascii][iv 12][tag 16][ciphertext]
//
// Same versioned-key discipline as the string codec: encrypt always writes
// the active key id, decrypt is fail-closed on an unknown version, a
// malformed header, or an unconfigured key id.
/** Version byte identifying the binary2 layout. */
const BYTES_CODEC_VERSION = 0x02;
/** Encrypt raw bytes with the active key into the binary2 layout. */
export function encryptBytes(plaintext: Buffer): Buffer {
const { id, key } = getActiveKey();
const keyId = Buffer.from(id, "ascii");
if (keyId.byteLength < 1 || keyId.byteLength > 32) {
// loadKeys() already constrains ids to [A-Za-z0-9_-]{1,32}; belt-and-braces.
throw new Error(`Encryption key id '${id}' is not header-encodable`);
}
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
const header = Buffer.from([BYTES_CODEC_VERSION, keyId.byteLength]);
return Buffer.concat([header, keyId, iv, tag, ct]);
}
/** Parse a binary2 header. Throws on any malformation (fail-closed). */
function parseBytesHeader(payload: Buffer): {
keyId: string;
iv: Buffer;
tag: Buffer;
ct: Buffer;
} {
if (payload.byteLength < 2) {
throw new Error("binary2 payload is truncated (no header)");
}
if (payload[0] !== BYTES_CODEC_VERSION) {
throw new Error(
`Unknown binary ciphertext version ${payload[0]} (expected ${BYTES_CODEC_VERSION})`,
);
}
const keyIdLen = payload[1];
if (keyIdLen < 1 || keyIdLen > 32) {
throw new Error("binary2 payload has an invalid key-id length");
}
const fixed = 2 + keyIdLen + IV_LENGTH + AUTH_TAG_LENGTH;
if (payload.byteLength < fixed) {
throw new Error("binary2 payload is truncated");
}
const keyId = payload.subarray(2, 2 + keyIdLen).toString("ascii");
if (!/^[A-Za-z0-9_-]{1,32}$/.test(keyId)) {
throw new Error("binary2 payload carries a malformed key id");
}
const iv = payload.subarray(2 + keyIdLen, 2 + keyIdLen + IV_LENGTH);
const tag = payload.subarray(2 + keyIdLen + IV_LENGTH, fixed);
const ct = payload.subarray(fixed);
return { keyId, iv, tag, ct };
}
/**
* Decrypt a binary2 payload back to its raw bytes. Fail-closed: an unknown
* version, a malformed header, or an unconfigured key id throws — the caller
* must treat a throw as "cannot serve", never fall back to the ciphertext.
*/
export function decryptBytes(payload: Buffer): Buffer {
const { keyId, iv, tag, ct } = parseBytesHeader(payload);
const key = getKeyById(keyId);
if (!key) {
throw new Error(
`Encryption key id '${keyId}' is not configured. Add it to ` +
`ENCRYPTION_KEYS before decrypting rows written under that key.`,
);
}
const dec = createDecipheriv(ALGORITHM, key, iv);
dec.setAuthTag(tag);
return Buffer.concat([dec.update(ct), dec.final()]);
}
/** The key id a binary2 payload was written under, or null when unparsable. */
export function extractKeyIdFromBytes(payload: Buffer): string | null {
try {
return parseBytesHeader(payload).keyId;
} catch {
return null;
}
}
/** Re-encrypt a binary2 payload with the active key. Used by rotation. */
export function reencryptBytesToActive(payload: Buffer): Buffer {
return encryptBytes(decryptBytes(payload));
}
/** Returns the key id portion of a versioned ciphertext, or null for legacy. */
export function extractKeyId(encoded: string): string | null {
const dot = encoded.indexOf(".");
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Buffer } from "node:buffer";
/**
* Document content codec dispatch: new uploads write "binary2", pre-vault
* rows keep decoding via the legacy "base64v1" string path, and an unknown
* codec value fails closed instead of guessing.
*/
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
import { _resetCryptoCacheForTests } from "@/lib/crypto";
import {
ACTIVE_DOCUMENT_CODEC,
decryptDocumentContent,
decryptDocumentFromBytes,
encryptDocumentContent,
encryptDocumentToBytes,
} from "../store";
const KEY_V1 =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const SAMPLE = Buffer.concat([
Buffer.from("%PDF-1.7\n"),
Buffer.from([0x00, 0xff, 0x80, 0x7f]),
Buffer.alloc(2048, 0xab),
]);
beforeEach(() => {
vi.unstubAllEnvs();
vi.stubEnv("ENCRYPTION_KEYS", "");
vi.stubEnv("ENCRYPTION_ACTIVE_KEY_ID", "");
vi.stubEnv("ENCRYPTION_KEY", KEY_V1);
_resetCryptoCacheForTests();
});
describe("document content codecs", () => {
it("writes new uploads with the binary2 codec and round-trips them", () => {
const { content, codec } = encryptDocumentContent(SAMPLE);
expect(codec).toBe("binary2");
expect(codec).toBe(ACTIVE_DOCUMENT_CODEC);
expect(Buffer.from(content).equals(SAMPLE)).toBe(false);
expect(decryptDocumentContent(content, codec).equals(SAMPLE)).toBe(true);
});
it("still decodes legacy base64v1 rows byte-identically", () => {
// A pre-vault row: base64-of-binary → encrypt() string → UTF-8 bytes.
const legacy = encryptDocumentToBytes(SAMPLE);
expect(decryptDocumentContent(legacy, "base64v1").equals(SAMPLE)).toBe(
true,
);
// The legacy helper itself stays the exact inverse.
expect(decryptDocumentFromBytes(legacy).equals(SAMPLE)).toBe(true);
});
it("binary2 is ~25 % smaller at rest than the base64 detour", () => {
const legacy = encryptDocumentToBytes(SAMPLE);
const { content } = encryptDocumentContent(SAMPLE);
expect(content.byteLength).toBeLessThan(legacy.byteLength * 0.8);
});
it("fails closed on an unknown codec value", () => {
const { content } = encryptDocumentContent(SAMPLE);
expect(() => decryptDocumentContent(content, "codec9")).toThrow(
/unknown document content codec/i,
);
expect(() => decryptDocumentContent(content, "")).toThrow();
});
it("fails closed when codecs are crossed (no silent fallback)", () => {
const legacy = encryptDocumentToBytes(SAMPLE);
const { content: binary } = encryptDocumentContent(SAMPLE);
// Legacy bytes read as binary2 → header parse fails.
expect(() => decryptDocumentContent(legacy, "binary2")).toThrow();
// Binary bytes read as base64v1 → string decrypt fails.
expect(() => decryptDocumentContent(binary, "base64v1")).toThrow();
});
});
@@ -0,0 +1,326 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* Document-vault policy layer: exhaustive accept-matrix coverage.
*
* Every Class A and Class B format is detected from real magic-byte
* fixtures, every deny case is denied, the wire Content-Type never plays a
* role (the detector does not even receive it), container ambiguity (ZIP →
* OOXML only under an Office extension) is pinned, adversarial polyglots
* behave as designed, and the limits resolver clamps to the hard ceiling.
*/
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
import { prisma } from "@/lib/db";
import {
DOCUMENT_DEFAULT_MAX_FILE_BYTES,
DOCUMENT_DEFAULT_QUOTA_BYTES,
DOCUMENT_HARD_MAX_FILE_BYTES,
detectDocumentType,
resolveDocumentLimits,
servingClassFor,
} from "../upload-policy";
// ─── Magic-byte fixtures ─────────────────────────────────────────────────────
const PDF = Buffer.from("%PDF-1.7\n%âãÏÓ\n1 0 obj\n", "latin1");
const JPEG = Buffer.concat([
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
Buffer.alloc(16, 0x11),
]);
const PNG = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(16, 0x22),
]);
const WEBP = Buffer.concat([
Buffer.from("RIFF"),
Buffer.from([0x24, 0x00, 0x00, 0x00]),
Buffer.from("WEBPVP8 "),
Buffer.alloc(8, 0x33),
]);
const GIF87 = Buffer.concat([Buffer.from("GIF87a"), Buffer.alloc(16, 0x44)]);
const GIF89 = Buffer.concat([Buffer.from("GIF89a"), Buffer.alloc(16, 0x44)]);
const ZIP = Buffer.concat([
Buffer.from([0x50, 0x4b, 0x03, 0x04]),
Buffer.alloc(32, 0x55),
]);
const CFB = Buffer.concat([
Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
Buffer.alloc(32, 0x66),
]);
const RTF = Buffer.from("{\\rtf1\\ansi Hello}", "latin1");
const TIFF_LE = Buffer.concat([
Buffer.from([0x49, 0x49, 0x2a, 0x00]),
Buffer.alloc(16, 0x77),
]);
const TIFF_BE = Buffer.concat([
Buffer.from([0x4d, 0x4d, 0x00, 0x2a]),
Buffer.alloc(16, 0x77),
]);
/** ISO-BMFF ftyp box with the given major + compatible brands. */
function ftyp(major: string, compat: string[] = []): Buffer {
const brands = Buffer.from([major, "0000", ...compat].join(""), "ascii");
const len = Buffer.alloc(4);
len.writeUInt32BE(8 + brands.byteLength);
return Buffer.concat([len, Buffer.from("ftyp"), brands, Buffer.alloc(8)]);
}
const TEXT = Buffer.from("Befund: alles unauffällig.\nZeile 2\n", "utf8");
const MZ = Buffer.concat([Buffer.from("MZ"), Buffer.alloc(64, 0x90)]);
const ELF = Buffer.concat([
Buffer.from([0x7f, 0x45, 0x4c, 0x46]),
Buffer.alloc(32, 0),
]);
const MACHO = Buffer.concat([
Buffer.from([0xcf, 0xfa, 0xed, 0xfe]),
Buffer.alloc(32, 0),
]);
describe("detectDocumentType — Class A (inline; magic bytes only)", () => {
it.each([
["report.pdf", PDF, "application/pdf"],
["photo.jpg", JPEG, "image/jpeg"],
["scan.png", PNG, "image/png"],
["scan.webp", WEBP, "image/webp"],
["anim87.gif", GIF87, "image/gif"],
["anim89.gif", GIF89, "image/gif"],
])("%s → %s inline", (name, bytes, mime) => {
expect(detectDocumentType(bytes, name)).toEqual({
mimeType: mime,
servingClass: "inline",
});
});
it("classifies by bytes, not extension (JPEG named .dat is still a JPEG)", () => {
expect(detectDocumentType(JPEG, "export.dat")).toEqual({
mimeType: "image/jpeg",
servingClass: "inline",
});
});
});
describe("detectDocumentType — Class B (attachment-only)", () => {
it.each([
[
"letter.docx",
ZIP,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
],
[
"values.xlsx",
ZIP,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
],
[
"slides.pptx",
ZIP,
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
],
["letter.doc", CFB, "application/msword"],
["values.xls", CFB, "application/vnd.ms-excel"],
["slides.ppt", CFB, "application/vnd.ms-powerpoint"],
["note.rtf", RTF, "application/rtf"],
["scan.tif", TIFF_LE, "image/tiff"],
["scan.tiff", TIFF_BE, "image/tiff"],
["befund.txt", TEXT, "text/plain"],
["befund.md", TEXT, "text/plain"],
["werte.csv", TEXT, "text/csv"],
["export.xml", TEXT, "application/xml"],
["export.json", TEXT, "application/json"],
])("%s → %s attachment", (name, bytes, mime) => {
expect(detectDocumentType(bytes, name)).toEqual({
mimeType: mime,
servingClass: "attachment",
});
});
it.each([
["heic (major brand)", ftyp("heic")],
["heix (major brand)", ftyp("heix")],
["hevc (major brand)", ftyp("hevc")],
["mif1 (major brand)", ftyp("mif1")],
["msf1 (major brand)", ftyp("msf1")],
["compatible-brand heic", ftyp("isom", ["heic"])],
])("HEIC/HEIF %s → image/heic attachment", (_label, bytes) => {
expect(detectDocumentType(bytes, "photo.heic")).toEqual({
mimeType: "image/heic",
servingClass: "attachment",
});
});
});
describe("detectDocumentType — deny", () => {
it.each([
["MZ executable", MZ, "payload.pdf"],
["ELF executable", ELF, "scan.png"],
["Mach-O executable", MACHO, "report.txt"],
])("%s is denied regardless of a friendly extension", (_l, bytes, name) => {
expect(detectDocumentType(bytes, name)).toBeNull();
});
it.each([
".exe",
".dll",
".bat",
".cmd",
".sh",
".ps1",
".js",
".mjs",
".jar",
".apk",
".html",
".htm",
".xhtml",
".svg",
".zip",
".7z",
".rar",
".tar",
".gz",
".iso",
".img",
".dmg",
])("extension %s is denied even with harmless text bytes", (ext) => {
expect(detectDocumentType(TEXT, `file${ext}`)).toBeNull();
});
it("denies a ZIP container without an Office extension (incl. no name)", () => {
expect(detectDocumentType(ZIP, "archive.zip")).toBeNull();
expect(detectDocumentType(ZIP, "archive")).toBeNull();
expect(detectDocumentType(ZIP, null)).toBeNull();
// CFB without a legacy-Office extension is equally refused.
expect(detectDocumentType(CFB, "blob.bin")).toBeNull();
});
it("denies text-extension files smuggling NUL bytes", () => {
const binaryish = Buffer.concat([TEXT, Buffer.from([0x00]), TEXT]);
expect(detectDocumentType(binaryish, "befund.txt")).toBeNull();
});
it("denies unidentifiable bytes and empty files", () => {
expect(detectDocumentType(Buffer.from([1, 2, 3, 4, 5, 6]), "x.bin")).toBe(
null,
);
expect(detectDocumentType(Buffer.alloc(0), "empty.txt")).toBeNull();
// Text-like bytes under a non-text extension stay unidentifiable.
expect(detectDocumentType(TEXT, "notes.abc")).toBeNull();
});
it("adversarial: SVG content cannot sneak in under an image extension", () => {
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>');
expect(detectDocumentType(svg, "image.svg")).toBeNull(); // ext deny
expect(detectDocumentType(svg, "image.png")).toBeNull(); // no PNG magic, no text ext
});
it("adversarial: HTML under a text extension stays attachment-only", () => {
// Accepted as text/plain — but Class B never renders inline, so the
// markup can never execute in-origin.
const html = Buffer.from("<!doctype html><script>alert(1)</script>");
expect(detectDocumentType(html, "page.txt")).toEqual({
mimeType: "text/plain",
servingClass: "attachment",
});
expect(detectDocumentType(html, "page.html")).toBeNull();
});
it("adversarial: a PDF with an HTML head is still a PDF (sandboxed inline)", () => {
const polyglot = Buffer.concat([
Buffer.from("%PDF-1.4\n"),
Buffer.from("<script>alert(1)</script>"),
]);
expect(detectDocumentType(polyglot, "poly.pdf")).toEqual({
mimeType: "application/pdf",
servingClass: "inline",
});
});
});
describe("servingClassFor", () => {
it("marks exactly the browser-native passive set inline", () => {
for (const mime of [
"application/pdf",
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]) {
expect(servingClassFor(mime)).toBe("inline");
}
});
it("marks every other stored type attachment (incl. unknowns)", () => {
for (const mime of [
"image/heic",
"image/tiff",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"text/plain",
"text/csv",
"application/rtf",
"application/xml",
"application/json",
"text/html", // must never happen at rest, but even then: attachment
"image/svg+xml",
"application/octet-stream",
]) {
expect(servingClassFor(mime)).toBe("attachment");
}
});
});
describe("resolveDocumentLimits", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("falls back to the defaults with no settings row and no override", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
await expect(resolveDocumentLimits("u1")).resolves.toEqual({
maxFileBytes: DOCUMENT_DEFAULT_MAX_FILE_BYTES,
quotaBytes: DOCUMENT_DEFAULT_QUOTA_BYTES,
});
});
it("clamps the per-file cap to the hard 100 MiB ceiling", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
documentMaxFileBytes: 999_999_999_999,
documentQuotaBytes: BigInt(DOCUMENT_DEFAULT_QUOTA_BYTES),
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
const limits = await resolveDocumentLimits("u1");
expect(limits.maxFileBytes).toBe(DOCUMENT_HARD_MAX_FILE_BYTES);
});
it("prefers the per-user quota override over the instance default", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
documentMaxFileBytes: 26_214_400,
documentQuotaBytes: BigInt(2_000_000_000),
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentQuotaBytes: BigInt(50_000_000),
} as never);
const limits = await resolveDocumentLimits("u1");
expect(limits.quotaBytes).toBe(50_000_000);
});
it("uses the instance quota when the user carries no override", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
documentMaxFileBytes: 26_214_400,
documentQuotaBytes: BigInt(2_000_000_000),
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
documentQuotaBytes: null,
} as never);
const limits = await resolveDocumentLimits("u1");
expect(limits.quotaBytes).toBe(2_000_000_000);
});
});
+59 -6
View File
@@ -9,14 +9,16 @@
import { Buffer } from "node:buffer";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { decrypt, encrypt } from "@/lib/crypto";
import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto";
import type {
ExtractedFact,
ExtractedFactStatus,
InboundDocument,
InboundDocumentStatus,
} from "@/generated/prisma/client";
import { servingClassFor } from "@/lib/documents/upload-policy";
import type {
DocumentConditionLinkDto,
ExtractedFactDto,
FactData,
FactProvenance,
@@ -26,6 +28,20 @@ import type {
InboundFactType,
} from "@/lib/validations/inbound-documents";
/**
* The two storage codecs `InboundDocument.contentEncrypted` may carry,
* recorded per row in the explicit `contentCodec` column (never sniffed):
* - "base64v1" — the legacy string path (base64-of-binary → `encrypt()`
* string → UTF-8 bytes). Pre-vault rows only; kept read-compatible.
* - "binary2" — the binary AES-256-GCM layout (`encryptBytes()`), no
* base64 detour. Every new upload writes this.
*/
export const DOCUMENT_CONTENT_CODECS = ["base64v1", "binary2"] as const;
export type DocumentContentCodec = (typeof DOCUMENT_CONTENT_CODECS)[number];
/** The codec every NEW upload is written with. */
export const ACTIVE_DOCUMENT_CODEC: DocumentContentCodec = "binary2";
/** Encrypt the raw document bytes into the `Bytes` payload the schema stores. */
export function encryptDocumentToBytes(bytes: Buffer): Uint8Array<ArrayBuffer> {
const ciphertext = encrypt(bytes.toString("base64"));
@@ -49,6 +65,31 @@ export function decryptDocumentFromBytes(buf: Uint8Array): Buffer {
return Buffer.from(base64, "base64");
}
/**
* Encrypt raw document bytes with the ACTIVE codec (binary2). Returns the
* `Bytes` payload plus the codec label the row must persist alongside it.
*/
export function encryptDocumentContent(bytes: Buffer): {
content: Uint8Array<ArrayBuffer>;
codec: DocumentContentCodec;
} {
const encrypted = encryptBytes(bytes);
const out = new Uint8Array(new ArrayBuffer(encrypted.byteLength));
out.set(encrypted);
return { content: out, codec: ACTIVE_DOCUMENT_CODEC };
}
/**
* Decrypt a stored document's payload, dispatching on the row's persisted
* codec. Fail-closed: an unknown codec value throws — a silent fallback to
* either codec would either return garbage or mask data corruption.
*/
export function decryptDocumentContent(buf: Uint8Array, codec: string): Buffer {
if (codec === "base64v1") return decryptDocumentFromBytes(buf);
if (codec === "binary2") return decryptBytes(Buffer.from(buf));
throw new Error(`Unknown document content codec '${codec}'`);
}
/**
* Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the
* schema stores. The structured clinical values (diagnosis text, lab values,
@@ -80,10 +121,19 @@ export function decryptFactProvenance(buf: Uint8Array): FactProvenance {
return JSON.parse(decryptFromBytes(buf)) as FactProvenance;
}
/** Map a persisted document row (+ counts) to the list/detail DTO. */
/**
* The row fields the serialisers read. Deliberately WITHOUT
* `contentEncrypted`: the list path must never select the blob column
* (hardening — see the list route's `omit`), so the DTO mappers cannot
* require it either.
*/
export type SerialisableDocument = Omit<InboundDocument, "contentEncrypted">;
/** Map a persisted document row (+ counts + links) to the list/detail DTO. */
export function serialiseDocument(
doc: InboundDocument,
doc: SerialisableDocument,
counts: { factCount: number; pendingCount: number },
conditionLinks: DocumentConditionLinkDto[] = [],
): InboundDocumentDto {
return {
id: doc.id,
@@ -103,6 +153,8 @@ export function serialiseDocument(
errorReason: doc.errorReason,
factCount: counts.factCount,
pendingCount: counts.pendingCount,
conditionLinks,
servingClass: servingClassFor(doc.mimeType),
createdAt: doc.createdAt.toISOString(),
updatedAt: doc.updatedAt.toISOString(),
};
@@ -123,17 +175,18 @@ export function serialiseFact(fact: ExtractedFact): ExtractedFactDto {
};
}
/** Assemble the detail DTO (document + its staged facts). */
/** Assemble the detail DTO (document + its staged facts + links). */
export function serialiseDocumentDetail(
doc: InboundDocument,
doc: SerialisableDocument,
facts: ExtractedFact[],
conditionLinks: DocumentConditionLinkDto[] = [],
): InboundDocumentDetailDto {
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
// of the document's tally) so the badge matches the list query.
const factCount = facts.filter((f) => f.status !== "REJECTED").length;
return {
...serialiseDocument(doc, { factCount, pendingCount }),
...serialiseDocument(doc, { factCount, pendingCount }, conditionLinks),
facts: facts.map(serialiseFact),
};
}
+370
View File
@@ -0,0 +1,370 @@
/**
* 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;
}