mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
7f1cd575f4
commit
bd64b78e5d
6 files changed
+938
-6
No files matched your search
Binary file not shown.
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user