diff --git a/tests/web/attachments.test.ts b/tests/web/attachments.test.ts new file mode 100644 index 00000000..da32bc5d --- /dev/null +++ b/tests/web/attachments.test.ts @@ -0,0 +1,37 @@ +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, + ); +}); diff --git a/web/protocol/attachments.ts b/web/protocol/attachments.ts new file mode 100644 index 00000000..edd1b0ee --- /dev/null +++ b/web/protocol/attachments.ts @@ -0,0 +1,71 @@ +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 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; +} + +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); +} + +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 (!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 }; +}