Skip to content

url: fix root slash and extended UNC check in pathToFileURL - #67092

Open
JoaoMarcos44 wants to merge 1 commit into
nodejs:mainfrom
JoaoMarcos44:fix-path-to-file-url
Open

JoaoMarcos44 wants to merge 1 commit into
nodejs:mainfrom
JoaoMarcos44:fix-path-to-file-url

Conversation

@JoaoMarcos44

@JoaoMarcos44 JoaoMarcos44 commented Oct 28, 2026 •

Copy link
Copy Markdown

Summary

This PR resolves two bugs in url.pathToFileURL(filepath, options) (lib/internal/url.js):

  1. Cross-platform root path double trailing slash (file://// and file:///C://):
    When preserving trailing slashes on directory inputs (filePathLast === CHAR_FORWARD_SLASH || (windows && filePathLast === CHAR_BACKWARD_SLASH)), pathToFileURL guarded against duplicating a root directory's existing separator by checking resolved[resolved.length - 1] !== path.sep. Because path.sep always reflects the host OS ('\\' on Windows, '/' on POSIX) rather than the target platform (windows ? '\\' : '/'), cross-platform calls with root paths failed this guard:

    • On a Windows host (path.sep === '\\'), url.pathToFileURL('/', { windows: false }) resolves '/' via path.posix.resolve('/') to '/', evaluates '/' !== '\\' (true), and appends an extra '/', producing 'file:////' (4 slashes) instead of 'file:///'.
    • On a POSIX host (path.sep === '/'), url.pathToFileURL('C:\\', { windows: true }) and url.pathToFileURL('C:/', { windows: true }) resolve via path.win32.resolve(...) to 'C:\\', evaluate '\\' !== '/' (true), and append '/', producing 'file:///C://' instead of 'file:///C:/'.
  2. Extended-length UNC path (\\?\UNC\...) with empty servername bypasses validation:
    When parsing Windows UNC paths, pathToFileURL computes const prefixLength = isExtendedUNC ? 8 : 2 (2 for standard \\server\share, 8 for extended-length \\?\UNC\server\share) and searches for the next backslash starting at prefixLength: const hostnameEndIndex = StringPrototypeIndexOf(resolved, '\\', prefixLength).
    However, the empty-servername check on line 1698 hardcoded if (hostnameEndIndex === 2) instead of if (hostnameEndIndex === prefixLength). Consequently, passing an extended-length UNC path with an empty servername such as url.pathToFileURL('\\\\?\\UNC\\\\share', { windows: true }) yielded hostnameEndIndex = 8, bypassed 8 === 2, extracted an empty hostname (resolved.slice(8, 8) === ''), and silently returned URL { href: 'file:///share' } instead of throwing ERR_INVALID_ARG_VALUE ('Empty UNC servername').

Fixes: #67091


Visual Overview

Node.js url.pathToFileURL — Cross-Platform Slash & UNC Fix

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:/' ✅"]
    end
Loading
flowchart 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') ✅"]
    end
Loading

Deep-Dive Root Cause Analysis

1. Host path.sep Leakage into Cross-Platform Resolution (lib/internal/url.js, lines 1678–1716)

At the beginning of pathToFileURL(filepath, options = kEmptyObject):

const windows = options?.windows ?? isWindows;
let resolved = windows ?
  path.win32.resolve(filepath) :
  path.posix.resolve(filepath);

When filepath is 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', and path.win32.resolve('C:\\dir\\') strips the trailing backslash and returns 'C:\\dir'. To preserve the directory semantics in the resulting file: URL, pathToFileURL appends '/' if filepath ended with a separator.

However, when filepath resolves to a root directory ('/' on POSIX or 'C:\\' on Windows), path.posix.resolve('/') and path.win32.resolve('C:\\') retain their trailing separator ('/' and '\\', respectively). To avoid appending a second slash to root paths, line 1713 checked:

// BEFORE:
const filePathLast = StringPrototypeCharCodeAt(filepath,
                                               filepath.length - 1);
if ((filePathLast === CHAR_FORWARD_SLASH ||
     ((windows ?? isWindows) && filePathLast === CHAR_BACKWARD_SLASH)) &&
    resolved[resolved.length - 1] !== path.sep)
  resolved += '/';

There were two flaws in this condition:

  1. resolved[resolved.length - 1] !== path.sep checked the host's path.sep instead of the target platform's separator (windows ? '\\' : '/'):
    • When windows is false on a Windows host (path.sep === '\\'), resolved is '/'. Since '/' !== '\\' is true, resolved becomes '//'. Then new URL('//', undefined, kCreateURLFromPosixPathSymbol) encodes '//' into pathname '//', producing file:////.
    • When windows is true on a POSIX host (path.sep === '/'), resolved is 'C:\\'. Since '\\' !== '/' is true, resolved becomes 'C:\\/'. Then new URL('C:\\/', undefined, kCreateURLFromWindowsPathSymbol) converts both separators into slashes, producing file:///C://.
  2. (windows ?? isWindows) was redundant:
    • windows is already initialized on line 1678 as const windows = options?.windows ?? isWindows;, which is always a boolean (true or false), so windows ?? isWindows can never fall back to isWindows.
// AFTER:
const filePathLast = StringPrototypeCharCodeAt(filepath,
                                               filepath.length - 1);
if ((filePathLast === CHAR_FORWARD_SLASH ||
     (windows && filePathLast === CHAR_BACKWARD_SLASH)) &&
    resolved[resolved.length - 1] !== (windows ? '\\' : '/'))
  resolved += '/';

2. Hardcoded Index 2 in Extended-Length UNC Validation (lib/internal/url.js, lines 1685–1706)

When windows is true and resolved starts with '\\\\', pathToFileURL handles both standard UNC paths (\\server\share) and Win32 extended-length UNC paths (\\?\UNC\server\share):

const isExtendedUNC = StringPrototypeStartsWith(resolved, '\\\\?\\UNC\\');
const prefixLength = isExtendedUNC ? 8 : 2;
const hostnameEndIndex = StringPrototypeIndexOf(
  resolved,
  '\\',
  prefixLength,
);
if (hostnameEndIndex === -1) {
  throw new ERR_INVALID_ARG_VALUE(
    'path',
    resolved,
    'Missing UNC resource path',
  );
}
if (hostnameEndIndex === 2) { // <-- BUG: hardcoded 2 instead of prefixLength!
  throw new ERR_INVALID_ARG_VALUE(
    'path',
    resolved,
    'Empty UNC servername',
  );
}
const hostname = StringPrototypeSlice(resolved, prefixLength, hostnameEndIndex);
  • For a standard UNC path '\\\\\\share', isExtendedUNC is false, prefixLength is 2, and StringPrototypeIndexOf('\\\\\\share', '\\', 2) returns 2. The check hostnameEndIndex === 2 catches the empty servername and throws ERR_INVALID_ARG_VALUE ('Empty UNC servername').
  • For an extended-length UNC path '\\\\?\\UNC\\\\share', isExtendedUNC is true and prefixLength is 8. Because path.win32.resolve('\\\\?\\UNC\\\\share') preserves the \\\\?\\ prefix verbatim without collapsing consecutive backslashes, resolved is '\\\\?\\UNC\\\\share'.
  • Calling StringPrototypeIndexOf('\\\\?\\UNC\\\\share', '\\', 8) finds the backslash at index 8 (hostnameEndIndex === 8).
  • Because 8 === 2 evaluated to false, execution proceeded to StringPrototypeSlice(resolved, 8, 8), which returned '' (empty string) as the hostname, and constructed file:///share instead of throwing ERR_INVALID_ARG_VALUE.
  • Changing if (hostnameEndIndex === 2) to if (hostnameEndIndex === prefixLength) properly validates both standard (prefixLength === 2) and extended-length (prefixLength === 8) UNC paths.

Step-by-Step Execution Trace (Before vs. After)

Scenario Input Call Host OS resolved after path.*.resolve Guard Evaluation (Before) Output (Before) Guard Evaluation (After) Output (After)
1. POSIX root on Windows pathToFileURL('/', { windows: false }) Windows (path.sep === '\\') '/' '/' !== '\\' (true -> appends '/') 'file:////' ❌ '/' !== '/' (false) 'file:///' ✅
2. Windows root (\) on POSIX pathToFileURL('C:\\', { windows: true }) POSIX (path.sep === '/') 'C:\\' '\\' !== '/' (true -> appends '/') 'file:///C://' ❌ '\\' !== '\\' (false) 'file:///C:/' ✅
3. Windows root (/) on POSIX pathToFileURL('C:/', { windows: true }) POSIX (path.sep === '/') 'C:\\' '\\' !== '/' (true -> appends '/') 'file:///C://' ❌ '\\' !== '\\' (false) 'file:///C:/' ✅
4. Extended UNC empty server pathToFileURL('\\\\?\\UNC\\\\no-server', { windows: true }) Any '\\\\?\\UNC\\\\no-server' hostnameEndIndex === 2 (8 === 2 -> false) 'file:///no-server' ❌ hostnameEndIndex === prefixLength (8 === 8 -> true) Throws ERR_INVALID_ARG_VALUE ✅

Reproduction

const assert = require('node:assert');
const url = require('node:url');

// 1. Cross-platform POSIX root path on Windows (or simulated)
assert.strictEqual(
  url.pathToFileURL('/', { windows: false }).href,
  'file:///'
);

// 2. Cross-platform Windows drive roots on POSIX (or simulated)
assert.strictEqual(
  url.pathToFileURL('C:\\', { windows: true }).href,
  'file:///C:/'
);
assert.strictEqual(
  url.pathToFileURL('C:/', { windows: true }).href,
  'file:///C:/'
);

// 3. Extended-length UNC path with empty servername
assert.throws(
  () => url.pathToFileURL('\\\\?\\UNC\\\\no-server', { windows: true }),
  {
    code: 'ERR_INVALID_ARG_VALUE',
    message: /Empty UNC servername/,
  }
);

Test Coverage & Verification

  • test/parallel/test-url-pathtofileurl.js:
    • Added '\\\\?\\UNC\\\\no-server' to the empty-server UNC assert.throws suite (ERR_INVALID_ARG_VALUE).
    • Added { path: 'C:\\', expected: 'file:///C:/' } and { path: 'C:/', expected: 'file:///C:/' } to windowsTestCases (which runs both with { windows: true } on all platforms and natively on Windows).
    • Added { path: '/', expected: 'file:///' } to posixTestCases (which runs both with { windows: false } on all platforms and natively on POSIX).
  • Verified all existing tests in test/parallel/test-url-pathtofileurl.js and Node.js ESLint rules pass with zero regressions.

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>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/url

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. whatwg-url Issues and PRs related to the WHATWG URL implementation. labels Oct 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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.

@MikeMcC399

Copy link
Copy Markdown
Contributor

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 main branch, GitHub classifies you as "first-time contributor". Each PR commit you push needs to be individually approved by team members to run in GitHub Actions CI. This causes delays for you as a contributor, and it adds to the work load for the team.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-ci PRs that need a full CI run. whatwg-url Issues and PRs related to the WHATWG URL implementation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

url: pathToFileURL duplicates root slash across platforms and misses empty extended UNC servername

3 participants