diff --git a/src/lib.rs b/src/lib.rs index 76e188f9..b610ed06 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -295,6 +295,9 @@ impl ResolverGeneric { .value(Utf8Path::from_path(path).expect("path should be UTF-8")); let cached_path = self.require(&cached_path, specifier, ctx).await?; let path = self.load_realpath(&cached_path, ctx).await?; + if !self.options.restrictions.is_empty() && !self.check_restrictions(&path.normalize()) { + return Err(ResolveError::NotFound(specifier.to_string())); + } let package_json = cached_path .find_package_json(&self.cache.fs, &self.options, ctx) @@ -766,23 +769,11 @@ impl ResolverGeneric { } fn check_restrictions(&self, path: &Utf8Path) -> bool { - // https://github.com/webpack/enhanced-resolve/blob/a998c7d218b7a9ec2461fc4fddd1ad5dd7687485/lib/RestrictionsPlugin.js#L19-L24 - fn is_inside(path: &Path, parent: &Path) -> bool { - if !path.starts_with(parent) { - return false; - } - if path.as_os_str().len() == parent.as_os_str().len() { - return true; - } - path - .strip_prefix(parent) - .is_ok_and(|p| p == Path::new("./")) - } let path = path.as_std_path(); for restriction in &self.options.restrictions { match restriction { Restriction::Path(restricted_path) => { - if !is_inside(path, restricted_path) { + if !path.starts_with(restricted_path) { return false; } } diff --git a/src/options.rs b/src/options.rs index e3d4c1a9..b2e2f154 100644 --- a/src/options.rs +++ b/src/options.rs @@ -4,6 +4,10 @@ use std::{ sync::Arc, }; +use camino::Utf8Path; + +use crate::path::PathUtil; + /// Module Resolution Options /// /// Options are directly ported from [enhanced-resolve](https://github.com/webpack/enhanced-resolve#resolver-options). @@ -394,6 +398,14 @@ impl ResolveOptions { self.enforce_extension = EnforceExtension::Disabled; } } + for restriction in &mut self.restrictions { + if let Restriction::Path(path) = restriction { + let normalized = Utf8Path::from_path(path).map(PathUtil::normalize); + if let Some(normalized) = normalized.filter(|p| !p.as_str().is_empty()) { + *path = normalized.into_std_path_buf(); + } + } + } self } } diff --git a/src/tests/restrictions.rs b/src/tests/restrictions.rs index e98e8017..eb980767 100644 --- a/src/tests/restrictions.rs +++ b/src/tests/restrictions.rs @@ -56,6 +56,34 @@ async fn should_respect_string_restriction() { assert_eq!(resolution, Err(ResolveError::NotFound("pck2".to_string()))); } +#[tokio::test] +async fn should_allow_descendant_of_string_restriction() { + let f = super::fixture().join("restrictions"); + + let resolver = Resolver::new(ResolveOptions { + extensions: vec![".js".into()], + restrictions: vec![Restriction::Path(f.clone())], + ..ResolveOptions::default() + }); + + let resolution = resolver.resolve(&f, "pck1").await.map(|r| r.full_path()); + assert_eq!(resolution, Ok(f.join("node_modules/pck1/index.js"))); +} + +#[tokio::test] +async fn should_reject_sibling_sharing_textual_prefix() { + let f = super::fixture().join("restrictions"); + + let resolver = Resolver::new(ResolveOptions { + extensions: vec![".js".into()], + restrictions: vec![Restriction::Path(f.join("node_modules/pck"))], + ..ResolveOptions::default() + }); + + let resolution = resolver.resolve(&f, "pck1").await.map(|r| r.full_path()); + assert_eq!(resolution, Err(ResolveError::NotFound("pck1".to_string()))); +} + #[tokio::test] async fn should_try_to_find_alternative_2() { let f = super::fixture().join("restrictions"); @@ -110,3 +138,274 @@ async fn should_try_to_find_alternative_4() { let resolution = resolver1.resolve(&f, "pck2").await.map(|r| r.full_path()); assert_eq!(resolution, Ok(f.join("node_modules/pck2/index.css"))); } + +/// Ported from enhanced-resolve `restrictions > path boundaries` +/// +/// +/// `MemoryFS` always separates with `/`, so these run on non-Windows only. +#[cfg(not(target_os = "windows"))] +mod path_boundaries { + use std::path::PathBuf; + + use super::super::memory_fs::MemoryFS; + use crate::{ResolveOptions, ResolverGeneric, Restriction}; + + async fn resolves(restriction: &str, context: &str, request: &str, file: &'static str) -> bool { + let resolver = ResolverGeneric::::new_with_file_system( + MemoryFS::new(&[(file, "")]), + ResolveOptions { + extensions: vec![".js".into()], + restrictions: vec![Restriction::Path(PathBuf::from(restriction))], + ..ResolveOptions::default() + }, + ); + resolver.resolve(context, request).await.is_ok() + } + + #[tokio::test] + async fn file_inside_a_restriction() { + assert!(resolves("/a/b/c", "/a/b/c", "./index.js", "/a/b/c/index.js").await); + } + + #[tokio::test] + async fn sibling_of_a_restriction() { + assert!(!resolves("/a/b/c", "/a/b", "./c-other.js", "/a/b/c-other.js").await); + } + + #[tokio::test] + async fn sibling_of_a_restriction_separated_by_a_backslash() { + assert!(!resolves("/a/b/c", "/a/b", "./c\\sibling.js", "/a/b/c\\sibling.js").await); + } + + #[tokio::test] + async fn sibling_of_a_restriction_containing_a_backslash() { + assert!(!resolves("/a/b\\c", "/a", "./b\\c\\sibling.js", "/a/b\\c\\sibling.js").await); + } + + #[tokio::test] + async fn file_inside_a_restriction_ending_with_a_separator() { + assert!(resolves("/a/b/c/", "/a/b/c", "./index.js", "/a/b/c/index.js").await); + } + + #[tokio::test] + async fn file_inside_the_root_restriction() { + assert!(resolves("/", "/a", "./index.js", "/a/index.js").await); + } + + #[tokio::test] + async fn file_inside_a_non_normalized_restriction() { + assert!(resolves("/a/x/../b/c", "/a/b/c", "./index.js", "/a/b/c/index.js").await); + } + + #[tokio::test] + async fn empty_restriction_matches_every_path() { + assert!(resolves("", "/a/b/c", "./index.js", "/a/b/c/index.js").await); + } + + #[tokio::test] + async fn relative_restriction_matches_no_absolute_path() { + assert!(!resolves(".", "/a/b/c", "./index.js", "/a/b/c/index.js").await); + assert!(!resolves("..", "/a/b/c", "./index.js", "/a/b/c/index.js").await); + assert!(!resolves("foo/..", "/a/b/c", "./index.js", "/a/b/c/index.js").await); + } + + #[tokio::test] + async fn file_inside_a_restriction_differing_in_case() { + assert!(!resolves("/A/B/C", "/a/b/c", "./index.js", "/a/b/c/index.js").await); + } +} + +/// Ported from enhanced-resolve `restrictions > windows and posix semantics` +/// +/// +/// Windows path forms cannot travel through a resolver on a posix host, so the +/// check is driven directly, the way upstream drives its plugin. +#[cfg(target_os = "windows")] +mod windows_path_boundaries { + use std::path::PathBuf; + + use camino::Utf8Path; + + use crate::{ResolveOptions, Resolver, Restriction}; + + fn is_inside(restriction: &str, path: &str) -> bool { + let resolver = Resolver::new(ResolveOptions { + restrictions: vec![Restriction::Path(PathBuf::from(restriction))], + ..ResolveOptions::default() + }); + resolver.check_restrictions(Utf8Path::new(path)) + } + + #[tokio::test] + async fn path_using_slashes_under_a_backslash_restriction() { + assert!(is_inside(r"C:\a\b\c", "C:/a/b/c/index.js")); + } + + #[tokio::test] + async fn path_mixing_separators_under_a_slash_restriction() { + assert!(is_inside("C:/a/b/c", r"C:\a\b/c/index.js")); + } + + #[tokio::test] + async fn path_under_a_restriction_ending_with_a_slash() { + assert!(is_inside(r"C:\a\b\c/", r"C:\a\b\c\index.js")); + } + + #[tokio::test] + async fn the_restricted_directory_itself() { + assert!(is_inside(r"C:\a\b\c\", r"C:\a\b\c")); + } + + #[tokio::test] + async fn path_whose_drive_letter_differs_in_case() { + assert!(is_inside(r"c:\a\b\c", r"C:\a\b\c\index.js")); + } + + #[tokio::test] + async fn path_inside_a_unc_restriction() { + assert!(is_inside(r"\\server\share\a", r"\\server\share\a\index.js")); + } + + #[tokio::test] + async fn path_inside_a_dos_device_restriction() { + assert!(is_inside(r"\\?\C:\a", r"\\?\C:\a\index.js")); + } + + #[tokio::test] + async fn path_on_another_share_than_the_unc_restriction() { + assert!(!is_inside( + r"\\server\share\a", + r"\\server\other\a\index.js" + )); + } + + #[tokio::test] + async fn sibling_of_a_restriction_written_with_slashes() { + assert!(!is_inside(r"C:\a\b\c", "C:/a/b/c-other.js")); + } +} + +/// Ported from enhanced-resolve `restrictions > with symlinks` +/// (GHSA-fvr2-82rg-p3pp) +/// +/// A restriction has to hold for the path the resolver returns, not for the +/// spelling a candidate happened to have while it was being selected. +mod escaping_the_restriction { + use std::{ + fs, io, + path::{Path, PathBuf}, + }; + + use crate::{ResolveError, ResolveOptions, Resolver, Restriction}; + + fn symlink, Q: AsRef>(original: P, link: Q) -> io::Result<()> { + #[cfg(target_family = "unix")] + return std::os::unix::fs::symlink(original, link); + #[cfg(target_family = "windows")] + return std::os::windows::fs::symlink_file(original, link); + } + + /// `allowed/{link,rel-link}.js` point at `outside/secret.js`. + /// `None` when the platform refuses to create symlinks. + fn fixture(name: &str) -> Option { + let root = std::env::temp_dir().join(name); + _ = fs::remove_dir_all(&root); + fs::create_dir_all(root.join("allowed")).ok()?; + fs::create_dir_all(root.join("outside")).ok()?; + // `/var` is a symlink on macOS, so the restriction has to name the real + // directory. Windows answers with a `\\?\` path, where `join` collapses the + // `..` these tests are about and the resolver cannot take it as a specifier. + let canonical = root.canonicalize().ok()?; + let canonical = canonical.to_str()?; + let root = PathBuf::from(canonical.strip_prefix(r"\\?\").unwrap_or(canonical)); + fs::write(root.join("outside/secret.js"), "").ok()?; + fs::write(root.join("allowed/real.js"), "").ok()?; + symlink(root.join("outside/secret.js"), root.join("allowed/link.js")).ok()?; + symlink( + Path::new("../outside/secret.js"), + root.join("allowed/rel-link.js"), + ) + .ok()?; + Some(root) + } + + async fn resolve(root: &Path, specifier: &str) -> Result { + resolve_with(root, specifier, true).await + } + + async fn resolve_with( + root: &Path, + specifier: &str, + symlinks: bool, + ) -> Result { + let allowed = root.join("allowed"); + Resolver::new(ResolveOptions { + extensions: vec![".js".into()], + restrictions: vec![Restriction::Path(allowed.clone())], + symlinks, + ..ResolveOptions::default() + }) + .resolve(&allowed, specifier) + .await + .map(|r| r.full_path()) + } + + #[tokio::test] + async fn in_root_symlink_to_an_outside_target() { + let Some(root) = fixture("rspack_resolver_restriction_symlink") else { + return; + }; + assert_eq!( + resolve(&root, "./link.js").await, + Err(ResolveError::NotFound("./link.js".to_string())) + ); + } + + #[tokio::test] + async fn in_root_relative_symlink_to_an_outside_target() { + let Some(root) = fixture("rspack_resolver_restriction_rel_symlink") else { + return; + }; + assert_eq!( + resolve(&root, "./rel-link.js").await, + Err(ResolveError::NotFound("./rel-link.js".to_string())) + ); + } + + #[tokio::test] + async fn parent_dir_traversal_in_an_absolute_specifier() { + let Some(root) = fixture("rspack_resolver_restriction_traversal") else { + return; + }; + let escaping = root.join("allowed/../outside/secret.js"); + let escaping = escaping.to_str().unwrap(); + assert_eq!( + resolve(&root, escaping).await, + Err(ResolveError::NotFound(escaping.to_string())) + ); + } + + #[tokio::test] + async fn parent_dir_traversal_without_symlink_resolution() { + let Some(root) = fixture("rspack_resolver_restriction_traversal_nosym") else { + return; + }; + let escaping = root.join("allowed/../outside/secret.js"); + let escaping = escaping.to_str().unwrap(); + assert_eq!( + resolve_with(&root, escaping, false).await, + Err(ResolveError::NotFound(escaping.to_string())) + ); + } + + #[tokio::test] + async fn real_in_root_file_still_resolves() { + let Some(root) = fixture("rspack_resolver_restriction_real") else { + return; + }; + assert_eq!( + resolve(&root, "./real.js").await, + Ok(root.join("allowed/real.js")) + ); + } +}