From dafefbb8c1119dd04ad2f9a320d602be5beef51c Mon Sep 17 00:00:00 2001 From: Alexander Kireev Date: Fri, 3 Jul 2026 07:43:34 +0700 Subject: [PATCH] path: fix win32 normalize false-positive on reserved names path.win32.normalize() checked names like CONx or NULs against the Windows reserved device list (CON, NUL, PRN, LPT1, ...) by slicing off the last character whenever the path had no colon, so any file name that happened to be a reserved name plus one extra character got wrongly treated as a device and prefixed with `.\`. A previous attempt at this fix (b0a4f162df5e, reverted in c4429c85ec) guarded the check with `colonIndex !== -1`, but that also suppressed the colon-less case Windows itself treats as reserved: a name followed immediately by a single dot, e.g. NUL. and COM9. (per Microsoft's own docs, "avoid these names followed immediately by an extension; NUL.txt and NUL.tar.gz are both equivalent to NUL"), which is why it broke Windows CI. This version keeps that dot case working while still rejecting an arbitrary trailing character. Signed-off-by: Alexander Kireev --- lib/path.js | 15 ++++++++++++++- test/parallel/test-path-normalize.js | 17 +++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/path.js b/lib/path.js index 63b037cddfb986..520dc8a574a346 100644 --- a/lib/path.js +++ b/lib/path.js @@ -471,7 +471,20 @@ const win32 = { } while ((index = StringPrototypeIndexOf(path, ':', index + 1)) !== -1); } const colonIndex = StringPrototypeIndexOf(path, ':'); - if (isWindowsReservedName(path, colonIndex)) { + if (colonIndex !== -1) { + if (isWindowsReservedName(path, colonIndex)) { + return `.\\${device ?? ''}${tail}`; + } + } else if ( + // A reserved name without a colon is only a device path when it is + // followed immediately by a single trailing dot (e.g. `NUL.` and + // `COM9.`, which Windows treats as equivalent to `NUL`/`COM9`). Any + // other trailing character (`CONx`, `NULs`, ...) is a distinct, + // ordinary file name and must be left untouched. + len > 0 && + StringPrototypeCharCodeAt(path, len - 1) === CHAR_DOT && + isWindowsReservedName(path, len - 1) + ) { return `.\\${device ?? ''}${tail}`; } if (device === undefined) { diff --git a/test/parallel/test-path-normalize.js b/test/parallel/test-path-normalize.js index 8b537676dbf45d..d704b666e14815 100644 --- a/test/parallel/test-path-normalize.js +++ b/test/parallel/test-path-normalize.js @@ -43,6 +43,23 @@ assert.strictEqual(path.win32.normalize('foo/bar\\baz'), 'foo\\bar\\baz'); assert.strictEqual(path.win32.normalize('\\\\.\\foo'), '\\\\.\\foo'); assert.strictEqual(path.win32.normalize('\\\\.\\foo\\'), '\\\\.\\foo\\'); +// A name that merely starts with a reserved device name is not itself +// reserved: Windows only treats `NAME` as equivalent to the device when it +// is bare, followed by a colon, or followed immediately by a single dot +// (e.g. `NUL.` and `NUL.txt` both refer to the `NUL` device). Any other +// trailing character makes it an ordinary, unrelated file name and it must +// be left untouched. +assert.strictEqual(path.win32.normalize('CONx'), 'CONx'); +assert.strictEqual(path.win32.normalize('NULs'), 'NULs'); +assert.strictEqual(path.win32.normalize('LPT1x'), 'LPT1x'); +assert.strictEqual(path.win32.normalize('PRNzzz'), 'PRNzzz'); +assert.strictEqual(path.win32.normalize('CON'), 'CON'); +// With a trailing colon or a single trailing dot the reserved-name handling +// still applies. +assert.strictEqual(path.win32.normalize('CON:'), '.\\CON:.'); +assert.strictEqual(path.win32.normalize('CON.'), '.\\CON.'); +assert.strictEqual(path.win32.normalize('COM9.'), '.\\COM9.'); + // Tests related to CVE-2024-36139. Path traversal should not result in changing // the root directory on Windows. assert.strictEqual(path.win32.normalize('test/../C:/Windows'), '.\\C:\\Windows');