Skip to content

Commit 56f9db2

Browse files
committed
lib: handle all windows reserved driver name
Signed-off-by: RafaelGSS <[email protected]> PR-URL: nodejs-private/node-private#721 Refs: https://hackerone.com/reports/3160912 CVE-ID: CVE-2025-27210
1 parent c33223f commit 56f9db2

File tree

2 files changed

+131
-10
lines changed

2 files changed

+131
-10
lines changed

lib/path.js

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
'use strict';
2323

2424
const {
25+
ArrayPrototypeIncludes,
2526
ArrayPrototypeJoin,
2627
ArrayPrototypeSlice,
2728
FunctionPrototypeBind,
@@ -34,6 +35,7 @@ const {
3435
StringPrototypeSlice,
3536
StringPrototypeSplit,
3637
StringPrototypeToLowerCase,
38+
StringPrototypeToUpperCase,
3739
} = primordials;
3840

3941
const {
@@ -67,6 +69,17 @@ function isPosixPathSeparator(code) {
6769
return code === CHAR_FORWARD_SLASH;
6870
}
6971

72+
const WINDOWS_RESERVED_NAMES = [
73+
'CON', 'PRN', 'AUX', 'NUL',
74+
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
75+
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
76+
];
77+
78+
function isWindowsReservedName(path, colonIndex) {
79+
const devicePart = StringPrototypeToUpperCase(StringPrototypeSlice(path, 0, colonIndex));
80+
return ArrayPrototypeIncludes(WINDOWS_RESERVED_NAMES, devicePart);
81+
}
82+
7083
function isWindowsDeviceRoot(code) {
7184
return (code >= CHAR_UPPERCASE_A && code <= CHAR_UPPERCASE_Z) ||
7285
(code >= CHAR_LOWERCASE_A && code <= CHAR_LOWERCASE_Z);
@@ -402,16 +415,21 @@ const win32 = {
402415
} else {
403416
rootEnd = 1;
404417
}
405-
} else if (isWindowsDeviceRoot(code) &&
406-
StringPrototypeCharCodeAt(path, 1) === CHAR_COLON) {
407-
// Possible device root
408-
device = StringPrototypeSlice(path, 0, 2);
409-
rootEnd = 2;
410-
if (len > 2 && isPathSeparator(StringPrototypeCharCodeAt(path, 2))) {
411-
// Treat separator following drive name as an absolute path
412-
// indicator
413-
isAbsolute = true;
414-
rootEnd = 3;
418+
} else {
419+
const colonIndex = StringPrototypeIndexOf(path, ':');
420+
if (colonIndex > 0) {
421+
if (isWindowsDeviceRoot(code) && colonIndex === 1) {
422+
device = StringPrototypeSlice(path, 0, 2);
423+
rootEnd = 2;
424+
if (len > 2 && isPathSeparator(StringPrototypeCharCodeAt(path, 2))) {
425+
isAbsolute = true;
426+
rootEnd = 3;
427+
}
428+
} else if (isWindowsReservedName(path, colonIndex)) {
429+
device = StringPrototypeSlice(path, 0, colonIndex + 1);
430+
rootEnd = colonIndex + 1;
431+
432+
}
415433
}
416434
}
417435

@@ -435,12 +453,17 @@ const win32 = {
435453
return `.\\${tail}`;
436454
}
437455
let index = StringPrototypeIndexOf(path, ':');
456+
438457
do {
439458
if (index === len - 1 || isPathSeparator(StringPrototypeCharCodeAt(path, index + 1))) {
440459
return `.\\${tail}`;
441460
}
442461
} while ((index = StringPrototypeIndexOf(path, ':', index + 1)) !== -1);
443462
}
463+
const colonIndex = StringPrototypeIndexOf(path, ':');
464+
if (isWindowsReservedName(path, colonIndex)) {
465+
return `.\\${device ?? ''}${tail}`;
466+
}
444467
if (device === undefined) {
445468
return isAbsolute ? `\\${tail}` : tail;
446469
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const assert = require('assert');
5+
const path = require('path');
6+
7+
if (!common.isWindows) {
8+
common.skip('Windows only');
9+
}
10+
11+
const normalizeDeviceNameTests = [
12+
{ input: 'CON', expected: 'CON' },
13+
{ input: 'con', expected: 'con' },
14+
{ input: 'CON:', expected: '.\\CON:.' },
15+
{ input: 'con:', expected: '.\\con:.' },
16+
{ input: 'CON:.', expected: '.\\CON:.' },
17+
{ input: 'coN:', expected: '.\\coN:.' },
18+
{ input: 'LPT9.foo', expected: 'LPT9.foo' },
19+
{ input: 'COM9:', expected: '.\\COM9:.' },
20+
{ input: 'COM9.', expected: '.\\COM9.' },
21+
{ input: 'C:COM9', expected: 'C:COM9' },
22+
{ input: 'C:\\COM9', expected: 'C:\\COM9' },
23+
{ input: 'CON:./foo', expected: '.\\CON:foo' },
24+
{ input: 'CON:/foo', expected: '.\\CON:foo' },
25+
{ input: 'CON:../foo', expected: '.\\CON:..\\foo' },
26+
{ input: 'CON:/../foo', expected: '.\\CON:..\\foo' },
27+
{ input: 'CON:./././foo', expected: '.\\CON:foo' },
28+
{ input: 'CON:..', expected: '.\\CON:..' },
29+
{ input: 'CON:..\\', expected: '.\\CON:..\\' },
30+
{ input: 'CON:..\\..', expected: '.\\CON:..\\..' },
31+
{ input: 'CON:..\\..\\', expected: '.\\CON:..\\..\\' },
32+
{ input: 'CON:..\\..\\foo', expected: '.\\CON:..\\..\\foo' },
33+
{ input: 'CON:..\\..\\foo\\', expected: '.\\CON:..\\..\\foo\\' },
34+
{ input: 'CON:..\\..\\foo\\bar', expected: '.\\CON:..\\..\\foo\\bar' },
35+
{ input: 'CON:..\\..\\foo\\bar\\', expected: '.\\CON:..\\..\\foo\\bar\\' },
36+
{ input: 'COM1:a:b:c', expected: '.\\COM1:a:b:c' },
37+
{ input: 'COM1:a:b:c/', expected: '.\\COM1:a:b:c\\' },
38+
{ input: 'c:lpt1', expected: 'c:lpt1' },
39+
{ input: 'c:\\lpt1', expected: 'c:\\lpt1' },
40+
41+
// Reserved device names with path traversal
42+
{ input: 'CON:.\\..\\..\\foo', expected: '.\\CON:..\\..\\foo' },
43+
{ input: 'PRN:.\\..\\bar', expected: '.\\PRN:..\\bar' },
44+
{ input: 'AUX:/../../baz', expected: '.\\AUX:..\\..\\baz' },
45+
46+
{ input: 'COM1:', expected: '.\\COM1:.' },
47+
{ input: 'COM9:', expected: '.\\COM9:.' },
48+
{ input: 'COM1:.\\..\\..\\foo', expected: '.\\COM1:..\\..\\foo' },
49+
{ input: 'LPT1:', expected: '.\\LPT1:.' },
50+
{ input: 'LPT9:', expected: '.\\LPT9:.' },
51+
{ input: 'LPT1:.\\..\\..\\foo', expected: '.\\LPT1:..\\..\\foo' },
52+
{ input: 'LpT5:/another/path', expected: '.\\LpT5:another\\path' },
53+
54+
{ input: 'C:\\foo', expected: 'C:\\foo' },
55+
{ input: 'D:bar', expected: 'D:bar' },
56+
57+
{ input: 'CON', expected: 'CON' },
58+
{ input: 'CON.TXT', expected: 'CON.TXT' },
59+
{ input: 'COM10:', expected: '.\\COM10:' },
60+
{ input: 'LPT10:', expected: '.\\LPT10:' },
61+
{ input: 'CONNINGTOWER:', expected: '.\\CONNINGTOWER:' },
62+
{ input: 'AUXILIARYDEVICE:', expected: '.\\AUXILIARYDEVICE:' },
63+
{ input: 'NULLED:', expected: '.\\NULLED:' },
64+
{ input: 'PRNINTER:', expected: '.\\PRNINTER:' },
65+
66+
{ input: 'CON:\\..\\..\\windows\\system32', expected: '.\\CON:..\\..\\windows\\system32' },
67+
{ input: 'PRN:.././../etc/passwd', expected: '.\\PRN:..\\..\\etc\\passwd' },
68+
69+
// Test with trailing slashes
70+
{ input: 'CON:\\', expected: '.\\CON:.\\' },
71+
{ input: 'COM1:\\foo\\bar\\', expected: '.\\COM1:foo\\bar\\' },
72+
73+
// Test cases from original vulnerability reports or similar scenarios
74+
{ input: 'COM1:.\\..\\..\\foo.js', expected: '.\\COM1:..\\..\\foo.js' },
75+
{ input: 'LPT1:.\\..\\..\\another.txt', expected: '.\\LPT1:..\\..\\another.txt' },
76+
77+
// Paths with device names not at the beginning
78+
{ input: 'C:\\CON', expected: 'C:\\CON' },
79+
{ input: 'C:\\path\\to\\COM1:', expected: 'C:\\path\\to\\COM1:' },
80+
81+
// Device name followed by multiple colons
82+
{ input: 'CON::', expected: '.\\CON::' },
83+
{ input: 'COM1:::foo', expected: '.\\COM1:::foo' },
84+
85+
// Device name with mixed path separators
86+
{ input: 'AUX:/foo\\bar/baz', expected: '.\\AUX:foo\\bar\\baz' },
87+
];
88+
89+
for (const { input, expected } of normalizeDeviceNameTests) {
90+
const actual = path.win32.normalize(input);
91+
assert.strictEqual(actual, expected,
92+
`path.win32.normalize(${JSON.stringify(input)}) === ${JSON.stringify(expected)}, but got ${JSON.stringify(actual)}`);
93+
}
94+
95+
assert.strictEqual(path.win32.normalize('CON:foo/../bar'), '.\\CON:bar');
96+
97+
// This should NOT be prefixed because 'c:' is treated as a drive letter.
98+
assert.strictEqual(path.win32.normalize('c:COM1:'), 'c:COM1:');

0 commit comments

Comments
 (0)