Files
RustProxy/src/web_server/config/compile.rs
T
2026-10-09 13:11:33 +02:00

459 lines
19 KiB
Rust

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::<usize>();
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:<extension> or mime:<prefix>"
);
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<CompiledService> {
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::<std::result::Result<Vec<_>, 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::<Result<Vec<_>>>()?;
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::<Result<Vec<_>>>()?;
let mut script_extensions: HashSet<String> = 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)),
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(),
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::<std::result::Result<BTreeMap<_, _>, 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<StatusCode>,
}
#[derive(Debug, Clone)]
pub(super) enum FastCgiEndpoint {
#[cfg(unix)]
Unix(PathBuf),
Tcp(std::net::SocketAddr),
}