diff --git a/tests/web/attachments.test.ts b/tests/web/attachments.test.ts new file mode 100644 index 00000000..3ecc84c6 --- /dev/null +++ b/tests/web/attachments.test.ts @@ -0,0 +1,21 @@ +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 }, + ]), { 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); +}); diff --git a/web/protocol/attachments.ts b/web/protocol/attachments.ts new file mode 100644 index 00000000..06b06af9 --- /dev/null +++ b/web/protocol/attachments.ts @@ -0,0 +1,42 @@ +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 SAFE_FILENAME = /^(?!\.\.?(?:$|\.))[\w .()\[\]-]{1,120}$/u; +const SUPPORTED_MIME = new Set([ + "text/plain", + "text/markdown", + "application/json", + "image/png", + "image/jpeg", + "image/webp", +]); + +export interface WebAttachmentInput { + readonly name: string; + readonly mime: string; + readonly size: number; +} + +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 (!SAFE_FILENAME.test(attachment.name) || attachment.name.includes("..")) { + return { ok: false as const, error: `invalid attachment name: ${attachment.name}` }; + } + if (!SUPPORTED_MIME.has(attachment.mime)) { + return { ok: false as const, error: `unsupported attachment type: ${attachment.mime}` }; + } + 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 }; +}