Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions tests/web/attachments.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
42 changes: 42 additions & 0 deletions web/protocol/attachments.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading