Repository navigation
url: fix root slash and extended UNC check in pathToFileURL - #67092
JoaoMarcos44 wants to merge 1 commit into
Conversation
When `pathToFileURL()` restores trailing slashes stripped by `path.resolve()`, it previously compared the last character of `resolved` against the host `path.sep` instead of the target separator (`windows ? '\\' : '/'`), duplicating the root slash when `options.windows` differed from the host platform. Additionally, check `hostnameEndIndex === prefixLength` instead of `hostnameEndIndex === 2` so extended UNC paths (`\\?\UNC\\share`) with an empty server name also throw `ERR_INVALID_ARG_VALUE`. Fixes: nodejs#67091 Signed-off-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
|
Review requested:
|
|
Welcome to Node.js, and thank you for your first contribution! Before review, please take a moment to read:
Please make sure every commit is signed off. For a first pull request, GitHub Actions require collaborator approval and Jenkins CI must be started by a collaborator or triager, so an initial wait is normal. |
|
The recommendations in: advise you should tackle only one issue at a time and that you should not open any new PRs until your first PR has been approved. You already have one other unapproved PR #67086 open at this time. Until the first PR has been approved and has landed in the Waiting until the first PR has landed ensures that you as a contributor have experienced the complete process from PR submission to successful landing. After this has been completed, any new PRs or new commits to an existing PR are treated to rules for regular Contributors. GitHub Actions CI workflows are then run automatically without intervention by a team member. |
Summary
This PR resolves two bugs in
url.pathToFileURL(filepath, options)(lib/internal/url.js):Cross-platform root path double trailing slash (
file:////andfile:///C://):When preserving trailing slashes on directory inputs (
filePathLast === CHAR_FORWARD_SLASH || (windows && filePathLast === CHAR_BACKWARD_SLASH)),pathToFileURLguarded against duplicating a root directory's existing separator by checkingresolved[resolved.length - 1] !== path.sep. Becausepath.sepalways reflects the host OS ('\\'on Windows,'/'on POSIX) rather than the target platform (windows ? '\\' : '/'), cross-platform calls with root paths failed this guard:path.sep === '\\'),url.pathToFileURL('/', { windows: false })resolves'/'viapath.posix.resolve('/')to'/', evaluates'/' !== '\\'(true), and appends an extra'/', producing'file:////'(4 slashes) instead of'file:///'.path.sep === '/'),url.pathToFileURL('C:\\', { windows: true })andurl.pathToFileURL('C:/', { windows: true })resolve viapath.win32.resolve(...)to'C:\\', evaluate'\\' !== '/'(true), and append'/', producing'file:///C://'instead of'file:///C:/'.Extended-length UNC path (
\\?\UNC\...) with empty servername bypasses validation:When parsing Windows UNC paths,
pathToFileURLcomputesconst prefixLength = isExtendedUNC ? 8 : 2(2for standard\\server\share,8for extended-length\\?\UNC\server\share) and searches for the next backslash starting atprefixLength:const hostnameEndIndex = StringPrototypeIndexOf(resolved, '\\', prefixLength).However, the empty-servername check on line 1698 hardcoded
if (hostnameEndIndex === 2)instead ofif (hostnameEndIndex === prefixLength). Consequently, passing an extended-length UNC path with an empty servername such asurl.pathToFileURL('\\\\?\\UNC\\\\share', { windows: true })yieldedhostnameEndIndex = 8, bypassed8 === 2, extracted an empty hostname (resolved.slice(8, 8) === ''), and silently returnedURL { href: 'file:///share' }instead of throwingERR_INVALID_ARG_VALUE('Empty UNC servername').Fixes: #67091
Visual Overview
Control-Flow Diagrams
flowchart TD subgraph Bug1["Bug 1: Cross-Platform Root Trailing Separator Guard"] A["Input: pathToFileURL(filepath, { windows })"] --> B["windows = options?.windows ?? isWindows\nresolved = windows ? path.win32.resolve(filepath) : path.posix.resolve(filepath)"] B --> C{"Input ends with separator?\n'/' or (windows && '\\\\')"} C -->|Yes| D{"Before (Buggy):\nresolved.at(-1) !== path.sep\n(Uses HOST path.sep!)"} D -->|"Windows host + { windows: false }\n'/' !== '\\\\' is TRUE"| E["resolved += '/' -> '//'\nReturns 'file:////' ❌"] D -->|"POSIX host + { windows: true }\n'\\\\' !== '/' is TRUE"| F["resolved += '/' -> 'C:\\\\/'\nReturns 'file:///C://' ❌"] C -->|Yes| G{"After (Fixed):\nresolved.at(-1) !== (windows ? '\\\\' : '/')\n(Uses TARGET separator!)"} G -->|"Windows host + { windows: false }\n'/' !== '/' is FALSE"| H["Keeps resolved = '/'\nReturns 'file:///' ✅"] G -->|"POSIX host + { windows: true }\n'\\\\' !== '\\\\' is FALSE"| I["Keeps resolved = 'C:\\\\'\nReturns 'file:///C:/' ✅"] endflowchart TD subgraph Bug2["Bug 2: Extended-Length UNC (\\\\?\\UNC\\) Empty Servername Validation"] U1["Input: pathToFileURL('\\\\\\\\?\\\\UNC\\\\\\\\share', { windows: true })"] --> U2["isExtendedUNC = StringPrototypeStartsWith(resolved, '\\\\\\\\?\\\\UNC\\\\') -> true\nprefixLength = isExtendedUNC ? 8 : 2 -> 8"] U2 --> U3["hostnameEndIndex = StringPrototypeIndexOf(resolved, '\\\\', 8) -> 8"] U3 --> U4{"Before (Buggy):\nhostnameEndIndex === 2"} U4 -->|"8 === 2 is FALSE"| U5["hostname = resolved.slice(8, 8) -> ''\nSilently returns 'file:///share' ❌"] U3 --> U6{"After (Fixed):\nhostnameEndIndex === prefixLength"} U6 -->|"8 === 8 is TRUE"| U7["Throws ERR_INVALID_ARG_VALUE\n('Empty UNC servername') ✅"] endDeep-Dive Root Cause Analysis
1. Host
path.sepLeakage into Cross-Platform Resolution (lib/internal/url.js, lines 1678–1716)At the beginning of
pathToFileURL(filepath, options = kEmptyObject):When
filepathis a non-root directory path with a trailing slash (e.g.,'/dir/'on POSIX or'C:\\dir\\'on Windows),path.posix.resolve('/dir/')strips the trailing slash and returns'/dir', andpath.win32.resolve('C:\\dir\\')strips the trailing backslash and returns'C:\\dir'. To preserve the directory semantics in the resultingfile:URL,pathToFileURLappends'/'iffilepathended with a separator.However, when
filepathresolves to a root directory ('/'on POSIX or'C:\\'on Windows),path.posix.resolve('/')andpath.win32.resolve('C:\\')retain their trailing separator ('/'and'\\', respectively). To avoid appending a second slash to root paths, line 1713 checked:There were two flaws in this condition:
resolved[resolved.length - 1] !== path.sepchecked the host'spath.sepinstead of the target platform's separator (windows ? '\\' : '/'):windowsisfalseon a Windows host (path.sep === '\\'),resolvedis'/'. Since'/' !== '\\'istrue,resolvedbecomes'//'. Thennew URL('//', undefined, kCreateURLFromPosixPathSymbol)encodes'//'into pathname'//', producingfile:////.windowsistrueon a POSIX host (path.sep === '/'),resolvedis'C:\\'. Since'\\' !== '/'istrue,resolvedbecomes'C:\\/'. Thennew URL('C:\\/', undefined, kCreateURLFromWindowsPathSymbol)converts both separators into slashes, producingfile:///C://.(windows ?? isWindows)was redundant:windowsis already initialized on line 1678 asconst windows = options?.windows ?? isWindows;, which is always a boolean (trueorfalse), sowindows ?? isWindowscan never fall back toisWindows.2. Hardcoded Index
2in Extended-Length UNC Validation (lib/internal/url.js, lines 1685–1706)When
windowsistrueandresolvedstarts with'\\\\',pathToFileURLhandles both standard UNC paths (\\server\share) and Win32 extended-length UNC paths (\\?\UNC\server\share):'\\\\\\share',isExtendedUNCisfalse,prefixLengthis2, andStringPrototypeIndexOf('\\\\\\share', '\\', 2)returns2. The checkhostnameEndIndex === 2catches the empty servername and throwsERR_INVALID_ARG_VALUE('Empty UNC servername').'\\\\?\\UNC\\\\share',isExtendedUNCistrueandprefixLengthis8. Becausepath.win32.resolve('\\\\?\\UNC\\\\share')preserves the\\\\?\\prefix verbatim without collapsing consecutive backslashes,resolvedis'\\\\?\\UNC\\\\share'.StringPrototypeIndexOf('\\\\?\\UNC\\\\share', '\\', 8)finds the backslash at index8(hostnameEndIndex === 8).8 === 2evaluated tofalse, execution proceeded toStringPrototypeSlice(resolved, 8, 8), which returned''(empty string) as the hostname, and constructedfile:///shareinstead of throwingERR_INVALID_ARG_VALUE.if (hostnameEndIndex === 2)toif (hostnameEndIndex === prefixLength)properly validates both standard (prefixLength === 2) and extended-length (prefixLength === 8) UNC paths.Step-by-Step Execution Trace (Before vs. After)
resolvedafterpath.*.resolvepathToFileURL('/', { windows: false })path.sep === '\\')'/''/' !== '\\'(true-> appends'/')'file:////'❌'/' !== '/'(false)'file:///'✅\) on POSIXpathToFileURL('C:\\', { windows: true })path.sep === '/')'C:\\''\\' !== '/'(true-> appends'/')'file:///C://'❌'\\' !== '\\'(false)'file:///C:/'✅/) on POSIXpathToFileURL('C:/', { windows: true })path.sep === '/')'C:\\''\\' !== '/'(true-> appends'/')'file:///C://'❌'\\' !== '\\'(false)'file:///C:/'✅pathToFileURL('\\\\?\\UNC\\\\no-server', { windows: true })'\\\\?\\UNC\\\\no-server'hostnameEndIndex === 2(8 === 2->false)'file:///no-server'❌hostnameEndIndex === prefixLength(8 === 8->true)ERR_INVALID_ARG_VALUE✅Reproduction
Test Coverage & Verification
test/parallel/test-url-pathtofileurl.js:'\\\\?\\UNC\\\\no-server'to the empty-server UNCassert.throwssuite (ERR_INVALID_ARG_VALUE).{ path: 'C:\\', expected: 'file:///C:/' }and{ path: 'C:/', expected: 'file:///C:/' }towindowsTestCases(which runs both with{ windows: true }on all platforms and natively on Windows).{ path: '/', expected: 'file:///' }toposixTestCases(which runs both with{ windows: false }on all platforms and natively on POSIX).test/parallel/test-url-pathtofileurl.jsand Node.js ESLint rules pass with zero regressions.