From 6d34243b89fe5f4e08f1fe9cc1fcb784d55d447d Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Wed, 23 Sep 2026 17:44:52 +0200 Subject: [PATCH] fix: file upload file name utf8 issues --- backend/src/uploadMiddleware.ts | 13 +++++++++++-- backend/src/utils/fileUtils.ts | 4 +++- backend/tests/importFilename.test.ts | 24 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/backend/src/uploadMiddleware.ts b/backend/src/uploadMiddleware.ts index 46cb9a1..30df3d6 100644 --- a/backend/src/uploadMiddleware.ts +++ b/backend/src/uploadMiddleware.ts @@ -12,8 +12,17 @@ const diskStorage = multer.diskStorage({ }, }); +// Browsers send multipart filenames as raw UTF-8 bytes, but multer decodes them as Latin-1 by +// default -- `哨子.3mf` would arrive as mojibake, and sanitizeFilename then strips the C1 bytes +// needed to recover it. +const defParamCharset = "utf8"; + /** Disk-backed multer instance for model file uploads (never memoryStorage — files can be large). */ -export const modelUpload = multer({ storage: diskStorage, limits: { fileSize: IMPORT_MAX_BYTES } }); +export const modelUpload = multer({ storage: diskStorage, limits: { fileSize: IMPORT_MAX_BYTES }, defParamCharset }); /** Small in-memory multer instance for the 8MB-capped client-rendered thumbnail upload. */ -export const thumbnailUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024 + 1 } }); +export const thumbnailUpload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 8 * 1024 * 1024 + 1 }, + defParamCharset, +}); diff --git a/backend/src/utils/fileUtils.ts b/backend/src/utils/fileUtils.ts index 014493e..7fbc1c7 100644 --- a/backend/src/utils/fileUtils.ts +++ b/backend/src/utils/fileUtils.ts @@ -28,7 +28,9 @@ export function sanitizeFilename(name: string | null | undefined): string { * alone instead of being mangled by the fix. */ function decodeLatin1AsUtf8(value: string): string { - if (!/[\u0080-\u00ff]/.test(value)) return value; + // Only a pure byte string can be one; anything above U+00FF was already decoded properly, and + // Buffer.from(..., "latin1") would silently truncate it. + if (!/[\u0080-\u00ff]/.test(value) || /[\u0100-\uffff]/.test(value)) return value; const bytes = Buffer.from(value, "latin1"); const decoded = bytes.toString("utf8"); return Buffer.from(decoded, "utf8").equals(bytes) ? decoded : value; diff --git a/backend/tests/importFilename.test.ts b/backend/tests/importFilename.test.ts index c09e88b..a41d1dc 100644 --- a/backend/tests/importFilename.test.ts +++ b/backend/tests/importFilename.test.ts @@ -1,4 +1,7 @@ +import express from "express"; +import request from "supertest"; import { describe, expect, it } from "vitest"; +import { modelUpload } from "../src/uploadMiddleware"; import { buildImportFilename, parseContentDisposition, sanitizeFilename } from "../src/utils/fileUtils"; // HTTP header values are ISO-8859-1 (RFC 7230 section 3.2.4). A server that puts raw UTF-8 bytes @@ -59,3 +62,24 @@ describe("buildImportFilename", () => { expect(/[\u0000-\u001f\u007f-\u009f]/.test(name)).toBe(false); }); }); + +describe("parseContentDisposition with already-decoded input", () => { + it("leaves a string with characters above U+00FF alone", () => { + // Not a byte string, so there is nothing to re-decode -- and latin1 would truncate `哨`. + expect(parseContentDisposition('attachment; filename="café哨.stl"')).toBe("café哨.stl"); + }); +}); + +describe("modelUpload", () => { + it("decodes a browser's raw UTF-8 multipart filename as UTF-8, not Latin-1", async () => { + const app = express(); + app.post("/upload", modelUpload.single("file"), (req, res) => { + res.json({ name: sanitizeFilename(req.file?.originalname) }); + }); + const res = await request(app) + .post("/upload") + .attach("file", Buffer.from("solid x\nendsolid x\n"), { filename: "哨子.stl", contentType: "model/stl" }); + expect(res.status).toBe(200); + expect(res.body.name).toBe("哨子.stl"); + }); +});