From 7d86cc420afa70fbccf91e6d03156e4970b16986 Mon Sep 17 00:00:00 2001 From: Hao0321 <126182090+Hao0321@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:53:41 +0000 Subject: [PATCH] fix(media): reject Windows device names and \??\ paths as media sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard added in #47 still accepted reserved DOS device names such as C:\media\NUL, nul.txt, NUL .txt, COM1, LPT¹ or CONOUT$, which Win32 opens as devices instead of files, and the \??\ prefix, which hands paths such as \??\UNC\host\share to the NT object manager unparsed. Reject both in isWindowsNetworkPath and its Rust twin, so every assertLocalMediaPath caller and the Tauri asset scope guard are covered. Names are matched in every path component, in any case and ignoring an extension or trailing spaces; COM0/LPT0 are rejected conservatively. POSIX paths are unchanged. Signed-off-by: Hao0321 <126182090+Hao0321@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQbUWKu73ka7dZ58vWVzgY --- src-tauri/src/main.rs | 77 +++++++++++++++++++++++++++++-- src/shared/localMediaPath.test.ts | 67 ++++++++++++++++++++++++++- src/shared/localMediaPath.ts | 27 +++++++++-- 3 files changed, 163 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1996e05..7cd6e9a 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1485,7 +1485,9 @@ fn string_field<'a>(value: &'a Value, field: &str) -> Result<&'a str, String> { /// UNC (`\\host\share`), `\\?\UNC\` and device (`\\.\`, `\\?\Volume{..}`) paths /// are absolute on Windows, but touching one opens an SMB connection (and may /// send NTLM credentials) to a host named by an untrusted project file. Only a -/// verbatim drive path (`\\?\C:\`) stays local. +/// verbatim drive path (`\\?\C:\`) stays local. `\??\` hands the rest of the +/// path to the NT object manager unparsed (`\??\UNC\host\share`), so no path +/// with that prefix is local media. Mirrors `src/shared/localMediaPath.ts`. fn is_windows_network_path(path: &str) -> bool { let normalized = path.replace('/', "\\"); let bytes = normalized.as_bytes(); @@ -1494,7 +1496,36 @@ fn is_windows_network_path(path: &str) -> bool { && bytes[4].is_ascii_alphabetic() && bytes[5] == b':' && bytes.get(6).is_none_or(|byte| *byte == b'\\'); - normalized.starts_with("\\\\") && !verbatim_drive + (normalized.starts_with("\\\\") && !verbatim_drive) + || normalized.starts_with("\\??\\") + || normalized + .split(['\\', ':']) + .any(is_windows_reserved_device_name) +} + +/// Win32 opens a DOS device instead of a file for these names in any letter +/// case, also with trailing spaces (`NUL `) and, before Windows 11, with an +/// extension (`nul.txt`, `NUL .txt`); `:` also ends a name (`C:NUL`). Classic +/// Win32 only maps the final component to a device; every component is checked +/// anyway, a conservative choice that also covers a folder later opened on its +/// own. COM0 and LPT0 are not on Microsoft's current list but are rejected too. +fn is_windows_reserved_device_name(name: &str) -> bool { + let base = name + .split('.') + .next() + .unwrap_or_default() + .trim_end_matches(' ') + .to_uppercase(); + match base.as_str() { + "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" => true, + _ => base + .strip_prefix("COM") + .or_else(|| base.strip_prefix("LPT")) + .is_some_and(|port| { + let mut chars = port.chars(); + matches!(chars.next(), Some('0'..='9' | '¹' | '²' | '³')) && chars.next().is_none() + }), + } } fn allow_path(app: &AppHandle, path: &str) -> Result<(), String> { @@ -1504,7 +1535,7 @@ fn allow_path(app: &AppHandle, path: &str) -> Result<(), String> { } if cfg!(windows) && is_windows_network_path(path) { return Err(format!( - "拒絕網路共用或裝置路徑作為媒體來源,請先複製到本機磁碟:{path}" + "拒絕網路共用或裝置路徑作為媒體來源,請先以一般檔名複製到本機磁碟:{path}" )); } app.asset_protocol_scope() @@ -10739,6 +10770,39 @@ mod tests { r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\clip.mp4", r"\\?\GLOBALROOT\Device\HarddiskVolume1\clip.mp4", r"\\?\", + r"\??\UNC\host\share\clip.mp4", + r"\??\C:\media\clip.mp4", + "/??/UNC/host/share/clip.mp4", + r"C:\media\NUL", + r"C:\media\nul.txt", + r"C:\x\CON", + r"C:\media\aux.mp4", + r"C:\media\PRN", + r"C:\media\COM1", + r"C:\media\com9.mp4", + r"C:\media\LPT1", + r"C:\media\lpt9.wav", + r"C:\media\COM¹", + r"C:\media\com².mp4", + r"C:\media\COM³", + r"C:\media\LPT¹.mp4", + r"C:\media\lpt²", + r"C:\media\LPT³", + r"C:\media\CONIN$", + r"C:\media\conout$.mp4", + r"C:\media\nul.", + r"C:\media\NUL .txt", + r"C:\media\NUL ", + r"C:\media\Nul.tar.gz", + "C:/media/nul.mp4", + "C:NUL", + r"C:\media\NUL:stream", + "nul.mp4", + // Conservative choices: a non-final component, a verbatim path, COM0 and LPT0. + r"C:\media\NUL\clip.mp4", + r"\\?\C:\media\NUL", + r"C:\media\COM0.mp4", + r"C:\media\LPT0.mp4", ] { assert!(is_windows_network_path(path), "{path}"); } @@ -10749,6 +10813,13 @@ mod tests { r"\\?\c:", "/Users/someone/clip.mp4", "media/clip.mp4", + r"C:\media\null.mp4", + r"C:\media\console.mp4", + r"C:\media\COM10.mp4", + r"C:\media\LPT10.mp4", + r"C:\media\clip.nul.mp4", + r"C:\media\auxiliary\clip.mp4", + r"C:\media\nul-cut\clip.mp4", ] { assert!(!is_windows_network_path(path), "{path}"); } diff --git a/src/shared/localMediaPath.test.ts b/src/shared/localMediaPath.test.ts index 23c3cca..1385a4e 100644 --- a/src/shared/localMediaPath.test.ts +++ b/src/shared/localMediaPath.test.ts @@ -13,6 +13,42 @@ const NETWORK_PATHS = [ String.raw`\\.\pipe\name`, String.raw`\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\clip.mp4`, String.raw`\\?\GLOBALROOT\Device\HarddiskVolume1\clip.mp4`, + String.raw`\??\UNC\host\share\clip.mp4`, + String.raw`\??\C:\media\clip.mp4`, + "/??/UNC/host/share/clip.mp4", +]; + +const DEVICE_NAME_PATHS = [ + String.raw`C:\media\NUL`, + String.raw`C:\media\nul.txt`, + String.raw`C:\x\CON`, + String.raw`C:\media\aux.mp4`, + String.raw`C:\media\PRN`, + String.raw`C:\media\COM1`, + String.raw`C:\media\com9.mp4`, + String.raw`C:\media\LPT1`, + String.raw`C:\media\lpt9.wav`, + String.raw`C:\media\COM¹`, + String.raw`C:\media\com².mp4`, + String.raw`C:\media\COM³`, + String.raw`C:\media\LPT¹.mp4`, + String.raw`C:\media\lpt²`, + String.raw`C:\media\LPT³`, + String.raw`C:\media\CONIN$`, + String.raw`C:\media\conout$.mp4`, + String.raw`C:\media\nul.`, + String.raw`C:\media\NUL .txt`, + String.raw`C:\media\NUL `, + String.raw`C:\media\Nul.tar.gz`, + "C:/media/nul.mp4", + "C:NUL", + String.raw`C:\media\NUL:stream`, + "nul.mp4", + // Conservative choices: a non-final component, a verbatim path, COM0 and LPT0. + String.raw`C:\media\NUL\clip.mp4`, + String.raw`\\?\C:\media\NUL`, + String.raw`C:\media\COM0.mp4`, + String.raw`C:\media\LPT0.mp4`, ]; const LOCAL_PATHS = [ @@ -22,10 +58,17 @@ const LOCAL_PATHS = [ String.raw`\\?\c:`, "/Users/someone/clip.mp4", "media/clip.mp4", + String.raw`C:\media\null.mp4`, + String.raw`C:\media\console.mp4`, + String.raw`C:\media\COM10.mp4`, + String.raw`C:\media\LPT10.mp4`, + String.raw`C:\media\clip.nul.mp4`, + String.raw`C:\media\auxiliary\clip.mp4`, + String.raw`C:\media\nul-cut\clip.mp4`, ]; describe("local media path guard", () => { - it.each(NETWORK_PATHS)("classifies %s as a network or device path", (path) => { + it.each([...NETWORK_PATHS, ...DEVICE_NAME_PATHS])("classifies %s as a network or device path", (path) => { expect(isWindowsNetworkPath(path)).toBe(true); expect(() => assertLocalMediaPath(path, "win32")).toThrow("拒絕網路共用或裝置路徑"); }); @@ -40,6 +83,11 @@ describe("local media path guard", () => { expect(() => assertLocalMediaPath("//host/share/clip.mp4", "darwin")).not.toThrow(); }); + it.each(["/Users/someone/NUL", "/media/con.mp4", "/media/COM1", String.raw`\??\C:\media\clip.mp4`])("keeps %s usable on POSIX", (path) => { + expect(() => assertLocalMediaPath(path, "linux")).not.toThrow(); + expect(() => assertLocalMediaPath(path, "darwin")).not.toThrow(); + }); + it("refuses a UNC source before any process or file access on Windows", async () => { const platform = Object.getOwnPropertyDescriptor(process, "platform")!; Object.defineProperty(process, "platform", { value: "win32" }); @@ -56,4 +104,21 @@ describe("local media path guard", () => { Object.defineProperty(process, "platform", platform); } }); + + it("refuses a DOS device name or NT object path before any process or file access on Windows", async () => { + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: "win32" }); + try { + for (const source of [String.raw`C:\media\CON`, String.raw`C:\media\com1.mp4`, String.raw`\??\UNC\attacker\share\clip.mp4`]) { + await expect(inspectMedia(source, "/nonexistent/ffprobe")).rejects.toThrow("拒絕網路共用或裝置路徑"); + await expect(analyzeSceneCuts({ sourcePath: source } as never, { ffmpegPath: "/nonexistent/ffmpeg" })).rejects.toThrow("拒絕網路共用或裝置路徑"); + await expect(probeMedia(source, "/nonexistent/ffprobe")).rejects.toThrow("拒絕網路共用或裝置路徑"); + await expect(analyzeAutomaticCaptionTranscript({ sourcePath: source } as never, {} as never)).rejects.toThrow("拒絕網路共用或裝置路徑"); + expect(() => resolveMediaPath(source)).toThrow("拒絕網路共用或裝置路徑"); + expect(() => resolveMediaPath("clip.mp4", source)).toThrow("拒絕網路共用或裝置路徑"); + } + } finally { + Object.defineProperty(process, "platform", platform); + } + }); }); diff --git a/src/shared/localMediaPath.ts b/src/shared/localMediaPath.ts index 4bfd099..7b21516 100644 --- a/src/shared/localMediaPath.ts +++ b/src/shared/localMediaPath.ts @@ -3,20 +3,39 @@ * paths are absolute, but touching them makes Windows open an SMB connection * (and may send NTLM credentials) to a host named by an untrusted project file. * The only `\\` form that stays local is a verbatim drive path (`\\?\C:\…`). + * `\??\` hands the rest of the path to the NT object manager unparsed + * (`\??\UNC\host\share\…`), so no path with that prefix is local media. */ const VERBATIM_DRIVE_PATH = /^\\\\\?\\[A-Za-z]:(?:\\|$)/; +/** + * Win32 opens a DOS device instead of a file for these names in any letter case, + * also with trailing spaces (`NUL `) and, before Windows 11, with an extension + * (`nul.txt`, `NUL .txt`); `:` also ends a name (`C:NUL`). Classic Win32 only + * maps the final component to a device; every component is checked anyway, a + * conservative choice that also covers a folder later opened on its own. + * COM0 and LPT0 are not on Microsoft's current list but are rejected too. + */ +const RESERVED_DEVICE_NAME = /^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|(?:COM|LPT)[0-9¹²³])$/; + +function hasReservedDeviceName(normalized: string): boolean { + return normalized.split(/[\\:]/).some((name) => + RESERVED_DEVICE_NAME.test(name.split(".", 1)[0].replace(/ +$/, "").toUpperCase())); +} + export function isWindowsNetworkPath(path: string): boolean { const normalized = path.replaceAll("/", "\\"); - return normalized.startsWith("\\\\") && !VERBATIM_DRIVE_PATH.test(normalized); + return (normalized.startsWith("\\\\") && !VERBATIM_DRIVE_PATH.test(normalized)) + || normalized.startsWith("\\??\\") + || hasReservedDeviceName(normalized); } /** - * Rejects network and device paths on Windows. Elsewhere `//x` is an ordinary - * POSIX path, so nothing is rejected. + * Rejects network, NT object and DOS device paths on Windows. Elsewhere `//x` + * and `NUL` are ordinary POSIX paths, so nothing is rejected. */ export function assertLocalMediaPath(path: string, platform: NodeJS.Platform = process.platform): void { if (platform === "win32" && isWindowsNetworkPath(path)) { - throw new Error(`拒絕網路共用或裝置路徑作為媒體來源,請先複製到本機磁碟:${path}`); + throw new Error(`拒絕網路共用或裝置路徑作為媒體來源,請先以一般檔名複製到本機磁碟:${path}`); } }