impl Php { pub fn validate(&mut self) -> Result<()> { ensure!( self.extensions.len() <= MAX_PHP_EXTENSIONS, "Web Service PHP allows at most 16 extensions" ); self.extensions = normalize_extensions( std::mem::take(&mut self.extensions), "Web Service PHP extension", )?; self.endpoint = self.endpoint.trim().to_owned(); ensure!( self.connect_timeout_ms >= 50 && self.connect_timeout_ms <= 60_000, "Web Service PHP connect timeout must be 50..60000 ms" ); ensure!( self.request_timeout_ms >= 100 && self.request_timeout_ms <= 3_600_000, "Web Service PHP request timeout must be 100..3600000 ms" ); ensure!( (1..=4096).contains(&self.max_concurrent_requests), "Web Service PHP max concurrent requests must be 1..4096" ); ensure!( self.max_request_body_bytes <= 8_u64 * 1024 * 1024 * 1024, "Web Service PHP request body limit must be 0..8589934592 bytes" ); ensure!( self.max_response_header_bytes >= 4096 && self.max_response_header_bytes <= 1024 * 1024, "Web Service PHP response header limit must be 4096..1048576 bytes" ); ensure!( self.stderr_log_bytes <= 64 * 1024, "Web Service PHP stderr log limit must be 0..65536 bytes" ); self.custom_params = normalize_fastcgi_params(std::mem::take(&mut self.custom_params))?; self.php_values = normalize_php_values(std::mem::take(&mut self.php_values))?; self.php_admin_values = normalize_php_values(std::mem::take(&mut self.php_admin_values))?; ensure!( !self .php_values .keys() .any(|key| self.php_admin_values.contains_key(key)), "PHP_VALUE and PHP_ADMIN_VALUE must not define the same directive" ); let php_custom_bytes = self .custom_params .iter() .chain(self.php_values.iter()) .chain(self.php_admin_values.iter()) .map(|(name, value)| name.len() + value.len() + 2) .sum::(); ensure!( php_custom_bytes <= 128 * 1024, "Web Service PHP custom FastCGI/PHP_VALUE data must be at most 128 KiB" ); if self.enabled { ensure!( !self.extensions.is_empty(), "PHP-FPM needs executable extensions" ); FastCgiEndpoint::parse(&self.endpoint)?; } Ok(()) } } impl PhpFpmProfile { pub fn validate(&mut self) -> Result<()> { ensure!( crate::config::valid_id(&self.id), "invalid PHP-FPM profile id" ); self.name = self.name.trim().to_owned(); ensure!( !self.name.is_empty() && self.name.len() <= MAX_SERVICE_NAME_BYTES && !self.name.chars().any(char::is_control), "PHP-FPM profile name is invalid" ); self.php.validate() } } impl Service { pub fn validate(&mut self) -> Result<()> { ensure!(crate::config::valid_id(&self.id), "invalid Web Service id"); self.name = self.name.trim().to_owned(); ensure!( !self.name.is_empty() && self.name.len() <= MAX_SERVICE_NAME_BYTES && !self.name.chars().any(char::is_control), "Web Service name must be 1..96 bytes without controls" ); ensure!( self.document_root.is_absolute(), "Web Service document root must be an absolute path" ); ensure!( self.index.len() <= MAX_INDEX_FILES, "Web Service index list allows at most 16 file names" ); let mut seen = std::collections::HashSet::new(); for item in &mut self.index { *item = item.trim().to_owned(); ensure!( !item.is_empty() && item.len() <= MAX_INDEX_NAME_BYTES && !item.chars().any(char::is_control) && !item.contains('/') && !item.contains('\\') && item != "." && item != "..", "Web Service index entries must be plain file names" ); ensure!( seen.insert(item.clone()), "duplicate Web Service index file" ); } ensure!( self.mime_types.len() <= MAX_MIME_TYPES, "Web Service MIME map allows at most 64 extensions" ); let mut normalized_mime_types = BTreeMap::new(); for (extension, value) in std::mem::take(&mut self.mime_types) { let extension = extension .trim() .trim_start_matches('.') .to_ascii_lowercase(); ensure!( !extension.is_empty() && extension.len() <= MAX_MIME_EXTENSION_BYTES && extension .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')), "Web Service MIME extensions must be 1..32 letters, numbers, '-' or '_'" ); let value = value.trim().to_owned(); ensure!( !value.is_empty() && value.len() <= MAX_MIME_VALUE_BYTES, "Web Service MIME value must be 1..256 bytes" ); HeaderValue::from_str(&value).context("invalid Web Service MIME header value")?; ensure!( normalized_mime_types.insert(extension, value).is_none(), "duplicate Web Service MIME extension" ); } self.mime_types = normalized_mime_types; self.default_mime_type = self.default_mime_type.trim().to_owned(); ensure!( !self.default_mime_type.is_empty() && self.default_mime_type.len() <= MAX_MIME_VALUE_BYTES, "Web Service default MIME type must be 1..256 bytes" ); HeaderValue::from_str(&self.default_mime_type) .context("invalid Web Service default MIME header value")?; self.cache_control = self .cache_control .take() .map(|value| value.trim().to_owned()) .filter(|value| !value.is_empty()); if let Some(cache_control) = &self.cache_control { ensure!( cache_control.len() <= MAX_CACHE_CONTROL_BYTES, "Web Service Cache-Control must be at most 1024 bytes" ); HeaderValue::from_str(cache_control) .context("invalid Web Service Cache-Control header value")?; } ensure!( self.cache_rules.len() <= MAX_CACHE_RULES, "Web Service cache policy allows at most 64 rules" ); for rule in &mut self.cache_rules { rule.matcher = rule.matcher.trim().to_ascii_lowercase(); rule.cache_control = rule.cache_control.trim().to_owned(); let valid_matcher = rule .matcher .strip_prefix("ext:") .is_some_and(valid_extension) || rule.matcher.strip_prefix("mime:").is_some_and(|value| { !value.is_empty() && value.len() <= MAX_MIME_VALUE_BYTES && !value.chars().any(char::is_control) }); ensure!( valid_matcher, "Web Service cache rule matcher must be ext: or mime:" ); ensure!( !rule.cache_control.is_empty() && rule.cache_control.len() <= MAX_CACHE_CONTROL_BYTES, "Web Service cache rule value must be 1..1024 bytes" ); HeaderValue::from_str(&rule.cache_control) .context("invalid Web Service cache rule header value")?; } ensure!( self.response_headers.len() <= MAX_RESPONSE_HEADERS, "Web Service allows at most 32 response headers" ); let mut normalized_headers = BTreeMap::new(); for (name, value) in std::mem::take(&mut self.response_headers) { let name = name.trim().to_ascii_lowercase(); ensure!( !name.is_empty() && name.len() <= MAX_HEADER_NAME_BYTES, "Web Service response header name must be 1..128 bytes" ); let header_name = HeaderName::from_str(&name).context("invalid Web Service response header name")?; ensure!( !protected_service_header(&header_name), "Web Service response header cannot override framing/transport headers" ); let value = value.trim().to_owned(); ensure!( value.len() <= MAX_HEADER_VALUE_BYTES, "Web Service response header value must be at most 8192 bytes" ); HeaderValue::from_str(&value).context("invalid Web Service response header value")?; ensure!( normalized_headers.insert(name, value).is_none(), "duplicate Web Service response header" ); } self.response_headers = normalized_headers; ensure!( self.try_files.len() <= MAX_TRY_FILES, "Web Service try_files allows at most 16 candidates" ); for candidate in &mut self.try_files { *candidate = candidate.trim().to_owned(); ensure!( !candidate.is_empty() && candidate.len() <= MAX_TRY_FILE_BYTES && !candidate.chars().any(char::is_control), "Web Service try_files entries must be 1..512 bytes without controls" ); if let Some(status) = candidate.strip_prefix('=') { let status: u16 = status.parse().context("invalid try_files status")?; ensure!( (400..=599).contains(&status) && StatusCode::from_u16(status).is_ok(), "try_files status must be an HTTP 4xx/5xx code" ); } else { ensure!( candidate.starts_with('/') || candidate.starts_with("$uri"), "Web Service try_files entries must start with / or $uri" ); } } ensure!( self.rewrites.len() <= MAX_REWRITE_RULES, "Web Service allows at most 32 rewrite rules" ); for rule in &mut self.rewrites { rule.pattern = rule.pattern.trim().to_owned(); rule.replacement = rule.replacement.trim().to_owned(); ensure!( !rule.pattern.is_empty() && rule.pattern.len() <= MAX_REWRITE_PATTERN_BYTES && rule.replacement.len() <= MAX_REWRITE_REPLACEMENT_BYTES && !rule.pattern.chars().any(char::is_control) && !rule.replacement.chars().any(char::is_control), "Web Service rewrite pattern/replacement is invalid" ); Regex::new(&rule.pattern).context("invalid Web Service rewrite regex")?; ensure!( rule.replacement.starts_with('/'), "Web Service rewrite replacement must start with /" ); if let Some(status) = rule.status { ensure!( matches!(status, 301 | 302 | 307 | 308), "Web Service rewrite redirect status must be 301, 302, 307 or 308" ); } } ensure!( self.download_extensions.len() <= MAX_DOWNLOAD_EXTENSIONS, "Web Service allows at most 64 download extensions" ); self.download_extensions = normalize_extensions( std::mem::take(&mut self.download_extensions), "Web Service download extension", )?; self.php.validate()?; if let Some(id) = &self.php_profile_id { ensure!( crate::config::valid_id(id), "invalid PHP-FPM profile reference" ); } htaccess::validate(&mut self.htaccess)?; if self.php.enabled { ensure!( !self.php.extensions.is_empty(), "Web Service PHP needs at least one executable extension" ); FastCgiEndpoint::parse(&self.php.endpoint)?; ensure!( self.symlink_policy == SymlinkPolicy::Deny, "PHP-FPM currently requires symlink_policy=deny to avoid script-path races" ); } #[cfg(not(target_os = "linux"))] ensure!( self.symlink_policy == SymlinkPolicy::Deny, "Web Service symlink_policy=inside_root currently requires Linux" ); Ok(()) } pub fn compile(&self) -> Result { let root = std::fs::canonicalize(&self.document_root).with_context(|| { format!( "Web Service {} document root {} is not accessible", self.id, self.document_root.display() ) })?; ensure!( std::fs::metadata(&root)?.is_dir(), "Web Service {} document root is not a directory", self.id ); #[cfg(not(unix))] anyhow::bail!( "Web Service secure filesystem confinement currently requires a Unix platform" ); #[cfg(unix)] let root_handle = Arc::new(open_root_directory(&root).with_context(|| { format!( "Web Service {} document root could not be opened safely", self.id ) })?); let htaccess = htaccess::compile(&root, &self.htaccess)?; if let Some(compiled) = &htaccess { ensure!( self.php.enabled || !htaccess::adds_php_extensions(Some(compiled)), ".htaccess AddHandler for PHP requires PHP-FPM to be enabled on the Web Service" ); tracing::debug!(service = %self.id, files = compiled.files_loaded, ".htaccess compatibility compiled"); for warning in &compiled.warnings { tracing::warn!(service = %self.id, warning = %warning, ".htaccess compatibility warning"); } } let response_headers = self .response_headers .iter() .map(|(name, value)| { Ok::<_, anyhow::Error>((HeaderName::from_str(name)?, HeaderValue::from_str(value)?)) }) .collect::, anyhow::Error>>()?; let cache_rules = self .cache_rules .iter() .map(|rule| { let matcher = if let Some(extension) = rule.matcher.strip_prefix("ext:") { CompiledCacheMatcher::Extension(extension.to_owned()) } else if let Some(prefix) = rule.matcher.strip_prefix("mime:") { CompiledCacheMatcher::MimePrefix(prefix.to_owned()) } else { anyhow::bail!("invalid compiled Web Service cache matcher") }; Ok(CompiledCacheRule { matcher, cache_control: HeaderValue::from_str(&rule.cache_control)?, }) }) .collect::>>()?; let rewrites = self .rewrites .iter() .map(|rule| { Ok(CompiledRewriteRule { pattern: Regex::new(&rule.pattern)?, replacement: rule.replacement.clone(), status: rule .status .and_then(|value| StatusCode::from_u16(value).ok()), }) }) .collect::>>()?; let mut script_extensions: HashSet = self.php.extensions.iter().cloned().collect(); script_extensions.extend( [ "php", "phtml", "phar", "php3", "php4", "php5", "php7", "php8", ] .into_iter() .map(str::to_owned), ); let php = if self.php.enabled { Some(CompiledPhp { extensions: self.php.extensions.iter().cloned().collect(), path_info: self.php.path_info, endpoint: FastCgiEndpoint::parse(&self.php.endpoint)?, connect_timeout: Duration::from_millis(self.php.connect_timeout_ms), request_timeout: Duration::from_millis(self.php.request_timeout_ms), permits: Arc::new(Semaphore::new(self.php.max_concurrent_requests)), max_concurrent: self.php.max_concurrent_requests, pass_authorization: self.php.pass_authorization, max_request_body_bytes: self.php.max_request_body_bytes, max_response_header_bytes: self.php.max_response_header_bytes, stderr_log_bytes: self.php.stderr_log_bytes, custom_params: self.php.custom_params.clone(), php_values: self.php.php_values.clone(), php_admin_values: self.php.php_admin_values.clone(), metrics: Arc::new(crate::web_server::metrics::PhpMetrics::default()), }) } else { None }; Ok(CompiledService { web_metrics: Arc::new(crate::web_server::metrics::PhpMetrics::default()), id: self.id.clone(), name: self.name.clone(), sendfile: self.sendfile, root, index: self.index.clone(), directory_listing: self.directory_listing, mime_types: self .mime_types .iter() .map(|(extension, value)| Ok((extension.clone(), HeaderValue::from_str(value)?))) .collect::, http::header::InvalidHeaderValue>>( )?, default_mime_type: HeaderValue::from_str(&self.default_mime_type)?, cache_control: self .cache_control .as_deref() .map(HeaderValue::from_str) .transpose()?, cache_rules, response_headers, try_files: self.try_files.clone(), rewrites, precompressed: self.precompressed.clone(), compression: self.compression.clone(), download_extensions: self.download_extensions.iter().cloned().collect(), symlink_policy: self.symlink_policy, script_extensions, php, htaccess, hardening: self.hardening.clone(), #[cfg(unix)] root_handle, }) } } #[derive(Debug, Clone)] pub(super) enum CompiledCacheMatcher { Extension(String), MimePrefix(String), } #[derive(Debug, Clone)] pub(super) struct CompiledCacheRule { pub(super) matcher: CompiledCacheMatcher, pub(super) cache_control: HeaderValue, } #[derive(Debug, Clone)] pub(super) struct CompiledRewriteRule { pub(super) pattern: Regex, pub(super) replacement: String, pub(super) status: Option, } #[derive(Debug, Clone)] pub(super) enum FastCgiEndpoint { #[cfg(unix)] Unix(PathBuf), Tcp(std::net::SocketAddr), }