459 lines
19 KiB
Rust
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),
|
|
}
|