From b82a48da1c951a37268f2ead67e21eecd2eca80c Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 14 Apr 2026 20:17:25 +0800 Subject: [PATCH] fix(install): reject non-semver package manager versions Strictly validate the resolved version in `download_package_manager` before it is interpolated into `$VP_HOME/package_manager/{name}/{version}`. `AbsolutePath::join` does not normalize `..`, so a version containing path components could escape the home directory. The check also covers registry-controlled `latest` lookups. fixes https://github.com/voidzero-dev/vite-plus/security/advisories/GHSA-33r3-4whc-44c2 --- crates/vite_install/src/package_manager.rs | 41 +++++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/vite_install/src/package_manager.rs b/crates/vite_install/src/package_manager.rs index 4be6cd0099..bd7c16e4f3 100644 --- a/crates/vite_install/src/package_manager.rs +++ b/crates/vite_install/src/package_manager.rs @@ -386,14 +386,27 @@ pub async fn download_package_manager( version_or_latest.into() }; + // Reject anything that is not strict semver `major.minor.patch[-prerelease][+build]`. + // This prevents path traversal via the version being interpolated into + // `$VP_HOME/package_manager/{name}/{version}` below, since `AbsolutePath::join` + // does not normalize `..` components. Also guards against registry-controlled + // "latest" lookups returning a malicious value. + let parsed_version = Version::parse(&version).map_err(|_| { + Error::InvalidArgument( + format!( + "invalid {package_manager_type} version {version:?}: expected semver 'major.minor.patch'" + ) + .into(), + ) + })?; + let mut package_name: Str = package_manager_type.to_string().into(); // handle yarn >= 2.0.0 to use `@yarnpkg/cli-dist` as package name // @see https://github.com/nodejs/corepack/blob/main/config.json#L135 - if matches!(package_manager_type, PackageManagerType::Yarn) { - let version_req = VersionReq::parse(">=2.0.0")?; - if version_req.matches(&Version::parse(&version)?) { - package_name = "@yarnpkg/cli-dist".into(); - } + if matches!(package_manager_type, PackageManagerType::Yarn) + && VersionReq::parse(">=2.0.0")?.matches(&parsed_version) + { + package_name = "@yarnpkg/cli-dist".into(); } let home_dir = vite_shared::get_vp_home()?; @@ -1840,6 +1853,24 @@ mod tests { assert!(result.get_bin_prefix().ends_with("pnpm/bin")); } + #[tokio::test] + async fn test_download_package_manager_rejects_path_traversal_version() { + // Versions containing path separators or traversal components must be + // rejected before any filesystem operations: `AbsolutePath::join` does + // not normalize `..`, so a bad version would escape the home dir. + for bad in ["../../../escape", "..", "1.0.0/../../escape", "/foo/bar", "1.0.0\0", ""] { + let result = download_package_manager(PackageManagerType::Pnpm, bad, None).await; + match result { + Err(Error::InvalidArgument(_)) => {} + other => panic!("expected InvalidArgument for {bad:?}, got {other:?}"), + } + } + + // Bun takes a separate code path but shares the same pre-validation. + let result = download_package_manager(PackageManagerType::Bun, "../../escape", None).await; + assert!(matches!(result, Err(Error::InvalidArgument(_)))); + } + #[tokio::test] async fn test_download_package_manager() { let result = download_package_manager(PackageManagerType::Yarn, "4.9.2", None).await;