diff --git a/crates/turborepo-lib/src/task_hash.rs b/crates/turborepo-lib/src/task_hash.rs index 8405b7554f896..a8c1a0f16b8b2 100644 --- a/crates/turborepo-lib/src/task_hash.rs +++ b/crates/turborepo-lib/src/task_hash.rs @@ -478,6 +478,7 @@ impl<'a> TaskHasher<'a> { "NEXT_*", "USE_OUTPUT_FOR_EDGE_FUNCTIONS", "NOW_BUILDER", + "VC_MICROFRONTENDS_CONFIG_FILE_NAME", // Command Prompt casing of env variables "APPDATA", "PATH", diff --git a/crates/turborepo-microfrontends/src/error.rs b/crates/turborepo-microfrontends/src/error.rs index 39e5990e0ee99..b34aca11b4b74 100644 --- a/crates/turborepo-microfrontends/src/error.rs +++ b/crates/turborepo-microfrontends/src/error.rs @@ -34,6 +34,11 @@ pub enum Error { }, #[error("Invalid package path: {0}. Path traversal outside repository root is not allowed.")] PathTraversal(String), + #[error( + "Invalid custom config file name: {0}. Must be a .json or .jsonc file directly in the \ + package root (no subdirectories or path traversal)." + )] + InvalidCustomConfigPath(String), } impl Error { diff --git a/crates/turborepo-microfrontends/src/lib.rs b/crates/turborepo-microfrontends/src/lib.rs index ba2536395f75b..f1404fdba68cb 100644 --- a/crates/turborepo-microfrontends/src/lib.rs +++ b/crates/turborepo-microfrontends/src/lib.rs @@ -26,8 +26,6 @@ mod configv1; mod error; mod schema; -use std::io; - use configv1::ConfigV1; pub use configv1::PathGroup; pub use error::Error; @@ -43,6 +41,7 @@ pub const DEFAULT_MICROFRONTENDS_CONFIG_V1: &str = "microfrontends.json"; pub const DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT: &str = "microfrontends.jsonc"; pub const MICROFRONTENDS_PACKAGE: &str = "@vercel/microfrontends"; pub const SUPPORTED_VERSIONS: &[&str] = ["1"].as_slice(); +pub const CUSTOM_CONFIG_ENV_VAR: &str = "VC_MICROFRONTENDS_CONFIG_FILE_NAME"; /// Strict Turborepo-only configuration for the microfrontends proxy. /// This configuration parser only accepts fields that Turborepo's native proxy @@ -57,6 +56,60 @@ pub struct TurborepoMfeConfig { } impl TurborepoMfeConfig { + /// Validates a custom config filename from environment variable. + /// Returns error if: + /// - Path contains ".." (attempting to traverse up) + /// - Path does not end with ".json" or ".jsonc" + /// - Path starts with "/" (must be relative) + /// - Path contains "/" or "\" (no subdirectories allowed) + fn validate_custom_config_name(filename: &str) -> Result<(), Error> { + // Must end with .json or .jsonc + if !filename.ends_with(".json") && !filename.ends_with(".jsonc") { + return Err(Error::InvalidCustomConfigPath(format!( + "{filename}: must be a JSON file ending with .json or .jsonc" + ))); + } + + // Must not contain directory separators (no subdirectories) + if filename.contains('/') || filename.contains('\\') { + return Err(Error::InvalidCustomConfigPath(format!( + "{filename}: subdirectories not allowed, file must be in package root" + ))); + } + + // Must not contain path traversal + if filename.contains("..") { + return Err(Error::InvalidCustomConfigPath(format!( + "{filename}: path traversal not allowed" + ))); + } + + // Must be relative (not start with /) + if filename.starts_with('/') { + return Err(Error::InvalidCustomConfigPath(format!( + "{filename}: must be relative to package root" + ))); + } + + Ok(()) + } + + /// Gets the custom config filename from environment variable if set. + /// Returns None if not set, or Error if invalid. + fn get_custom_config_name() -> Result, Error> { + match std::env::var(CUSTOM_CONFIG_ENV_VAR) { + Ok(filename) if !filename.is_empty() => { + Self::validate_custom_config_name(&filename)?; + Ok(Some(filename)) + } + Ok(_) => Ok(None), // Empty string means not set + Err(std::env::VarError::NotPresent) => Ok(None), + Err(std::env::VarError::NotUnicode(_)) => Err(Error::InvalidCustomConfigPath( + "environment variable contains invalid UTF-8".to_string(), + )), + } + } + /// Reads config from given path using strict Turborepo schema. /// Returns `Ok(None)` if the file does not exist pub fn load(config_path: &AbsoluteSystemPath) -> Result, Error> { @@ -89,10 +142,9 @@ impl TurborepoMfeConfig { Config::validate_package_path(repo_root, &absolute_dir)?; - let Some((contents, path)) = Self::load_v1_dir(&absolute_dir) else { + let Some((contents, path)) = Self::load_v1_dir(&absolute_dir)? else { return Ok(None); }; - let contents = contents?; let mut config = Self::from_str_with_mfe_dep(&contents, path.as_str(), has_mfe_dependency)?; config.filename = path .file_name() @@ -200,15 +252,36 @@ impl TurborepoMfeConfig { fn load_v1_dir( dir: &AbsoluteSystemPath, - ) -> Option<(Result, AbsoluteSystemPathBuf)> { + ) -> Result, Error> { let load_config = - |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { + |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { let path = dir.join_component(filename); - let contents = path.read_existing_to_string().transpose()?; + let contents = path + .read_existing_to_string() + .transpose()? + .map_err(Error::from); Some((contents, path)) }; - load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) - .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) + + // First check if custom config is specified via environment variable + match Self::get_custom_config_name()? { + Some(custom_name) => { + // If environment variable is set, only try that path + let Some((contents, path)) = load_config(&custom_name) else { + return Ok(None); + }; + Ok(Some((contents?, path))) + } + None => { + // Otherwise use default config file names + let Some((contents, path)) = load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) + .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) + else { + return Ok(None); + }; + Ok(Some((contents?, path))) + } + } } pub fn set_path(&mut self, dir: &AnchoredSystemPath) { @@ -300,10 +373,9 @@ impl Config { Self::validate_package_path(repo_root, &absolute_dir)?; // we want to try different paths and then do `from_str` - let Some((contents, path)) = Self::load_v1_dir(&absolute_dir) else { + let Some((contents, path)) = Self::load_v1_dir(&absolute_dir)? else { return Ok(None); }; - let contents = contents?; let mut config = Config::from_str(&contents, path.as_str())?; config.filename = path .file_name() @@ -390,15 +462,36 @@ impl Config { fn load_v1_dir( dir: &AbsoluteSystemPath, - ) -> Option<(Result, AbsoluteSystemPathBuf)> { + ) -> Result, Error> { let load_config = - |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { + |filename: &str| -> Option<(Result, AbsoluteSystemPathBuf)> { let path = dir.join_component(filename); - let contents = path.read_existing_to_string().transpose()?; + let contents = path + .read_existing_to_string() + .transpose()? + .map_err(Error::from); Some((contents, path)) }; - load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) - .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) + + // First check if custom config is specified via environment variable + match TurborepoMfeConfig::get_custom_config_name()? { + Some(custom_name) => { + // If environment variable is set, only try that path + let Some((contents, path)) = load_config(&custom_name) else { + return Ok(None); + }; + Ok(Some((contents?, path))) + } + None => { + // Otherwise use default config file names + let Some((contents, path)) = load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1) + .or_else(|| load_config(DEFAULT_MICROFRONTENDS_CONFIG_V1_ALT)) + else { + return Ok(None); + }; + Ok(Some((contents?, path))) + } + } } /// Sets the path the configuration was loaded from