diff --git a/tests/web/attachments.test.ts b/tests/web/attachments.test.ts new file mode 100644 index 00000000..e21f08e0 --- /dev/null +++ b/tests/web/attachments.test.ts @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { validateWebAttachments } from "../../web/protocol/attachments.ts"; + +test("attachment validation accepts bounded supported files", () => { + assert.deepEqual( + validateWebAttachments([ + { name: "notes.md", mime: "text/markdown", size: 100 }, + { name: "shot.PNG", mime: "image/png", size: 200 }, + { name: "说明.txt", mime: "text/plain", size: 12 }, + ]), + { ok: true }, + ); +}); + +test("attachment validation rejects traversal, unsupported types, and oversized totals", () => { + assert.equal( + validateWebAttachments([{ name: "../secret", mime: "text/plain", size: 1 }]) + .ok, + false, + ); + assert.equal( + validateWebAttachments([ + { name: "x.bin", mime: "application/octet-stream", size: 1 }, + ]).ok, + false, + ); + assert.equal( + validateWebAttachments([ + { name: "a.txt", mime: "text/plain", size: 2 * 1024 * 1024 }, + { name: "b.txt", mime: "text/plain", size: 2 * 1024 * 1024 }, + { name: "c.txt", mime: "text/plain", size: 2 * 1024 * 1024 }, + { name: "d.txt", mime: "text/plain", size: 2 * 1024 * 1024 + 1 }, + ]).ok, + false, + ); +}); + +test("attachment validation rejects inherited MIME keys without throwing", () => { + for (const mime of ["constructor", "__proto__", "toString"]) { + assert.deepEqual( + validateWebAttachments([{ name: "x.txt", mime, size: 1 }]), + { ok: false, error: "unsupported attachment type" }, + ); + } +}); + +test("attachment validation rejects MIME and extension mismatch", () => { + assert.deepEqual( + validateWebAttachments([ + { name: "notes.txt", mime: "text/markdown", size: 1 }, + ]), + { ok: false, error: "attachment extension does not match type" }, + ); +}); diff --git a/web/protocol/attachments.ts b/web/protocol/attachments.ts new file mode 100644 index 00000000..07edf27f --- /dev/null +++ b/web/protocol/attachments.ts @@ -0,0 +1,89 @@ +export const WEB_MAX_ATTACHMENTS = 8; +export const WEB_MAX_ATTACHMENT_BYTES = 2 * 1024 * 1024; +export const WEB_MAX_ATTACHMENT_TOTAL_BYTES = 8 * 1024 * 1024; + +const MIME_EXTENSIONS = new Map([ + ["text/plain", [".txt", ".log"]], + ["text/markdown", [".md", ".markdown"]], + ["application/json", [".json"]], + ["image/png", [".png"]], + ["image/jpeg", [".jpg", ".jpeg"]], + ["image/webp", [".webp"]], +]); + +export interface WebAttachmentInput { + readonly name: string; + readonly mime: string; + readonly size: number; +} + +function isSafeAttachmentName(name: string) { + if (name.length < 1 || name.length > 120) return false; + if (name === "." || name === "..") return false; + if (name.includes("..") || name.includes("/") || name.includes("\\")) { + return false; + } + return !/[\u0000-\u001f\u007f]/u.test(name); +} + +function extensionOf(name: string) { + const separator = name.lastIndexOf("."); + if (separator <= 0 || separator === name.length - 1) return ""; + return name.slice(separator).toLowerCase(); +} + +function matchesMimeExtension(name: string, mime: string) { + const extensions = MIME_EXTENSIONS.get(mime); + if (!extensions) return false; + return extensions.includes(extensionOf(name)); +} + +export function validateWebAttachments( + attachments: readonly WebAttachmentInput[], +) { + if (attachments.length > WEB_MAX_ATTACHMENTS) { + return { + ok: false as const, + error: `at most ${WEB_MAX_ATTACHMENTS} attachments are allowed`, + }; + } + let total = 0; + for (const attachment of attachments) { + if (!isSafeAttachmentName(attachment.name)) { + return { + ok: false as const, + error: `invalid attachment name: ${attachment.name}`, + }; + } + if (!MIME_EXTENSIONS.has(attachment.mime)) { + return { + ok: false as const, + error: "unsupported attachment type", + }; + } + if (!matchesMimeExtension(attachment.name, attachment.mime)) { + return { + ok: false as const, + error: "attachment extension does not match type", + }; + } + if ( + !Number.isSafeInteger(attachment.size) || + attachment.size < 0 || + attachment.size > WEB_MAX_ATTACHMENT_BYTES + ) { + return { + ok: false as const, + error: `attachment exceeds ${WEB_MAX_ATTACHMENT_BYTES} byte limit`, + }; + } + total += attachment.size; + if (total > WEB_MAX_ATTACHMENT_TOTAL_BYTES) { + return { + ok: false as const, + error: `attachments exceed ${WEB_MAX_ATTACHMENT_TOTAL_BYTES} byte total`, + }; + } + } + return { ok: true as const }; +}