From 795965ed43139eb97b10085206ec6f24e601c92b Mon Sep 17 00:00:00 2001 From: ashim-hq Date: Mon, 20 Apr 2026 22:14:14 +0800 Subject: [PATCH] fix: truncate long filenames to prevent filesystem ENAMETOOLONG errors --- apps/api/src/lib/filename.ts | 13 ++++++++ tests/unit/api/utilities.test.ts | 53 ++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/apps/api/src/lib/filename.ts b/apps/api/src/lib/filename.ts index c043dd4c..7b640566 100644 --- a/apps/api/src/lib/filename.ts +++ b/apps/api/src/lib/filename.ts @@ -45,5 +45,18 @@ export function sanitizeFilename(raw: string): string { } } + // Truncate to filesystem-safe length (255 byte NAME_MAX minus margin for _toolId suffix) + const MAX_NAME_BYTES = 200; + const enc = new TextEncoder(); + if (enc.encode(name).length > MAX_NAME_BYTES) { + const dotIdx = name.lastIndexOf("."); + const ext = dotIdx > 0 ? name.slice(dotIdx) : ""; + let base = dotIdx > 0 ? name.slice(0, dotIdx) : name; + while (enc.encode(base + ext).length > MAX_NAME_BYTES) { + base = base.slice(0, -1); + } + name = base + ext; + } + return name; } diff --git a/tests/unit/api/utilities.test.ts b/tests/unit/api/utilities.test.ts index d032a7f6..52271fe8 100644 --- a/tests/unit/api/utilities.test.ts +++ b/tests/unit/api/utilities.test.ts @@ -821,6 +821,46 @@ function sanitizeFilename(raw: string): string { if (!name || name === "." || name === "..") { name = "upload"; } + + // Guard against double-extension attacks (e.g. "image.png.php"). + const dotIndex = name.indexOf("."); + if (dotIndex !== -1) { + const parts = name.split("."); + const SAFE_IMAGE_EXTENSIONS = new Set([ + ".jpg", + ".jpeg", + ".png", + ".webp", + ".gif", + ".bmp", + ".tiff", + ".tif", + ".avif", + ".svg", + ".pdf", + ]); + for (let i = 1; i < parts.length; i++) { + const ext = `.${parts[i].toLowerCase()}`; + if (SAFE_IMAGE_EXTENSIONS.has(ext)) { + name = parts.slice(0, i + 1).join("."); + break; + } + } + } + + // Truncate to filesystem-safe length (255 byte NAME_MAX minus margin for _toolId suffix) + const MAX_NAME_BYTES = 200; + const enc = new TextEncoder(); + if (enc.encode(name).length > MAX_NAME_BYTES) { + const dotIdx = name.lastIndexOf("."); + const ext = dotIdx > 0 ? name.slice(dotIdx) : ""; + let base = dotIdx > 0 ? name.slice(0, dotIdx) : name; + while (enc.encode(base + ext).length > MAX_NAME_BYTES) { + base = base.slice(0, -1); + } + name = base + ext; + } + return name; } @@ -961,9 +1001,18 @@ describe("sanitizeFilename", () => { expect(sanitizeFilename("my..file..name.png")).toBe("myfilename.png"); }); - it("handles very long filenames", () => { + it("handles very long filenames by truncating to filesystem-safe length", () => { const longName = "a".repeat(500) + ".png"; - expect(sanitizeFilename(longName)).toBe(longName); + const result = sanitizeFilename(longName); + expect(new TextEncoder().encode(result).length).toBeLessThanOrEqual(200); + expect(result).toMatch(/\.png$/); + }); + + it("truncates very long filenames to filesystem-safe length", () => { + const longName = "a".repeat(300) + ".jpg"; + const result = sanitizeFilename(longName); + expect(new TextEncoder().encode(result).length).toBeLessThanOrEqual(200); + expect(result).toMatch(/\.jpg$/); }); it("handles filename with only extension", () => {