From 339198f4aa111109565c831d5c9725ff63e6e9e3 Mon Sep 17 00:00:00 2001 From: seekskyworld Date: Sun, 13 Sep 2026 14:05:18 +0800 Subject: [PATCH 1/2] feat(web): add bounded attachment admission rules Validate count, MIME, filename, and byte limits as a pure admission contract. Allow ordinary Unicode names while rejecting traversal and control characters. This slice remains unused until staging/upload calls it. --- tests/web/attachments.test.ts | 37 ++++++++++++++++++ web/protocol/attachments.ts | 71 +++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 tests/web/attachments.test.ts create mode 100644 web/protocol/attachments.ts 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 }; +} From 19f01fa136fa3226f064babe951713d07d737a71 Mon Sep 17 00:00:00 2001 From: seekskyworld Date: Sun, 13 Sep 2026 14:05:54 +0800 Subject: [PATCH 2/2] fix(web): validate attachment filename types Require MIME types to come from an own-property whitelist Map and match the ASCII-folded filename extension. Inherited keys such as constructor are rejected instead of throwing. --- tests/web/attachments.test.ts | 20 ++++++++++++++++++- web/protocol/attachments.ts | 36 ++++++++++++++++++++++++++--------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/tests/web/attachments.test.ts b/tests/web/attachments.test.ts index da32bc5d..e21f08e0 100644 --- a/tests/web/attachments.test.ts +++ b/tests/web/attachments.test.ts @@ -6,7 +6,7 @@ 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: "shot.PNG", mime: "image/png", size: 200 }, { name: "说明.txt", mime: "text/plain", size: 12 }, ]), { ok: true }, @@ -35,3 +35,21 @@ test("attachment validation rejects traversal, unsupported types, and oversized 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 index edd1b0ee..07edf27f 100644 --- a/web/protocol/attachments.ts +++ b/web/protocol/attachments.ts @@ -2,13 +2,13 @@ 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", +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 { @@ -26,6 +26,18 @@ function isSafeAttachmentName(name: string) { 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[], ) { @@ -43,10 +55,16 @@ export function validateWebAttachments( error: `invalid attachment name: ${attachment.name}`, }; } - if (!SUPPORTED_MIME.has(attachment.mime)) { + 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: `unsupported attachment type: ${attachment.mime}`, + error: "attachment extension does not match type", }; } if (