fix: decode UTF-8 filenames delivered in the Latin-1 Content-Disposition header (#3)

HTTP header values are ISO-8859-1 (RFC 7230 section 3.2.4), so a server that puts
raw UTF-8 bytes in the plain `filename=` parameter -- rather than the
`filename*=UTF-8''...` form that exists for exactly this -- hands us one character
per byte. MakerWorld's CDN does this, so a model called 哨子 was written to disk as
a name whose characters are the individual bytes of its own UTF-8 encoding.

parseContentDisposition now re-reads those bytes as UTF-8, but only when they
round-trip exactly. Invalid UTF-8 decodes to U+FFFD and re-encodes to different
bytes, so a genuinely Latin-1 filename is left alone rather than being mangled by
the fix.

sanitizeFilename stripped NUL but not the rest of C0, and nothing in C1
(U+0080-U+009F) -- which is precisely what a mis-decoded header leaves behind. It
now strips both ranges, matching what sanitizePathSegment already does for path
segments. This is defence in depth rather than the fix: on its own it would have
produced a legal but permanently garbled name, because the information needed to
recover the real one is in those bytes.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tim MooreandClaude Opus 5 authored and GitHub committed 2026-09-23 17:42:37 +02:00
1 parent bc0e062180
commit b73299222a
2 files changed
+89 -2

No files matched your search

+28 -2
View File
@@ -2,19 +2,45 @@ import path from "node:path";
import mimeTypes from "mime-types";
import { IMPORT_ALLOWED_EXTS } from "../config";
// The whole C0 and C1 range, not just NUL. C1 (U+0080-U+009F) is what a mis-decoded header
// leaves behind, and a control character in a filename survives the local filesystem happily but
// is rejected further downstream -- OneDrive answers 400 Bad Request for the subset undefined in
// Windows-1252 (0x81 0x8D 0x8F 0x90 0x9D) and accepts the rest, so a backup silently keeps some
// names and drops others.
// oxlint-disable-next-line no-control-regex -- stripping control chars is the point here.
const CONTROL_CHARS_RE = /[\u0000-\u001f\u007f-\u009f]/g;
export function sanitizeFilename(name: string | null | undefined): string {
let cleaned = (name || "").replace(/\0/g, "").trim();
let cleaned = (name || "").replace(CONTROL_CHARS_RE, "").trim();
cleaned = cleaned.replace(/\//g, "_").replace(/\\/g, "_");
cleaned = path.basename(cleaned);
return cleaned || "imported-file";
}
/**
* HTTP header values are ISO-8859-1 (RFC 7230 section 3.2.4), so a server that puts raw UTF-8
* bytes in the plain `filename=` parameter -- rather than the `filename*=UTF-8''...` form that
* exists for exactly this -- hands us one character per byte. MakerWorld's CDN does this, which
* turned `哨子.3mf` into `å<93>¨å­<90>.3mf` on disk.
*
* Re-read those bytes as UTF-8, but only when they round-trip exactly. Invalid UTF-8 decodes to
* U+FFFD and therefore re-encodes to different bytes, so a genuinely Latin-1 `café.stl` is left
* alone instead of being mangled by the fix.
*/
function decodeLatin1AsUtf8(value: string): string {
if (!/[\u0080-\u00ff]/.test(value)) return value;
const bytes = Buffer.from(value, "latin1");
const decoded = bytes.toString("utf8");
return Buffer.from(decoded, "utf8").equals(bytes) ? decoded : value;
}
export function parseContentDisposition(cd: string | null | undefined): string | null {
if (!cd) return null;
// `filename*=` is already percent-encoded with its charset named, so it needs no repair.
const starMatch = cd.match(/filename\*=([^']*)''([^;]+)/i);
if (starMatch) return decodeURIComponent(starMatch[2]);
const plainMatch = cd.match(/filename="?([^";]+)"?/i);
return plainMatch ? plainMatch[1] : null;
return plainMatch ? decodeLatin1AsUtf8(plainMatch[1]) : null;
}
export class HttpError extends Error {
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
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
// in the plain `filename=` parameter therefore arrives as one character per byte, and MakerWorld's
// CDN does exactly that -- which wrote `å<93>¨å­<90>.3mf` to disk for a model called `哨子`.
// The repair has to be conditional, because a genuinely Latin-1 filename must survive untouched.
const headers = (cd: string) => new Headers({ "content-disposition": cd });
describe("parseContentDisposition", () => {
it("recovers UTF-8 bytes delivered through the Latin-1 plain filename parameter", () => {
const mangled = Buffer.from("哨子.3mf", "utf8").toString("latin1");
expect(parseContentDisposition(`attachment; filename="${mangled}"`)).toBe("哨子.3mf");
});
it("leaves a genuinely Latin-1 filename alone", () => {
// `café.stl` in Latin-1 is not valid UTF-8, so it must not be re-decoded.
expect(parseContentDisposition('attachment; filename="café.stl"')).toBe("café.stl");
});
it("still prefers the filename*= form, which names its own charset", () => {
expect(parseContentDisposition("attachment; filename*=UTF-8''%E5%93%A8%E5%AD%90.3mf")).toBe(
"哨子.3mf",
);
});
it("is unchanged for plain ASCII", () => {
expect(parseContentDisposition('attachment; filename="widget.stl"')).toBe("widget.stl");
expect(parseContentDisposition(null)).toBeNull();
expect(parseContentDisposition("attachment")).toBeNull();
});
});
describe("sanitizeFilename", () => {
it("strips C1 control characters, not just NUL", () => {
// U+0080-U+009F are what a mis-decoded header leaves behind; they reach the filesystem
// happily and are then rejected by cloud storage.
expect(sanitizeFilename("we\u0080ird\u009dname.stl")).toBe("weirdname.stl");
expect(sanitizeFilename("tab\tseparated.stl")).toBe("tabseparated.stl");
});
it("keeps the existing behaviour for separators and empties", () => {
expect(sanitizeFilename("a/b\\c.stl")).toBe("a_b_c.stl");
expect(sanitizeFilename(" ")).toBe("imported-file");
expect(sanitizeFilename(null)).toBe("imported-file");
});
});
describe("buildImportFilename", () => {
it("writes a correctly encoded name for a MakerWorld-style download", () => {
const mangled = Buffer.from("哨子.3mf", "utf8").toString("latin1");
const name = buildImportFilename(
"https://example.invalid/download",
headers(`attachment; filename="${mangled}"`),
);
expect(name).toBe("哨子.3mf");
// oxlint-disable-next-line no-control-regex -- asserting there are no control chars is the point here.
expect(/[\u0000-\u001f\u007f-\u009f]/.test(name)).toBe(false);
});
});