Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Prevent fork bomb on Windows #1761

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/volta-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,5 @@ fs2 = "0.4.3"

[target.'cfg(windows)'.dependencies]
winreg = "0.52.0"
junction = "1.1.0"
which = "6.0.1"
junction = "1.1.0"
52 changes: 52 additions & 0 deletions crates/volta-core/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ use std::ffi::OsStr;
use std::process::Command;

use cfg_if::cfg_if;
use log::debug;

use crate::error::Fallible;

cfg_if! {
if #[cfg(windows)] {
Expand All @@ -28,3 +31,52 @@ cfg_if! {
}
}
}

/// Rebuild command against given PATH
///
/// On Windows, we need to explicitly use an absolute path to the executable,
/// otherwise the executable will not be located properly, even if we've set the PATH.
/// see: https://github.com/rust-lang/rust/issues/37519
///
/// This function will try to find the executable in the given path and rebuild
/// the command with the absolute path to the executable.
pub fn rebuild_command<S: AsRef<OsStr>>(command: Command, path: S) -> Fallible<Command> {
chawyehsu marked this conversation as resolved.
Show resolved Hide resolved
debug!("PATH: {}", path.as_ref().to_string_lossy());

#[cfg(not(windows))]
{
let mut command = command;
command.env("PATH", path.as_ref());
Ok(command)
}

#[cfg(windows)]
{
let args = command.get_args().collect::<Vec<_>>();
// cmd /c <name> [...other]
// args_idx 0 1 2..
let name = args.get(1).expect("should have the name");
chawyehsu marked this conversation as resolved.
Show resolved Hide resolved

if let Ok(mut paths) = which::which_in_global(name, Some(&path)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that the trigger for this (as I understand it) is the cmd /c implementation searching the CWD for the binary, even when it's not explicitly on the Path, would it be unreasonable to gate this reformulation of the Command on that, rather than doing it every time on Windows?

It's relatively minor, but the which_in_global is going to necessarily do a decent chunk of File I/O for every command run, which seems excessive for something on the hot path.

if let Some(exe) = paths.next() {
chawyehsu marked this conversation as resolved.
Show resolved Hide resolved
let mut new_command = create_command(exe);
let envs = command
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Why does this need to filter the environment variables like this, rather than maintaining the same environment variables as the previous version of the command?
  2. Can you add a comment explaining that here? That will help us remember (and other contributors understand)!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on adding a comment!

I believe this needs to filter because the output of Command::get_envs includes None values for variables that were explicitly removed. I don't think we do any explicit removing of environment variables currently, but possibly what we want to do is mirror the same behavior:

for (key, maybe_value) in command.get_envs() {
    match maybe_value {
        Some(value) => command.env(key, value),
        None => command.env_remove(key),
    }
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because the output of Command::get_envs includes None values for variables that were explicitly removed.

This was the reason to filter out envs, envs with None values should not be added to the new_command that's built.

Do you think it's better to use

for (key, maybe_value) in command.get_envs() {
    match maybe_value {
        Some(value) => command.env(key, value),
        None => command.env_remove(key),
    }
}

to apply envs to the new_command or just leave the current implementation?

let envs = command
    .get_envs()
    .filter_map(|(k, maybe_v)| Some(k).zip(maybe_v))
    .collect::<Vec<_>>();
new_command.envs(envs);

.get_envs()
.filter(|(_, v)| v.is_some())
.map(|(k, v)| (k, v.unwrap()))
chawyehsu marked this conversation as resolved.
Show resolved Hide resolved
.collect::<Vec<_>>();

new_command.args(&args[2..]);
chawyehsu marked this conversation as resolved.
Show resolved Hide resolved
new_command.envs(envs);
new_command.env("PATH", path.as_ref());

return Ok(new_command);
}
}

Err(crate::error::ErrorKind::BinaryNotFound {
name: name.to_string_lossy().to_string(),
}
.into())
}
}
8 changes: 8 additions & 0 deletions crates/volta-core/src/error/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,9 @@ pub enum ErrorKind {
file: PathBuf,
},

/// Throw when recursion limit is reached
chawyehsu marked this conversation as resolved.
Show resolved Hide resolved
RecursionLimit,

/// Thrown when unable to read the user Path environment variable from the registry
#[cfg(windows)]
ReadUserPathError,
Expand Down Expand Up @@ -1246,6 +1249,10 @@ from {}
file.display(),
PERMISSIONS_CTA
),
ErrorKind::RecursionLimit => write!(
f,
"Recursive call limit reached."
),
Comment on lines +1259 to +1262
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a great suggestion on the top of my head, but is it impossible to create a call-to-action for this error? We have generally tried to always have a CTA included with any error message, so that we guide users to how to fix the problems they run into.

#[cfg(windows)]
ErrorKind::ReadUserPathError => write!(
f,
Expand Down Expand Up @@ -1549,6 +1556,7 @@ impl ErrorKind {
ErrorKind::ReadNpmManifestError => ExitCode::UnknownError,
ErrorKind::ReadPackageConfigError { .. } => ExitCode::FileSystemError,
ErrorKind::ReadPlatformError { .. } => ExitCode::FileSystemError,
ErrorKind::RecursionLimit => ExitCode::ExecutionFailure,
#[cfg(windows)]
ErrorKind::ReadUserPathError => ExitCode::EnvironmentError,
ErrorKind::RegistryFetchError { .. } => ExitCode::NetworkError,
Expand Down
62 changes: 36 additions & 26 deletions crates/volta-core/src/run/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ use std::os::unix::process::ExitStatusExt;
use std::os::windows::process::ExitStatusExt;
use std::process::{Command, ExitStatus};

use super::RECURSION_ENV_VAR;
use crate::command::create_command;
use super::{RECURSION_ENV_VAR, RECURSION_LIMIT};
use crate::command::{create_command, rebuild_command};
use crate::error::{Context, ErrorKind, Fallible};
use crate::layout::volta_home;
use crate::platform::{CliPlatform, Platform, System};
Expand Down Expand Up @@ -175,7 +175,7 @@ impl ToolCommand {
}

/// Runs the command, returning the `ExitStatus` if it successfully launches
pub fn execute(mut self, session: &mut Session) -> Fallible<ExitStatus> {
pub fn execute(self, session: &mut Session) -> Fallible<ExitStatus> {
let (path, on_failure) = match self.kind {
ToolKind::Node => super::node::execution_context(self.platform, session)?,
ToolKind::Npm => super::npm::execution_context(self.platform, session)?,
Expand All @@ -191,11 +191,22 @@ impl ToolCommand {
ToolKind::Bypass(command) => (System::path()?, ErrorKind::BypassError { command }),
};

self.command.env(RECURSION_ENV_VAR, "1");
self.command.env("PATH", path);
// Recursive call limit
let mut recursion = std::env::var(RECURSION_ENV_VAR)
.map(|var| var.parse::<u8>().unwrap_or(1u8))
.unwrap_or(1u8);
chawyehsu marked this conversation as resolved.
Show resolved Hide resolved
if recursion > RECURSION_LIMIT {
return Err(ErrorKind::RecursionLimit.into());
}
recursion += 1;
chawyehsu marked this conversation as resolved.
Show resolved Hide resolved

pass_control_to_shim();
self.command.status().with_context(|| on_failure)
rebuild_command(self.command, path)
.and_then(|mut command| {
command.env(RECURSION_ENV_VAR, recursion.to_string());
chawyehsu marked this conversation as resolved.
Show resolved Hide resolved
pass_control_to_shim();
command.status().with_context(|| ErrorKind::BinaryExecError)
})
.with_context(|| on_failure)
}
}

Expand Down Expand Up @@ -274,17 +285,17 @@ impl PackageInstallCommand {

/// Runs the install command, applying the necessary modifications to install into the Volta
/// data directory
pub fn execute(mut self, session: &mut Session) -> Fallible<ExitStatus> {
pub fn execute(self, session: &mut Session) -> Fallible<ExitStatus> {
let _lock = VoltaLock::acquire();
let image = self.platform.checkout(session)?;
let path = image.path()?;

self.command.env(RECURSION_ENV_VAR, "1");
self.command.env("PATH", path);
self.installer.setup_command(&mut self.command);
let mut command = rebuild_command(self.command, path)?;

let status = self
.command
command.env(RECURSION_ENV_VAR, "1");
self.installer.setup_command(&mut command);

let status = command
.status()
.with_context(|| ErrorKind::BinaryExecError)?;

Expand Down Expand Up @@ -350,20 +361,19 @@ impl PackageLinkCommand {
/// directory.
///
/// This will also check for some common failure cases and alert the user
pub fn execute(mut self, session: &mut Session) -> Fallible<ExitStatus> {
pub fn execute(self, session: &mut Session) -> Fallible<ExitStatus> {
self.check_linked_package(session)?;

let image = self.platform.checkout(session)?;
let path = image.path()?;

self.command.env(RECURSION_ENV_VAR, "1");
self.command.env("PATH", path);
let mut command = rebuild_command(self.command, path)?;

command.env(RECURSION_ENV_VAR, "1");
let package_root = volta_home()?.package_image_dir(&self.tool);
PackageManager::Npm.setup_global_command(&mut self.command, package_root);
PackageManager::Npm.setup_global_command(&mut command, package_root);

self.command
.status()
.with_context(|| ErrorKind::BinaryExecError)
command.status().with_context(|| ErrorKind::BinaryExecError)
}

/// Check for possible failure cases with the linked package:
Expand Down Expand Up @@ -465,19 +475,19 @@ impl PackageUpgradeCommand {
///
/// Will also check for common failure cases, such as non-existant package or wrong package
/// manager
pub fn execute(mut self, session: &mut Session) -> Fallible<ExitStatus> {
pub fn execute(self, session: &mut Session) -> Fallible<ExitStatus> {
self.upgrader.check_upgraded_package()?;

let _lock = VoltaLock::acquire();
let image = self.platform.checkout(session)?;
let path = image.path()?;

self.command.env(RECURSION_ENV_VAR, "1");
self.command.env("PATH", path);
self.upgrader.setup_command(&mut self.command);
let mut command = rebuild_command(self.command, path)?;

command.env(RECURSION_ENV_VAR, "1");
self.upgrader.setup_command(&mut command);

let status = self
.command
let status = command
.status()
.with_context(|| ErrorKind::BinaryExecError)?;

Expand Down
1 change: 1 addition & 0 deletions crates/volta-core/src/run/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ mod yarn;
/// Note: This is explicitly _removed_ when calling a command through `volta run`, as that will
/// never happen due to the Volta environment.
const RECURSION_ENV_VAR: &str = "_VOLTA_TOOL_RECURSION";
const RECURSION_LIMIT: u8 = 10;
const VOLTA_BYPASS: &str = "VOLTA_BYPASS";

/// Execute a shim command, based on the command-line arguments to the current process
Expand Down
7 changes: 5 additions & 2 deletions crates/volta-core/src/tool/package/install.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::path::PathBuf;

use super::manager::PackageManager;
use crate::command::create_command;
use crate::command::{create_command, rebuild_command};
use crate::error::{Context, ErrorKind, Fallible};
use crate::platform::Image;
use crate::style::progress_spinner;
Expand All @@ -17,6 +17,7 @@ pub(super) fn run_global_install(
staging_dir: PathBuf,
platform_image: &Image,
) -> Fallible<()> {
let path = platform_image.path()?;
let mut command = create_command("npm");
command.args([
"install",
Expand All @@ -26,7 +27,9 @@ pub(super) fn run_global_install(
"--no-audit",
]);
command.arg(&package);
command.env("PATH", platform_image.path()?);

command = rebuild_command(command, path)?;

PackageManager::Npm.setup_global_command(&mut command, staging_dir);

debug!("Installing {} with command: {:?}", package, command);
Expand Down
9 changes: 0 additions & 9 deletions tests/acceptance/volta_bypass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,10 @@ fn shim_skips_platform_checks_on_bypass() {
)
.build();

#[cfg(unix)]
assert_that!(
s.process(shim_exe()),
execs()
.with_status(ExitCode::ExecutionFailure as i32)
.with_stderr_contains("VOLTA_BYPASS is enabled[..]")
);

#[cfg(windows)]
assert_that!(
s.process(shim_exe()),
execs()
.with_status(ExitCode::UnknownError as i32)
.with_stderr_contains("[..]is not recognized as an internal or external command[..]")
);
chriskrycho marked this conversation as resolved.
Show resolved Hide resolved
}
Loading