diff --git a/CHANGELOG.md b/CHANGELOG.md index d981aa9..0acfd91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Security and Correctness + +- **Security — `sanitizeUntrustedFileName()`:** Reject Windows reserved device basenames (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`, and variants with extensions or trailing spaces/dots) and return the caller fallback, matching the reserved-name rules already used by device-path read guards. Prevents staging/output helpers from accepting untrusted names that open devices on Windows. + ## 0.5.0 - 2026-07-26 ### Highlights diff --git a/src/device-path.ts b/src/device-path.ts index 2bf572b..f574484 100644 --- a/src/device-path.ts +++ b/src/device-path.ts @@ -104,6 +104,11 @@ function normalizeWindowsDeviceBaseName(filePath: string): string { return (withoutTrailingIgnoredChars.split(".")[0] ?? withoutTrailingIgnoredChars).toUpperCase(); } +/** True when the basename is a Windows reserved device name (CON, NUL, COM1, …). */ +export function isWindowsReservedDeviceBaseName(fileName: string): boolean { + return WINDOWS_RESERVED_DEVICE_NAMES.has(normalizeWindowsDeviceBaseName(fileName)); +} + function matchWindowsDeviceReadPath(filePath: string): UnsafeDeviceReadPathMatch | undefined { const normalized = filePath.replace(/\//g, "\\"); if (/^\\\\\.\\/.test(normalized) || /^\\\\\?\\GLOBALROOT\\Device\\/i.test(normalized)) { diff --git a/src/filename.ts b/src/filename.ts index 1227ed6..db6a1de 100644 --- a/src/filename.ts +++ b/src/filename.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { isWindowsReservedDeviceBaseName } from "./device-path.js"; const WINDOWS_INVALID_FILE_NAME_CHARACTERS = new Set('<>:"/\\|?*'); @@ -25,6 +26,12 @@ export function sanitizeUntrustedFileName(fileName: string, fallbackName: string if (!base || base === "." || base === "..") { return fallbackName; } + // Windows treats these basenames as devices even with an extension + // (NUL.txt opens the NUL device). Refuse them so staging/output helpers + // never create reserved paths from untrusted names. + if (isWindowsReservedDeviceBaseName(base)) { + return fallbackName; + } if (base.length > 200) { base = base.slice(0, 200); } diff --git a/test/filename.test.ts b/test/filename.test.ts index b79aee3..2e71193 100644 --- a/test/filename.test.ts +++ b/test/filename.test.ts @@ -18,4 +18,16 @@ describe("sanitizeUntrustedFileName", () => { sanitizeUntrustedFileName('rer:t"|?*\u0085\u009f.pdf', "fallback.bin"), ).toBe("report.pdf"); }); + + it("rejects Windows reserved device basenames", () => { + expect(sanitizeUntrustedFileName("CON", "fallback.bin")).toBe("fallback.bin"); + expect(sanitizeUntrustedFileName("nul.txt", "fallback.bin")).toBe("fallback.bin"); + expect(sanitizeUntrustedFileName("COM1", "fallback.bin")).toBe("fallback.bin"); + expect(sanitizeUntrustedFileName("LPT9.doc", "fallback.bin")).toBe("fallback.bin"); + expect(sanitizeUntrustedFileName("aux ", "fallback.bin")).toBe("fallback.bin"); + expect(sanitizeUntrustedFileName("PRN.backup", "fallback.bin")).toBe("fallback.bin"); + // Not reserved: longer names that only contain reserved tokens as a prefix/suffix. + expect(sanitizeUntrustedFileName("console.txt", "fallback.bin")).toBe("console.txt"); + expect(sanitizeUntrustedFileName("null.pdf", "fallback.bin")).toBe("null.pdf"); + }); });