Repository: nodejs/node
Pull request: lib: handle windows reserved device names on UNC

**lib: handle windows reserved device names on UNC**
```
We have found that UNC paths weren't covered
when .join/.normalize windows reserved device
names (COM1, LPT1).
```

---

Windows paths (specifically, UNC) are confusing, I'm not convinced I have covered all cases where `..` would skip device names, but this PR should improve things a bit.

Without this PR a path `\\\\?\\COM1:.\\..\\..\\foo2.js` would be "normalized" to `\\\\?\\foo2.js` (skipping the COM1 device completely). This is not a vulnerability because we clearly state that path sanitisation is the user's responsibility in https://github.com/nodejs/node/pull/59262



Changed files:
filename: lib/path.js
patch: @@ -400,6 +400,13 @@ const win32 = {
                 // We matched a device root (e.g. \\\\.\\PHYSICALDRIVE0)
                 device = `\\\\${firstPart}`;
                 rootEnd = 4;
+                const colonIndex = StringPrototypeIndexOf(path, ':');
+                // Special case: handle \\?\COM1: or similar reserved device paths
+                const possibleDevice = StringPrototypeSlice(path, 4, colonIndex + 1);
+                if (isWindowsReservedName(possibleDevice, possibleDevice.length - 1)) {
+                  device = `\\\\?\\${possibleDevice}`;
+                  rootEnd = 4 + possibleDevice.length;
+                }
               } else if (j === len) {
                 // We matched a UNC root only
                 // Return the normalized version of the UNC root since there
@@ -558,6 +565,36 @@ const win32 = {
         joined = `\\${StringPrototypeSlice(joined, slashCount)}`;
     }
 
+    // Skip normalization when reserved device names are present
+    const parts = [];
+    let part = '';
+
+    for (let i = 0; i < joined.length; i++) {
+      if (joined[i] === '\\') {
+        if (part) parts.push(part);
+        part = '';
+        // Skip consecutive backslashes
+        while (i + 1 < joined.length && joined[i + 1] === '\\') i++;
+      } else {
+        part += joined[i];
+      }
+    }
+    // Add the final part if any
+    if (part) parts.push(part);
+
+    // Check if any part has a Windows reserved name
+    if (parts.some((p) => {
+      const colonIndex = StringPrototypeIndexOf(p, ':');
+      return colonIndex !== -1 && isWindowsReservedName(p, colonIndex);
+    })) {
+      // Replace forward slashes with backslashes
+      let result = '';
+      for (let i = 0; i < joined.length; i++) {
+        result += joined[i] === '/' ? '\\' : joined[i];
+      }
+      return result;
+    }
+
     return win32.normalize(joined);
   },
 

filename: test/parallel/test-path-join.js
patch: @@ -110,6 +110,14 @@ joinTests.push([
       [['c:.', 'file'], 'c:file'],
       [['c:', '/'], 'c:\\'],
       [['c:', 'file'], 'c:\\file'],
+      // UNC path join tests (Windows)
+      [['\\server\\share', 'file.txt'], '\\server\\share\\file.txt'],
+      [['\\server\\share', 'folder', 'another.txt'], '\\server\\share\\folder\\another.txt'],
+      [['\\server\\share', 'COM1:'], '\\server\\share\\COM1:'],
+      [['\\server\\share', 'path', 'LPT1:'], '\\server\\share\\path\\LPT1:'],
+      [['\\fileserver\\public\\uploads', 'CON:..\\..\\..\\private\\db.conf'],
+       '\\fileserver\\public\\uploads\\CON:..\\..\\..\\private\\db.conf'],
+
       // Path traversal in previous versions of Node.js.
       [['./upload', '/../C:/Windows'], '.\\C:\\Windows'],
       [['upload', '../', 'C:foo'], '.\\C:foo'],

filename: test/parallel/test-path-win32-normalize-device-names.js
patch: @@ -9,6 +9,19 @@ if (!common.isWindows) {
 }
 
 const normalizeDeviceNameTests = [
+  // UNC paths: \\server\share\... is a Windows UNC path, where 'server' is the network server name and 'share'
+  // is the shared folder. These are used for network file access and are subject to reserved device name
+  // checks after the share.
+  { input: '\\\\server\\share\\COM1:', expected: '\\\\server\\share\\COM1:' },
+  { input: '\\\\server\\share\\PRN:', expected: '\\\\server\\share\\PRN:' },
+  { input: '\\\\server\\share\\AUX:', expected: '\\\\server\\share\\AUX:' },
+  { input: '\\\\server\\share\\LPT1:', expected: '\\\\server\\share\\LPT1:' },
+  { input: '\\\\server\\share\\COM1:\\foo\\bar', expected: '\\\\server\\share\\COM1:\\foo\\bar' },
+  { input: '\\\\server\\share\\path\\COM1:', expected: '\\\\server\\share\\path\\COM1:' },
+  { input: '\\\\server\\share\\COM1:..\\..\\..\\..\\Windows', expected: '\\\\server\\share\\Windows' },
+  { input: '\\\\server\\share\\path\\to\\LPT9:..\\..\\..\\..\\..\\..\\..\\..\\..\\file.txt',
+    expected: '\\\\server\\share\\file.txt' },
+
   { input: 'CON', expected: 'CON' },
   { input: 'con', expected: 'con' },
   { input: 'CON:', expected: '.\\CON:.' },
@@ -81,6 +94,8 @@ const normalizeDeviceNameTests = [
   // Test cases from original vulnerability reports or similar scenarios
   { input: 'COM1:.\\..\\..\\foo.js', expected: '.\\COM1:..\\..\\foo.js' },
   { input: 'LPT1:.\\..\\..\\another.txt', expected: '.\\LPT1:..\\..\\another.txt' },
+  // UNC paths
+  { input: '\\\\?\\COM1:.\\..\\..\\foo2.js', expected: '\\\\?\\COM1:\\foo2.js' },
 
   // Paths with device names not at the beginning
   { input: 'C:\\CON', expected: 'C:\\CON' },