From 841ab022a6df78adcc2ff6c06eb20fd773819e17 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 17:49:30 -0400 Subject: [PATCH] Sanitize Windows-invalid characters in exported filenames (#12407) * Sanitize Windows-invalid ZIP filename characters Agent-Logs-Url: https://github.com/outline/outline/sessions/539082bc-597f-463d-b77c-6eb1bcf9bffa Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * Refine Windows filename sanitization regex handling Agent-Logs-Url: https://github.com/outline/outline/sessions/539082bc-597f-463d-b77c-6eb1bcf9bffa Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> * PR feedback --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: tommoor <380914+tommoor@users.noreply.github.com> Co-authored-by: Tom Moor --- server/utils/fs.test.ts | 24 ++++++++++++++++++++++++ server/utils/fs.ts | 41 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/server/utils/fs.test.ts b/server/utils/fs.test.ts index ea159cfc1a..a53f307c1c 100644 --- a/server/utils/fs.test.ts +++ b/server/utils/fs.test.ts @@ -19,6 +19,18 @@ describe("serializeFilename", () => { "this %5C and %5C this" ); }); + + it("should serialize Windows-invalid filename characters", () => { + expect(serializeFilename(`this: * ? " < > | that`)).toBe( + "this%3A %2A %3F %22 %3C %3E %7C that" + ); + }); + + it("should serialize trailing Windows-invalid filename characters", () => { + expect(serializeFilename("file.")).toBe("file%2E"); + expect(serializeFilename("file ")).toBe("file%20"); + expect(serializeFilename("file. ")).toBe("file%2E%20"); + }); }); describe("deserializeFilename", () => { @@ -35,6 +47,18 @@ describe("deserializeFilename", () => { `this \\ and \\ this` ); }); + + it("should deserialize Windows-invalid filename characters", () => { + expect(deserializeFilename("%3A %2A %3F %22 %3C %3E %7C")).toBe( + `: * ? " < > |` + ); + }); + + it("should deserialize trailing Windows-invalid filename characters", () => { + expect(deserializeFilename("file%2E")).toBe("file."); + expect(deserializeFilename("file%20")).toBe("file "); + expect(deserializeFilename("file%2E%20")).toBe("file. "); + }); }); describe("stringByteLength", () => { diff --git a/server/utils/fs.ts b/server/utils/fs.ts index a9db935eff..db2878210b 100644 --- a/server/utils/fs.ts +++ b/server/utils/fs.ts @@ -1,6 +1,33 @@ import path from "node:path"; import fs from "fs-extra"; +const windowsInvalidFileNameCharsRegex = /[\\/:*?"<>|]/g; +const windowsTrailingFileNameCharsRegex = /[. ]+$/g; +const encodedWindowsCharacters: Record = { + "%2F": "/", + "%5C": "\\", + "%3A": ":", + "%2A": "*", + "%3F": "?", + "%22": '"', + "%3C": "<", + "%3E": ">", + "%7C": "|", + "%2E": ".", + "%20": " ", +}; +const encodedWindowsCharactersRegex = /%(?:2F|5C|3A|2A|3F|22|3C|3E|7C|2E|20)/gi; + +/** + * Encodes a single character to uppercase percent-encoding. + * + * @param char The character to encode. + * @returns The encoded character. + */ +function encodeWindowsUnsafeCharacter(char: string): string { + return `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`; +} + /** * Serialize a file name for inclusion in a ZIP. * @@ -8,7 +35,14 @@ import fs from "fs-extra"; * @returns The serialized file name. */ export function serializeFilename(text: string): string { - return text.replace(/\//g, "%2F").replace(/\\/g, "%5C"); + const encoded = text.replace( + windowsInvalidFileNameCharsRegex, + encodeWindowsUnsafeCharacter + ); + + return encoded.replace(windowsTrailingFileNameCharsRegex, (trailing) => + trailing.split("").map(encodeWindowsUnsafeCharacter).join("") + ); } /** @@ -18,7 +52,10 @@ export function serializeFilename(text: string): string { * @returns The deserialized file name. */ export function deserializeFilename(text: string): string { - return text.replace(/%2F/g, "/").replace(/%5C/g, "\\"); + return text.replace( + encodedWindowsCharactersRegex, + (match) => encodedWindowsCharacters[match.toUpperCase()] ?? match + ); } /**