mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
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:
1 parent
bc0e062180
commit
b73299222a
2 files changed
+89
-2
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user