v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -0,0 +1,3 @@
|
||||
include!("config/model.rs");
|
||||
include!("config/compile.rs");
|
||||
include!("config/validation.rs");
|
||||
@@ -0,0 +1,427 @@
|
||||
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",
|
||||
)?;
|
||||
|
||||
ensure!(
|
||||
self.php.extensions.len() <= MAX_PHP_EXTENSIONS,
|
||||
"Web Service PHP allows at most 16 extensions"
|
||||
);
|
||||
self.php.extensions = normalize_extensions(
|
||||
std::mem::take(&mut self.php.extensions),
|
||||
"Web Service PHP extension",
|
||||
)?;
|
||||
self.php.endpoint = self.php.endpoint.trim().to_owned();
|
||||
ensure!(
|
||||
self.php.connect_timeout_ms >= 50 && self.php.connect_timeout_ms <= 60_000,
|
||||
"Web Service PHP connect timeout must be 50..60000 ms"
|
||||
);
|
||||
ensure!(
|
||||
self.php.request_timeout_ms >= 100 && self.php.request_timeout_ms <= 3_600_000,
|
||||
"Web Service PHP request timeout must be 100..3600000 ms"
|
||||
);
|
||||
ensure!(
|
||||
(1..=4096).contains(&self.php.max_concurrent_requests),
|
||||
"Web Service PHP max concurrent requests must be 1..4096"
|
||||
);
|
||||
ensure!(
|
||||
self.php.max_request_body_bytes <= 8_u64 * 1024 * 1024 * 1024,
|
||||
"Web Service PHP request body limit must be 0..8589934592 bytes"
|
||||
);
|
||||
ensure!(
|
||||
self.php.max_response_header_bytes >= 4096
|
||||
&& self.php.max_response_header_bytes <= 1024 * 1024,
|
||||
"Web Service PHP response header limit must be 4096..1048576 bytes"
|
||||
);
|
||||
ensure!(
|
||||
self.php.stderr_log_bytes <= 64 * 1024,
|
||||
"Web Service PHP stderr log limit must be 0..65536 bytes"
|
||||
);
|
||||
self.php.custom_params = normalize_fastcgi_params(std::mem::take(&mut self.php.custom_params))?;
|
||||
self.php.php_values = normalize_php_values(std::mem::take(&mut self.php.php_values))?;
|
||||
let php_custom_bytes = self
|
||||
.php
|
||||
.custom_params
|
||||
.iter()
|
||||
.chain(self.php.php_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"
|
||||
);
|
||||
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(),
|
||||
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(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(CompiledService {
|
||||
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(),
|
||||
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),
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
use super::htaccess;
|
||||
use super::htaccess::Config as HtaccessConfig;
|
||||
#[cfg(unix)]
|
||||
use super::filesystem::open_root_directory;
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use http::{HeaderName, HeaderValue, StatusCode};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
fs::File,
|
||||
path::PathBuf,
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
const MAX_INDEX_FILES: usize = 16;
|
||||
const MAX_INDEX_NAME_BYTES: usize = 128;
|
||||
const MAX_SERVICE_NAME_BYTES: usize = 96;
|
||||
const MAX_MIME_TYPES: usize = 64;
|
||||
const MAX_MIME_EXTENSION_BYTES: usize = 32;
|
||||
const MAX_MIME_VALUE_BYTES: usize = 256;
|
||||
const MAX_CACHE_CONTROL_BYTES: usize = 1024;
|
||||
const MAX_TRY_FILES: usize = 16;
|
||||
const MAX_TRY_FILE_BYTES: usize = 512;
|
||||
const MAX_REWRITE_RULES: usize = 32;
|
||||
const MAX_REWRITE_PATTERN_BYTES: usize = 512;
|
||||
const MAX_REWRITE_REPLACEMENT_BYTES: usize = 1024;
|
||||
const MAX_CACHE_RULES: usize = 64;
|
||||
const MAX_RESPONSE_HEADERS: usize = 32;
|
||||
const MAX_HEADER_NAME_BYTES: usize = 128;
|
||||
pub(super) const MAX_HEADER_VALUE_BYTES: usize = 8192;
|
||||
const MAX_DOWNLOAD_EXTENSIONS: usize = 64;
|
||||
const MAX_PHP_EXTENSIONS: usize = 16;
|
||||
const MAX_PHP_CUSTOM_PARAMS: usize = 64;
|
||||
const MAX_PHP_VALUES: usize = 64;
|
||||
const MAX_FASTCGI_PARAM_NAME_BYTES: usize = 64;
|
||||
const MAX_FASTCGI_PARAM_VALUE_BYTES: usize = 8192;
|
||||
const DEFAULT_FASTCGI_HEADER_BYTES: usize = 64 * 1024;
|
||||
const DEFAULT_FASTCGI_STDERR_BYTES: usize = 4096;
|
||||
|
||||
fn default_index() -> Vec<String> {
|
||||
vec!["index.html".into(), "index.htm".into()]
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_php_extensions() -> Vec<String> {
|
||||
vec!["php".into()]
|
||||
}
|
||||
|
||||
fn default_php_connect_timeout_ms() -> u64 {
|
||||
1_000
|
||||
}
|
||||
|
||||
fn default_php_request_timeout_ms() -> u64 {
|
||||
30_000
|
||||
}
|
||||
|
||||
fn default_php_max_concurrent_requests() -> usize {
|
||||
64
|
||||
}
|
||||
|
||||
fn default_php_pass_authorization() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_php_max_response_header_bytes() -> usize {
|
||||
DEFAULT_FASTCGI_HEADER_BYTES
|
||||
}
|
||||
|
||||
fn default_php_stderr_log_bytes() -> usize {
|
||||
DEFAULT_FASTCGI_STDERR_BYTES
|
||||
}
|
||||
|
||||
fn valid_extension(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= MAX_MIME_EXTENSION_BYTES
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
}
|
||||
|
||||
fn normalize_extensions(values: Vec<String>, label: &str) -> Result<Vec<String>> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut normalized = Vec::with_capacity(values.len());
|
||||
for value in values {
|
||||
let value = value.trim().trim_start_matches('.').to_ascii_lowercase();
|
||||
ensure!(valid_extension(&value), "{label} is invalid");
|
||||
ensure!(seen.insert(value.clone()), "duplicate {label}");
|
||||
normalized.push(value);
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn reserved_fastcgi_param(name: &str) -> bool {
|
||||
name.starts_with("HTTP_")
|
||||
|| matches!(
|
||||
name,
|
||||
"REQUEST_METHOD"
|
||||
| "SCRIPT_FILENAME"
|
||||
| "SCRIPT_NAME"
|
||||
| "QUERY_STRING"
|
||||
| "REQUEST_URI"
|
||||
| "DOCUMENT_ROOT"
|
||||
| "SERVER_PROTOCOL"
|
||||
| "SERVER_NAME"
|
||||
| "SERVER_PORT"
|
||||
| "REMOTE_ADDR"
|
||||
| "REMOTE_PORT"
|
||||
| "HTTPS"
|
||||
| "GATEWAY_INTERFACE"
|
||||
| "SERVER_SOFTWARE"
|
||||
| "REDIRECT_STATUS"
|
||||
| "CONTENT_TYPE"
|
||||
| "CONTENT_LENGTH"
|
||||
| "PATH_INFO"
|
||||
| "PATH_TRANSLATED"
|
||||
| "PHP_VALUE"
|
||||
| "PHP_ADMIN_VALUE"
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_fastcgi_params(values: BTreeMap<String, String>) -> Result<BTreeMap<String, String>> {
|
||||
ensure!(
|
||||
values.len() <= MAX_PHP_CUSTOM_PARAMS,
|
||||
"Web Service PHP allows at most 64 custom FastCGI parameters"
|
||||
);
|
||||
let mut normalized = BTreeMap::new();
|
||||
for (name, value) in values {
|
||||
let name = name.trim().to_ascii_uppercase();
|
||||
let value = value.trim().to_owned();
|
||||
ensure!(
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_FASTCGI_PARAM_NAME_BYTES
|
||||
&& name.bytes().enumerate().all(|(index, byte)| {
|
||||
byte.is_ascii_uppercase()
|
||||
|| byte.is_ascii_digit() && index > 0
|
||||
|| byte == b'_' && index > 0
|
||||
}),
|
||||
"Web Service PHP custom FastCGI parameter name is invalid"
|
||||
);
|
||||
ensure!(
|
||||
!reserved_fastcgi_param(&name),
|
||||
"Web Service PHP custom FastCGI parameter uses a reserved name"
|
||||
);
|
||||
ensure!(
|
||||
!value.is_empty()
|
||||
&& value.len() <= MAX_FASTCGI_PARAM_VALUE_BYTES
|
||||
&& !value.chars().any(char::is_control),
|
||||
"Web Service PHP custom FastCGI parameter value is invalid"
|
||||
);
|
||||
ensure!(
|
||||
normalized.insert(name, value).is_none(),
|
||||
"duplicate Web Service PHP custom FastCGI parameter"
|
||||
);
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn normalize_php_values(values: BTreeMap<String, String>) -> Result<BTreeMap<String, String>> {
|
||||
ensure!(
|
||||
values.len() <= MAX_PHP_VALUES,
|
||||
"Web Service PHP allows at most 64 PHP INI overrides"
|
||||
);
|
||||
let mut normalized = BTreeMap::new();
|
||||
for (name, value) in values {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let value = value.trim().to_owned();
|
||||
ensure!(
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_FASTCGI_PARAM_NAME_BYTES
|
||||
&& name.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-')
|
||||
}),
|
||||
"Web Service PHP INI override name is invalid"
|
||||
);
|
||||
ensure!(
|
||||
!value.is_empty()
|
||||
&& value.len() <= MAX_FASTCGI_PARAM_VALUE_BYTES
|
||||
&& !value.chars().any(char::is_control),
|
||||
"Web Service PHP INI override value is invalid"
|
||||
);
|
||||
ensure!(
|
||||
normalized.insert(name, value).is_none(),
|
||||
"duplicate Web Service PHP INI override"
|
||||
);
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
fn protected_service_header(name: &HeaderName) -> bool {
|
||||
matches!(
|
||||
name.as_str(),
|
||||
"connection"
|
||||
| "content-length"
|
||||
| "content-encoding"
|
||||
| "content-range"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
| "keep-alive"
|
||||
| "proxy-connection"
|
||||
| "vary"
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SymlinkPolicy {
|
||||
#[default]
|
||||
Deny,
|
||||
InsideRoot,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct RewriteRule {
|
||||
pub pattern: String,
|
||||
pub replacement: String,
|
||||
/// `None` means an internal rewrite. Redirects are limited to 301/302/307/308.
|
||||
pub status: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct CacheRule {
|
||||
/// `ext:<extension>` or `mime:<prefix>`.
|
||||
pub matcher: String,
|
||||
pub cache_control: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Precompressed {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub brotli: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub gzip: bool,
|
||||
}
|
||||
|
||||
impl Default for Precompressed {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
brotli: true,
|
||||
gzip: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Php {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_php_extensions")]
|
||||
pub extensions: Vec<String>,
|
||||
/// `unix:/run/php/php-fpm.sock` or `tcp:127.0.0.1:9000`.
|
||||
pub endpoint: String,
|
||||
#[serde(default = "default_php_connect_timeout_ms")]
|
||||
pub connect_timeout_ms: u64,
|
||||
#[serde(default = "default_php_request_timeout_ms")]
|
||||
pub request_timeout_ms: u64,
|
||||
#[serde(default = "default_php_max_concurrent_requests")]
|
||||
pub max_concurrent_requests: usize,
|
||||
/// Forward the inbound Authorization header as HTTP_AUTHORIZATION.
|
||||
#[serde(default = "default_php_pass_authorization")]
|
||||
pub pass_authorization: bool,
|
||||
/// Optional RustProxy-side request-body limit for PHP. 0 keeps the body unlimited here.
|
||||
pub max_request_body_bytes: u64,
|
||||
/// Maximum bytes accepted before the CGI response-header terminator.
|
||||
#[serde(default = "default_php_max_response_header_bytes")]
|
||||
pub max_response_header_bytes: usize,
|
||||
/// Maximum PHP-FPM stderr bytes retained for one request diagnostic log entry.
|
||||
#[serde(default = "default_php_stderr_log_bytes")]
|
||||
pub stderr_log_bytes: usize,
|
||||
/// Trusted operator-defined static FastCGI parameters. Core CGI/HTTP/PHP_* names are reserved.
|
||||
pub custom_params: BTreeMap<String, String>,
|
||||
/// Trusted operator-defined PHP INI overrides encoded through a single PHP_VALUE parameter.
|
||||
pub php_values: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for Php {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
extensions: default_php_extensions(),
|
||||
endpoint: "unix:/run/php/php-fpm.sock".into(),
|
||||
connect_timeout_ms: default_php_connect_timeout_ms(),
|
||||
request_timeout_ms: default_php_request_timeout_ms(),
|
||||
max_concurrent_requests: default_php_max_concurrent_requests(),
|
||||
pass_authorization: default_php_pass_authorization(),
|
||||
max_request_body_bytes: 0,
|
||||
max_response_header_bytes: default_php_max_response_header_bytes(),
|
||||
stderr_log_bytes: default_php_stderr_log_bytes(),
|
||||
custom_params: BTreeMap::new(),
|
||||
php_values: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Hardening {
|
||||
/// Reject high-confidence exploit probes and sensitive control/backup paths.
|
||||
pub block_common_attacks: bool,
|
||||
/// Remove implementation/fingerprint response headers from Web Server/PHP responses.
|
||||
pub strip_unnecessary_headers: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Service {
|
||||
/// Stable configuration identifier referenced by vhosts/locations.
|
||||
pub id: String,
|
||||
/// Human-readable service name shown in the administration UI.
|
||||
pub name: String,
|
||||
pub document_root: PathBuf,
|
||||
pub index: Vec<String>,
|
||||
pub directory_listing: bool,
|
||||
/// Optional per-extension MIME overrides. Keys are extensions without a leading dot.
|
||||
pub mime_types: BTreeMap<String, String>,
|
||||
/// Fallback MIME type used when neither an override nor a built-in mapping exists.
|
||||
pub default_mime_type: String,
|
||||
/// Browser/proxy cache policy emitted for static file responses. Location/vhost
|
||||
/// response-header rules run later and can intentionally override this value.
|
||||
pub cache_control: Option<String>,
|
||||
/// More specific cache policy rules evaluated before `cache_control`.
|
||||
pub cache_rules: Vec<CacheRule>,
|
||||
/// Fixed response headers emitted by this Web Service. Vhost/location response
|
||||
/// headers still run later and remain authoritative.
|
||||
pub response_headers: BTreeMap<String, String>,
|
||||
/// Nginx-style file candidates, e.g. `$uri`, `$uri/`, `/index.html`.
|
||||
pub try_files: Vec<String>,
|
||||
/// Regex rewrite/redirect rules evaluated before `try_files`.
|
||||
pub rewrites: Vec<RewriteRule>,
|
||||
/// Serve pre-generated `.br`/`.gz` sidecars when accepted by the client.
|
||||
pub precompressed: Precompressed,
|
||||
/// Extensions that should be downloaded as attachments.
|
||||
pub download_extensions: Vec<String>,
|
||||
/// Symlink handling. The conservative default is `deny`.
|
||||
pub symlink_policy: SymlinkPolicy,
|
||||
/// Optional FastCGI/PHP-FPM execution for configured script extensions.
|
||||
pub php: Php,
|
||||
/// Optional bounded Apache .htaccess compatibility layer compiled at Web Service apply time.
|
||||
pub htaccess: HtaccessConfig,
|
||||
/// Optional Web Service hardening. This is intentionally narrower than a generic WAF.
|
||||
pub hardening: Hardening,
|
||||
}
|
||||
|
||||
impl Default for Service {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
document_root: PathBuf::new(),
|
||||
index: default_index(),
|
||||
directory_listing: false,
|
||||
mime_types: BTreeMap::new(),
|
||||
default_mime_type: "application/octet-stream".into(),
|
||||
cache_control: None,
|
||||
cache_rules: Vec::new(),
|
||||
response_headers: BTreeMap::new(),
|
||||
try_files: Vec::new(),
|
||||
rewrites: Vec::new(),
|
||||
precompressed: Precompressed::default(),
|
||||
download_extensions: Vec::new(),
|
||||
symlink_policy: SymlinkPolicy::Deny,
|
||||
php: Php::default(),
|
||||
htaccess: HtaccessConfig::default(),
|
||||
hardening: Hardening::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
impl FastCgiEndpoint {
|
||||
pub(super) fn parse(value: &str) -> Result<Self> {
|
||||
if let Some(path) = value.strip_prefix("unix:") {
|
||||
ensure!(!path.trim().is_empty(), "PHP-FPM unix socket path is empty");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
return Ok(Self::Unix(PathBuf::from(path)));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
anyhow::bail!("PHP-FPM unix sockets require a Unix platform");
|
||||
}
|
||||
if let Some(address) = value.strip_prefix("tcp:") {
|
||||
let address = address
|
||||
.parse::<std::net::SocketAddr>()
|
||||
.context("PHP-FPM TCP endpoint must be an IP socket address")?;
|
||||
ensure!(
|
||||
address.ip().is_loopback(),
|
||||
"PHP-FPM TCP endpoint must use a loopback address"
|
||||
);
|
||||
return Ok(Self::Tcp(address));
|
||||
}
|
||||
anyhow::bail!("PHP-FPM endpoint must start with unix: or tcp:")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct CompiledPhp {
|
||||
pub(super) extensions: HashSet<String>,
|
||||
pub(super) endpoint: FastCgiEndpoint,
|
||||
pub(super) connect_timeout: Duration,
|
||||
pub(super) request_timeout: Duration,
|
||||
pub(super) permits: Arc<Semaphore>,
|
||||
pub(super) pass_authorization: bool,
|
||||
pub(super) max_request_body_bytes: u64,
|
||||
pub(super) max_response_header_bytes: usize,
|
||||
pub(super) stderr_log_bytes: usize,
|
||||
pub(super) custom_params: BTreeMap<String, String>,
|
||||
pub(super) php_values: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledService {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub(super) root: PathBuf,
|
||||
pub(super) index: Vec<String>,
|
||||
pub(super) directory_listing: bool,
|
||||
pub(super) mime_types: BTreeMap<String, HeaderValue>,
|
||||
pub(super) default_mime_type: HeaderValue,
|
||||
pub(super) cache_control: Option<HeaderValue>,
|
||||
pub(super) cache_rules: Vec<CompiledCacheRule>,
|
||||
pub(super) response_headers: Vec<(HeaderName, HeaderValue)>,
|
||||
pub(super) try_files: Vec<String>,
|
||||
pub(super) rewrites: Vec<CompiledRewriteRule>,
|
||||
pub(super) precompressed: Precompressed,
|
||||
pub(super) download_extensions: HashSet<String>,
|
||||
pub(super) symlink_policy: SymlinkPolicy,
|
||||
pub(super) script_extensions: HashSet<String>,
|
||||
pub(super) php: Option<CompiledPhp>,
|
||||
pub(super) htaccess: Option<htaccess::Compiled>,
|
||||
pub(super) hardening: Hardening,
|
||||
#[cfg(unix)]
|
||||
pub(super) root_handle: Arc<File>,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
include!("fastcgi/protocol.rs");
|
||||
include!("fastcgi/response.rs");
|
||||
@@ -0,0 +1,364 @@
|
||||
use super::{
|
||||
config::{CompiledPhp, FastCgiEndpoint, MAX_HEADER_VALUE_BYTES},
|
||||
filesystem::path_components,
|
||||
htaccess,
|
||||
response::{builtin_error_response, status_response},
|
||||
request::server_protocol,
|
||||
CompiledService, RequestContext,
|
||||
};
|
||||
use anyhow::{Context, ensure, Result};
|
||||
use axum::{body::Body, response::Response};
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream;
|
||||
use http::{
|
||||
header::{ALLOW, CONTENT_LENGTH, CONTENT_TYPE},
|
||||
HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri,
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use std::{collections::BTreeMap, io, path::Path, str::FromStr};
|
||||
use tokio::{
|
||||
io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt},
|
||||
sync::{mpsc, OwnedSemaphorePermit},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
const FCGI_VERSION_1: u8 = 1;
|
||||
const FCGI_BEGIN_REQUEST: u8 = 1;
|
||||
pub(super) const FCGI_END_REQUEST: u8 = 3;
|
||||
const FCGI_PARAMS: u8 = 4;
|
||||
pub(super) const FCGI_STDIN: u8 = 5;
|
||||
pub(super) const FCGI_STDOUT: u8 = 6;
|
||||
const FCGI_STDERR: u8 = 7;
|
||||
const FCGI_RESPONDER: u16 = 1;
|
||||
const FCGI_REQUEST_ID: u16 = 1;
|
||||
|
||||
pub(super) trait FastCgiIo: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T> FastCgiIo for T where T: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
|
||||
async fn connect_fastcgi(endpoint: &FastCgiEndpoint) -> io::Result<Box<dyn FastCgiIo>> {
|
||||
match endpoint {
|
||||
#[cfg(unix)]
|
||||
FastCgiEndpoint::Unix(path) => {
|
||||
Ok(Box::new(tokio::net::UnixStream::connect(path).await?))
|
||||
}
|
||||
FastCgiEndpoint::Tcp(address) => {
|
||||
Ok(Box::new(tokio::net::TcpStream::connect(address).await?))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn write_fcgi_record(
|
||||
io: &mut dyn FastCgiIo,
|
||||
record_type: u8,
|
||||
content: &[u8],
|
||||
) -> io::Result<()> {
|
||||
if content.len() > u16::MAX as usize {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"FastCGI record is too large",
|
||||
));
|
||||
}
|
||||
let padding = (8 - (content.len() % 8)) % 8;
|
||||
let mut header = [0u8; 8];
|
||||
header[0] = FCGI_VERSION_1;
|
||||
header[1] = record_type;
|
||||
header[2..4].copy_from_slice(&FCGI_REQUEST_ID.to_be_bytes());
|
||||
header[4..6].copy_from_slice(&(content.len() as u16).to_be_bytes());
|
||||
header[6] = padding as u8;
|
||||
io.write_all(&header).await?;
|
||||
io.write_all(content).await?;
|
||||
if padding != 0 {
|
||||
io.write_all(&[0u8; 8][..padding]).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_fcgi_length(target: &mut Vec<u8>, length: usize) -> io::Result<()> {
|
||||
if length < 128 {
|
||||
target.push(length as u8);
|
||||
} else if length <= 0x7fff_ffff {
|
||||
target.extend_from_slice(&((length as u32) | 0x8000_0000).to_be_bytes());
|
||||
} else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"FastCGI parameter is too large",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_fcgi_param(target: &mut Vec<u8>, name: &str, value: &str) -> io::Result<()> {
|
||||
encode_fcgi_length(target, name.len())?;
|
||||
encode_fcgi_length(target, value.len())?;
|
||||
target.extend_from_slice(name.as_bytes());
|
||||
target.extend_from_slice(value.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn fastcgi_header_param(name: &HeaderName) -> Option<String> {
|
||||
let lower = name.as_str();
|
||||
if matches!(
|
||||
lower,
|
||||
"content-type"
|
||||
| "content-length"
|
||||
| "connection"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
| "keep-alive"
|
||||
| "proxy-connection"
|
||||
| "vary"
|
||||
| "te"
|
||||
| "trailer"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"HTTP_{}",
|
||||
lower.replace('-', "_").to_ascii_uppercase()
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn fastcgi_params(
|
||||
service: &CompiledService,
|
||||
php: &CompiledPhp,
|
||||
method: &Method,
|
||||
headers: &HeaderMap,
|
||||
original_uri: &Uri,
|
||||
script_path: &str,
|
||||
query: Option<&str>,
|
||||
context: &RequestContext,
|
||||
) -> Result<Vec<u8>> {
|
||||
let components = path_components(script_path)?;
|
||||
let mut script_filename = service.root.clone();
|
||||
for component in &components {
|
||||
script_filename.push(component);
|
||||
}
|
||||
let document_root = service.root.to_string_lossy().to_string();
|
||||
let script_filename = script_filename.to_string_lossy().to_string();
|
||||
let request_uri = original_uri
|
||||
.path_and_query()
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or(original_uri.path());
|
||||
let mut params = BTreeMap::<String, String>::new();
|
||||
params.insert("REQUEST_METHOD".into(), method.as_str().into());
|
||||
params.insert("SCRIPT_FILENAME".into(), script_filename);
|
||||
params.insert("SCRIPT_NAME".into(), script_path.into());
|
||||
params.insert("QUERY_STRING".into(), query.unwrap_or("").into());
|
||||
params.insert("REQUEST_URI".into(), request_uri.into());
|
||||
params.insert("DOCUMENT_ROOT".into(), document_root);
|
||||
params.insert("SERVER_PROTOCOL".into(), server_protocol(context.version).into());
|
||||
params.insert("SERVER_NAME".into(), context.server_name.clone());
|
||||
params.insert("SERVER_PORT".into(), context.server_port.to_string());
|
||||
params.insert("REMOTE_ADDR".into(), context.client_ip.to_string());
|
||||
if let Some(port) = context.client_port {
|
||||
params.insert("REMOTE_PORT".into(), port.to_string());
|
||||
}
|
||||
params.insert("HTTPS".into(), if context.tls { "on" } else { "off" }.into());
|
||||
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.25.1".into());
|
||||
params.insert("REDIRECT_STATUS".into(), "200".into());
|
||||
if let Some(value) = headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()) {
|
||||
params.insert("CONTENT_TYPE".into(), value.into());
|
||||
}
|
||||
if let Some(value) = headers.get(CONTENT_LENGTH).and_then(|value| value.to_str().ok()) {
|
||||
params.insert("CONTENT_LENGTH".into(), value.into());
|
||||
}
|
||||
for (name, value) in headers {
|
||||
// HTTP_PROXY is a well-known CGI/FastCGI injection vector (httpoxy).
|
||||
// This must never depend on the optional Web Service hardening switch.
|
||||
if name.as_str() == "proxy"
|
||||
|| (!php.pass_authorization && name.as_str() == "authorization")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(param) = fastcgi_header_param(name) else { continue };
|
||||
let Ok(value) = value.to_str() else { continue };
|
||||
if value.len() > MAX_HEADER_VALUE_BYTES {
|
||||
continue;
|
||||
}
|
||||
params
|
||||
.entry(param)
|
||||
.and_modify(|existing| {
|
||||
if existing.len() + value.len() + 2 <= MAX_HEADER_VALUE_BYTES {
|
||||
existing.push_str(", ");
|
||||
existing.push_str(value);
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| value.to_owned());
|
||||
}
|
||||
// Trusted operator-defined parameters cannot override request-derived/core CGI names.
|
||||
for (name, value) in &php.custom_params {
|
||||
params.insert(name.clone(), value.clone());
|
||||
}
|
||||
if !php.php_values.is_empty() {
|
||||
let mut value = String::new();
|
||||
for (name, item) in &php.php_values {
|
||||
if !value.is_empty() {
|
||||
value.push('\n');
|
||||
}
|
||||
value.push_str(name);
|
||||
value.push('=');
|
||||
value.push_str(item);
|
||||
}
|
||||
params.insert("PHP_VALUE".into(), value);
|
||||
}
|
||||
// Request headers cannot synthesize PHP_VALUE/PHP_ADMIN_VALUE because only HTTP_*
|
||||
// names are derived above. PHP_VALUE comes exclusively from validated operator config.
|
||||
let mut encoded = Vec::with_capacity(4096);
|
||||
for (name, value) in params {
|
||||
encode_fcgi_param(&mut encoded, &name, &value)?;
|
||||
ensure!(
|
||||
encoded.len() <= 512 * 1024,
|
||||
"FastCGI parameter block exceeds 512 KiB"
|
||||
);
|
||||
}
|
||||
Ok(encoded)
|
||||
}
|
||||
|
||||
pub(super) async fn read_fcgi_record(
|
||||
io: &mut dyn FastCgiIo,
|
||||
deadline: Instant,
|
||||
) -> io::Result<(u8, Vec<u8>)> {
|
||||
let mut header = [0u8; 8];
|
||||
tokio::time::timeout_at(deadline, io.read_exact(&mut header))
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??;
|
||||
if header[0] != FCGI_VERSION_1 || u16::from_be_bytes([header[2], header[3]]) != FCGI_REQUEST_ID {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid FastCGI record header",
|
||||
));
|
||||
}
|
||||
let length = u16::from_be_bytes([header[4], header[5]]) as usize;
|
||||
let padding = header[6] as usize;
|
||||
let mut content = vec![0u8; length];
|
||||
if length != 0 {
|
||||
tokio::time::timeout_at(deadline, io.read_exact(&mut content))
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??;
|
||||
}
|
||||
if padding != 0 {
|
||||
let mut discard = [0u8; 255];
|
||||
tokio::time::timeout_at(deadline, io.read_exact(&mut discard[..padding]))
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "FastCGI response timeout"))??;
|
||||
}
|
||||
Ok((header[1], content))
|
||||
}
|
||||
|
||||
fn find_cgi_header_end(value: &[u8]) -> Option<(usize, usize)> {
|
||||
value
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|position| (position, 4))
|
||||
.or_else(|| {
|
||||
value
|
||||
.windows(2)
|
||||
.position(|window| window == b"\n\n")
|
||||
.map(|position| (position, 2))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn parse_cgi_headers(value: &[u8]) -> Result<(StatusCode, HeaderMap)> {
|
||||
let text = std::str::from_utf8(value).context("PHP-FPM returned non-UTF-8 CGI headers")?;
|
||||
let mut status = StatusCode::OK;
|
||||
let mut headers = HeaderMap::new();
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
ensure!(index < 128, "PHP-FPM returned too many CGI headers");
|
||||
let line = line.trim_end_matches('\r');
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (name, value) = line
|
||||
.split_once(':')
|
||||
.context("PHP-FPM returned a malformed CGI header")?;
|
||||
if name.eq_ignore_ascii_case("status") {
|
||||
let code = value
|
||||
.trim()
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.context("PHP-FPM returned an empty Status header")?
|
||||
.parse::<u16>()?;
|
||||
ensure!(
|
||||
(200..=599).contains(&code),
|
||||
"PHP-FPM returned an unsupported informational/invalid status"
|
||||
);
|
||||
status = StatusCode::from_u16(code)?;
|
||||
continue;
|
||||
}
|
||||
let name = HeaderName::from_str(name.trim())?;
|
||||
if matches!(
|
||||
name.as_str(),
|
||||
"connection"
|
||||
| "content-length"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
| "keep-alive"
|
||||
| "proxy-connection"
|
||||
| "te"
|
||||
| "trailer"
|
||||
| "proxy-authenticate"
|
||||
| "proxy-authorization"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let value = HeaderValue::from_str(value.trim())?;
|
||||
headers.append(name, value);
|
||||
}
|
||||
Ok((status, headers))
|
||||
}
|
||||
|
||||
async fn drain_fastcgi_body(
|
||||
mut io: Box<dyn FastCgiIo>,
|
||||
deadline: Instant,
|
||||
tx: mpsc::Sender<io::Result<Bytes>>,
|
||||
initial: Vec<u8>,
|
||||
send_body: bool,
|
||||
stderr_log_bytes: usize,
|
||||
_permit: OwnedSemaphorePermit,
|
||||
) {
|
||||
if send_body && !initial.is_empty() && tx.send(Ok(Bytes::from(initial))).await.is_err() {
|
||||
return;
|
||||
}
|
||||
let mut stderr = Vec::new();
|
||||
loop {
|
||||
match read_fcgi_record(io.as_mut(), deadline).await {
|
||||
Ok((FCGI_STDOUT, content)) => {
|
||||
if send_body && !content.is_empty() && tx.send(Ok(Bytes::from(content))).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok((FCGI_STDERR, content)) => {
|
||||
let room = stderr_log_bytes.saturating_sub(stderr.len());
|
||||
stderr.extend_from_slice(&content[..content.len().min(room)]);
|
||||
}
|
||||
Ok((FCGI_END_REQUEST, _)) => break,
|
||||
Ok(_) => {}
|
||||
Err(error) => {
|
||||
if send_body {
|
||||
let _ = tx.send(Err(error)).await;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
tracing::warn!(
|
||||
stderr = %String::from_utf8_lossy(&stderr),
|
||||
"PHP-FPM FastCGI stderr"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum FastCgiRequestWriteError {
|
||||
Io(io::Error),
|
||||
BodyTooLarge,
|
||||
}
|
||||
|
||||
impl From<io::Error> for FastCgiRequestWriteError {
|
||||
fn from(value: io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
pub(super) async fn php_response(
|
||||
service: &CompiledService,
|
||||
php: &CompiledPhp,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
original_uri: Uri,
|
||||
script_path: String,
|
||||
query: Option<String>,
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
) -> Response {
|
||||
if php.max_request_body_bytes > 0 {
|
||||
if let Some(length) = headers
|
||||
.get(CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
{
|
||||
if length > php.max_request_body_bytes {
|
||||
return builtin_error_response(StatusCode::PAYLOAD_TOO_LARGE, &method);
|
||||
}
|
||||
}
|
||||
}
|
||||
let permit = match php.permits.clone().try_acquire_owned() {
|
||||
Ok(permit) => permit,
|
||||
Err(_) => {
|
||||
let mut response = builtin_error_response(StatusCode::SERVICE_UNAVAILABLE, &method);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("retry-after", HeaderValue::from_static("1"));
|
||||
return response;
|
||||
}
|
||||
};
|
||||
let mut io = match tokio::time::timeout(php.connect_timeout, connect_fastcgi(&php.endpoint)).await {
|
||||
Ok(Ok(io)) => io,
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(%error, service = %service.id, "PHP-FPM connection failed");
|
||||
return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
|
||||
}
|
||||
Err(_) => return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method),
|
||||
};
|
||||
let params = match fastcgi_params(
|
||||
service,
|
||||
php,
|
||||
&method,
|
||||
&headers,
|
||||
&original_uri,
|
||||
&script_path,
|
||||
query.as_deref(),
|
||||
&context,
|
||||
) {
|
||||
Ok(params) => params,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, service = %service.id, "PHP-FPM parameter preparation failed");
|
||||
return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
|
||||
}
|
||||
};
|
||||
let deadline = Instant::now() + php.request_timeout;
|
||||
let exchange = async {
|
||||
let mut begin = [0u8; 8];
|
||||
begin[..2].copy_from_slice(&FCGI_RESPONDER.to_be_bytes());
|
||||
write_fcgi_record(io.as_mut(), FCGI_BEGIN_REQUEST, &begin).await?;
|
||||
for chunk in params.chunks(u16::MAX as usize) {
|
||||
write_fcgi_record(io.as_mut(), FCGI_PARAMS, chunk).await?;
|
||||
}
|
||||
write_fcgi_record(io.as_mut(), FCGI_PARAMS, &[]).await?;
|
||||
|
||||
let mut body = body;
|
||||
let mut body_bytes = 0u64;
|
||||
while let Some(frame) = body.frame().await {
|
||||
let frame = frame
|
||||
.map_err(|error| FastCgiRequestWriteError::Io(io::Error::other(error.to_string())))?;
|
||||
if let Ok(data) = frame.into_data() {
|
||||
body_bytes = body_bytes.saturating_add(data.len() as u64);
|
||||
if php.max_request_body_bytes > 0 && body_bytes > php.max_request_body_bytes {
|
||||
return Err(FastCgiRequestWriteError::BodyTooLarge);
|
||||
}
|
||||
for chunk in data.chunks(u16::MAX as usize) {
|
||||
write_fcgi_record(io.as_mut(), FCGI_STDIN, chunk).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
write_fcgi_record(io.as_mut(), FCGI_STDIN, &[]).await?;
|
||||
io.flush().await?;
|
||||
Ok::<(), FastCgiRequestWriteError>(())
|
||||
};
|
||||
match tokio::time::timeout_at(deadline, exchange).await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(FastCgiRequestWriteError::BodyTooLarge)) => {
|
||||
return builtin_error_response(StatusCode::PAYLOAD_TOO_LARGE, &method);
|
||||
}
|
||||
Ok(Err(FastCgiRequestWriteError::Io(error))) => {
|
||||
tracing::warn!(%error, service = %service.id, "PHP-FPM request write failed");
|
||||
return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
|
||||
}
|
||||
Err(_) => return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method),
|
||||
}
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let (status, response_headers, initial_body) = loop {
|
||||
let record = read_fcgi_record(io.as_mut(), deadline).await;
|
||||
let (record_type, content) = match record {
|
||||
Ok(value) => value,
|
||||
Err(error) if error.kind() == io::ErrorKind::TimedOut => {
|
||||
return builtin_error_response(StatusCode::GATEWAY_TIMEOUT, &method)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, service = %service.id, "PHP-FPM response read failed");
|
||||
return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
|
||||
}
|
||||
};
|
||||
match record_type {
|
||||
FCGI_STDOUT => {
|
||||
stdout.extend_from_slice(&content);
|
||||
if stdout.len() > php.max_response_header_bytes {
|
||||
return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
|
||||
}
|
||||
if let Some((position, separator_len)) = find_cgi_header_end(&stdout) {
|
||||
let body = stdout[position + separator_len..].to_vec();
|
||||
let parsed = parse_cgi_headers(&stdout[..position]);
|
||||
match parsed {
|
||||
Ok((status, headers)) => break (status, headers, body),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, service = %service.id, "PHP-FPM CGI headers rejected");
|
||||
return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
FCGI_STDERR => {
|
||||
let room = php.stderr_log_bytes.saturating_sub(stderr.len());
|
||||
stderr.extend_from_slice(&content[..content.len().min(room)]);
|
||||
}
|
||||
FCGI_END_REQUEST => {
|
||||
if !stderr.is_empty() {
|
||||
tracing::warn!(stderr = %String::from_utf8_lossy(&stderr), service = %service.id, "PHP-FPM FastCGI stderr");
|
||||
}
|
||||
return builtin_error_response(StatusCode::BAD_GATEWAY, &method);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
if !stderr.is_empty() {
|
||||
tracing::warn!(stderr = %String::from_utf8_lossy(&stderr), service = %service.id, "PHP-FPM FastCGI stderr");
|
||||
}
|
||||
let send_body = method != Method::HEAD
|
||||
&& status != StatusCode::NO_CONTENT
|
||||
&& status != StatusCode::NOT_MODIFIED
|
||||
&& !status.is_informational();
|
||||
let (tx, rx) = mpsc::channel::<io::Result<Bytes>>(8);
|
||||
tokio::spawn(drain_fastcgi_body(
|
||||
io,
|
||||
deadline,
|
||||
tx,
|
||||
initial_body,
|
||||
send_body,
|
||||
php.stderr_log_bytes,
|
||||
permit,
|
||||
));
|
||||
let mut response = Response::new(if send_body {
|
||||
Body::from_stream(stream::unfold(rx, |mut rx| async move {
|
||||
rx.recv().await.map(|item| (item, rx))
|
||||
}))
|
||||
} else {
|
||||
Body::empty()
|
||||
});
|
||||
*response.status_mut() = status;
|
||||
*response.headers_mut() = response_headers;
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn method_not_allowed_response() -> Response {
|
||||
let mut response = status_response(
|
||||
StatusCode::METHOD_NOT_ALLOWED,
|
||||
"RustProxy: Web Server method is not allowed for a static resource\n",
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(ALLOW, HeaderValue::from_static("GET, HEAD"));
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn php_for_path<'a>(service: &'a CompiledService, path: &Path) -> Option<&'a CompiledPhp> {
|
||||
let php = service.php.as_ref()?;
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())?
|
||||
.to_ascii_lowercase();
|
||||
(php.extensions.contains(&extension) || htaccess::php_extension(service.htaccess.as_ref(), path)).then_some(php)
|
||||
}
|
||||
|
||||
pub(super) fn protected_script_without_handler(service: &CompiledService, path: &Path) -> bool {
|
||||
if service.php.is_some() { return false; }
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.is_some_and(|extension| service.script_extensions.contains(&extension))
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use super::{CompiledService, SymlinkPolicy};
|
||||
use std::{fs::File, io, path::Path};
|
||||
#[cfg(unix)]
|
||||
use std::ffi::CString;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Opened {
|
||||
pub(super) file: File,
|
||||
pub(super) metadata: std::fs::Metadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct DirectoryEntry {
|
||||
pub(super) name: String,
|
||||
pub(super) directory: bool,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn cstring_component(value: &str) -> io::Result<CString> {
|
||||
CString::new(value.as_bytes())
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn open_root_directory(path: &Path) -> io::Result<File> {
|
||||
use std::os::unix::{ffi::OsStrExt, io::FromRawFd};
|
||||
let path = CString::new(path.as_os_str().as_bytes())
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
|
||||
let fd = unsafe {
|
||||
libc::open(
|
||||
path.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(unsafe { File::from_raw_fd(fd) })
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn path_components(path: &str) -> io::Result<Vec<String>> {
|
||||
if !path.starts_with('/') {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "path is not absolute"));
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
for part in path.split('/').filter(|part| !part.is_empty()) {
|
||||
if part == "." || part == ".." || part.contains('\\') || part.contains('\0') {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "ambiguous path"));
|
||||
}
|
||||
result.push(part.to_owned());
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn secure_open_sync(service: &CompiledService, components: &[String]) -> io::Result<Opened> {
|
||||
use std::os::unix::io::{AsRawFd, FromRawFd};
|
||||
|
||||
if components.is_empty() {
|
||||
// `File::try_clone()` uses dup(2), which shares the directory offset with
|
||||
// the persistent root descriptor. Passing such a clone to readdir(3)
|
||||
// would leave the next root listing positioned at EOF. openat(".")
|
||||
// creates a fresh open file description and therefore a fresh offset for
|
||||
// every request while retaining the same O_NOFOLLOW confinement.
|
||||
let dot = cstring_component(".")?;
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
service.root_handle.as_raw_fd(),
|
||||
dot.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let current = unsafe { File::from_raw_fd(fd) };
|
||||
let metadata = current.metadata()?;
|
||||
return Ok(Opened { file: current, metadata });
|
||||
}
|
||||
let mut current = service.root_handle.try_clone()?;
|
||||
for (index, component) in components.iter().enumerate() {
|
||||
let name = cstring_component(component)?;
|
||||
let intermediate = index + 1 != components.len();
|
||||
// The default remains O_NOFOLLOW on every component. `inside_root` is an
|
||||
// explicit Linux-only opt-in: the opened descriptor is checked through
|
||||
// /proc/self/fd after each resolution step before it can be used further.
|
||||
let mut flags = libc::O_RDONLY | libc::O_CLOEXEC;
|
||||
if service.symlink_policy == SymlinkPolicy::Deny {
|
||||
flags |= libc::O_NOFOLLOW;
|
||||
}
|
||||
if intermediate {
|
||||
flags |= libc::O_DIRECTORY;
|
||||
} else {
|
||||
// Avoid blocking on special files. Only regular files/directories are served.
|
||||
flags |= libc::O_NONBLOCK;
|
||||
}
|
||||
let fd = unsafe { libc::openat(current.as_raw_fd(), name.as_ptr(), flags) };
|
||||
if fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if service.symlink_policy == SymlinkPolicy::InsideRoot {
|
||||
#[cfg(target_os = "linux")]
|
||||
verify_linux_fd_beneath_root(service, fd)?;
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
unsafe { libc::close(fd) };
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"inside_root symlink policy requires Linux",
|
||||
));
|
||||
}
|
||||
}
|
||||
current = unsafe { File::from_raw_fd(fd) };
|
||||
}
|
||||
let metadata = current.metadata()?;
|
||||
if !metadata.is_file() && !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"Web Server serves only regular files and directories",
|
||||
));
|
||||
}
|
||||
Ok(Opened { file: current, metadata })
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn verify_linux_fd_beneath_root(service: &CompiledService, fd: libc::c_int) -> io::Result<()> {
|
||||
let target = match std::fs::read_link(format!("/proc/self/fd/{fd}")) {
|
||||
Ok(target) => target,
|
||||
Err(error) => {
|
||||
unsafe { libc::close(fd) };
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if target.starts_with(&service.root) {
|
||||
Ok(())
|
||||
} else {
|
||||
unsafe { libc::close(fd) };
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"symlink target escaped document root",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(super) fn secure_open_sync(service: &CompiledService, components: &[String]) -> io::Result<Opened> {
|
||||
let mut candidate = service.root.clone();
|
||||
for component in components {
|
||||
candidate.push(component);
|
||||
}
|
||||
let canonical = std::fs::canonicalize(&candidate)?;
|
||||
if !canonical.starts_with(&service.root) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"path escaped document root",
|
||||
));
|
||||
}
|
||||
let file = File::open(&canonical)?;
|
||||
let metadata = file.metadata()?;
|
||||
if !metadata.is_file() && !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"Web Server serves only regular files and directories",
|
||||
));
|
||||
}
|
||||
Ok(Opened { file, metadata })
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(super) fn list_directory_sync(
|
||||
service: &CompiledService,
|
||||
components: &[String],
|
||||
) -> io::Result<Vec<DirectoryEntry>> {
|
||||
use std::ffi::CStr;
|
||||
use std::os::unix::io::IntoRawFd;
|
||||
|
||||
let opened = secure_open_sync(service, components)?;
|
||||
if !opened.metadata.is_dir() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory"));
|
||||
}
|
||||
let raw = opened.file.try_clone()?.into_raw_fd();
|
||||
let dir = unsafe { libc::fdopendir(raw) };
|
||||
if dir.is_null() {
|
||||
let error = io::Error::last_os_error();
|
||||
unsafe { libc::close(raw) };
|
||||
return Err(error);
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
loop {
|
||||
let entry = unsafe { libc::readdir(dir) };
|
||||
if entry.is_null() {
|
||||
break;
|
||||
}
|
||||
let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
|
||||
if name == b"." || name == b".." {
|
||||
continue;
|
||||
}
|
||||
let Ok(name) = std::str::from_utf8(name) else {
|
||||
// Routing intentionally accepts only valid UTF-8, so do not emit an
|
||||
// unusable or ambiguously decoded link for a non-UTF-8 file name.
|
||||
continue;
|
||||
};
|
||||
let mut child = components.to_vec();
|
||||
child.push(name.to_owned());
|
||||
// Re-open through the root fd using the configured symlink policy. The
|
||||
// default omits every symlink; Linux inside_root may include a target only
|
||||
// after the opened descriptor has been proven to remain below the root.
|
||||
let Ok(child_opened) = secure_open_sync(service, &child) else {
|
||||
continue;
|
||||
};
|
||||
entries.push(DirectoryEntry {
|
||||
name: name.to_owned(),
|
||||
directory: child_opened.metadata.is_dir(),
|
||||
});
|
||||
}
|
||||
unsafe { libc::closedir(dir) };
|
||||
entries.sort_by(|a, b| {
|
||||
b.directory
|
||||
.cmp(&a.directory)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(super) fn list_directory_sync(
|
||||
service: &CompiledService,
|
||||
components: &[String],
|
||||
) -> io::Result<Vec<DirectoryEntry>> {
|
||||
let opened = secure_open_sync(service, components)?;
|
||||
if !opened.metadata.is_dir() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotADirectory, "not a directory"));
|
||||
}
|
||||
let mut path = service.root.clone();
|
||||
for component in components {
|
||||
path.push(component);
|
||||
}
|
||||
let canonical = std::fs::canonicalize(path)?;
|
||||
if !canonical.starts_with(&service.root) {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "path escaped document root"));
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
for entry in std::fs::read_dir(canonical)? {
|
||||
let entry = entry?;
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
let mut child = components.to_vec();
|
||||
child.push(name.clone());
|
||||
let Ok(child_opened) = secure_open_sync(service, &child) else {
|
||||
continue;
|
||||
};
|
||||
entries.push(DirectoryEntry {
|
||||
name,
|
||||
directory: child_opened.metadata.is_dir(),
|
||||
});
|
||||
}
|
||||
entries.sort_by(|a, b| {
|
||||
b.directory
|
||||
.cmp(&a.directory)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
+3
-1428
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,359 @@
|
||||
use super::{
|
||||
config::CompiledService,
|
||||
filesystem::path_components,
|
||||
request::RequestContext,
|
||||
routing::open_components,
|
||||
};
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{
|
||||
header::{CACHE_CONTROL, EXPIRES, LOCATION},
|
||||
HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri,
|
||||
};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
fs,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
|
||||
const MAX_HTACCESS_FILES: usize = 128;
|
||||
const MAX_HTACCESS_DEPTH: usize = 16;
|
||||
const MAX_HTACCESS_FILE_BYTES: u64 = 128 * 1024;
|
||||
const MAX_HTACCESS_TOTAL_BYTES: u64 = 1024 * 1024;
|
||||
const MAX_HTACCESS_RULES: usize = 256;
|
||||
const MAX_HTACCESS_CONDITIONS: usize = 512;
|
||||
const MAX_HTACCESS_HEADERS: usize = 64;
|
||||
const MAX_HTACCESS_MIME_TYPES: usize = 128;
|
||||
|
||||
fn default_filename() -> String {
|
||||
".htaccess".into()
|
||||
}
|
||||
|
||||
fn default_scan_subdirectories() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_filename")]
|
||||
pub filename: String,
|
||||
#[serde(default = "default_scan_subdirectories")]
|
||||
pub scan_subdirectories: bool,
|
||||
/// Unsupported directives are warnings by default. Strict mode rejects them.
|
||||
pub strict: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
filename: default_filename(),
|
||||
scan_subdirectories: true,
|
||||
strict: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(config: &mut Config) -> Result<()> {
|
||||
config.filename = config.filename.trim().to_owned();
|
||||
ensure!(
|
||||
!config.filename.is_empty()
|
||||
&& config.filename.len() <= 64
|
||||
&& !config.filename.chars().any(char::is_control)
|
||||
&& !config.filename.contains('/')
|
||||
&& !config.filename.contains('\\')
|
||||
&& config.filename != "."
|
||||
&& config.filename != "..",
|
||||
".htaccess compatibility filename must be a plain 1..64 byte file name"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Compiled {
|
||||
pub filename: String,
|
||||
scopes: Vec<Scope>,
|
||||
pub files_loaded: usize,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct Scope {
|
||||
components: Vec<String>,
|
||||
rewrites: Vec<RewriteRule>,
|
||||
directory_index: Option<Vec<String>>,
|
||||
directory_listing: Option<bool>,
|
||||
mime_types: BTreeMap<String, HeaderValue>,
|
||||
php_extensions: HashSet<String>,
|
||||
headers: Vec<HeaderOp>,
|
||||
expires_active: bool,
|
||||
expires_default: Option<Duration>,
|
||||
expires_by_type: Vec<(String, Duration)>,
|
||||
deny_all: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RewriteRule {
|
||||
pattern: Regex,
|
||||
replacement: String,
|
||||
redirect: Option<StatusCode>,
|
||||
forbidden: bool,
|
||||
last: bool,
|
||||
end: bool,
|
||||
qsa: bool,
|
||||
conditions: Vec<RewriteCondition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct RewriteCondition {
|
||||
input: String,
|
||||
matcher: ConditionMatcher,
|
||||
or_next: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum ConditionMatcher {
|
||||
Regex { regex: Regex, negate: bool },
|
||||
Exact { value: String, negate: bool, insensitive: bool },
|
||||
File { kind: FileTest, negate: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum FileTest {
|
||||
File,
|
||||
Directory,
|
||||
NonEmptyFile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum HeaderOp {
|
||||
Set(HeaderName, HeaderValue),
|
||||
Add(HeaderName, HeaderValue),
|
||||
Unset(HeaderName),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RewriteOutcome {
|
||||
Internal { path: String, query: Option<String> },
|
||||
Redirect(Response),
|
||||
Forbidden,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ParseState {
|
||||
rewrite_engine: bool,
|
||||
pending_conditions: Vec<RewriteCondition>,
|
||||
skip_next_rule: bool,
|
||||
in_files_block: usize,
|
||||
unsupported: Vec<String>,
|
||||
}
|
||||
|
||||
fn tokens(value: &str) -> Result<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut quote = None;
|
||||
let mut escaped = false;
|
||||
for ch in value.chars() {
|
||||
if escaped {
|
||||
current.push(ch);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true;
|
||||
current.push(ch);
|
||||
continue;
|
||||
}
|
||||
if let Some(wanted) = quote {
|
||||
if ch == wanted {
|
||||
quote = None;
|
||||
} else {
|
||||
current.push(ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ch == '"' || ch == '\'' {
|
||||
quote = Some(ch);
|
||||
} else if ch.is_whitespace() {
|
||||
if !current.is_empty() {
|
||||
out.push(std::mem::take(&mut current));
|
||||
}
|
||||
} else {
|
||||
current.push(ch);
|
||||
}
|
||||
}
|
||||
ensure!(quote.is_none(), "unterminated quote in .htaccess");
|
||||
if !current.is_empty() {
|
||||
out.push(current);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn flags(value: Option<&str>) -> Vec<String> {
|
||||
value
|
||||
.and_then(|value| value.strip_prefix('[').and_then(|v| v.strip_suffix(']')))
|
||||
.map(|value| value.split(',').map(|v| v.trim().to_owned()).filter(|v| !v.is_empty()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ci_regex(pattern: &str, insensitive: bool) -> Result<Regex> {
|
||||
if insensitive {
|
||||
Regex::new(&format!("(?i:{pattern})")).context("invalid case-insensitive .htaccess regex")
|
||||
} else {
|
||||
Regex::new(pattern).context("invalid .htaccess regex")
|
||||
}
|
||||
}
|
||||
|
||||
fn supported_response_header(name: &HeaderName) -> bool {
|
||||
!matches!(
|
||||
name.as_str(),
|
||||
"connection"
|
||||
| "content-length"
|
||||
| "content-encoding"
|
||||
| "content-range"
|
||||
| "transfer-encoding"
|
||||
| "upgrade"
|
||||
| "keep-alive"
|
||||
| "proxy-connection"
|
||||
| "vary"
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_duration(value: &str) -> Result<Duration> {
|
||||
let value = value.trim().trim_matches('"').trim();
|
||||
if let Some(seconds) = value.strip_prefix('A').or_else(|| value.strip_prefix('a')) {
|
||||
let seconds: u64 = seconds.parse().context("invalid Expires duration")?;
|
||||
return Ok(Duration::from_secs(seconds));
|
||||
}
|
||||
let parts = value.split_whitespace().collect::<Vec<_>>();
|
||||
ensure!(parts.len() >= 4, "Expires value must look like 'access plus 1 day' or A3600");
|
||||
ensure!(parts[0].eq_ignore_ascii_case("access") && parts[1].eq_ignore_ascii_case("plus"), "only access-based Expires values are supported");
|
||||
let count: u64 = parts[2].parse().context("invalid Expires duration number")?;
|
||||
let unit = parts[3].to_ascii_lowercase();
|
||||
let seconds = match unit.trim_end_matches('s') {
|
||||
"second" => count,
|
||||
"minute" => count.saturating_mul(60),
|
||||
"hour" => count.saturating_mul(3600),
|
||||
"day" => count.saturating_mul(86400),
|
||||
"week" => count.saturating_mul(7 * 86400),
|
||||
"month" => count.saturating_mul(30 * 86400),
|
||||
"year" => count.saturating_mul(365 * 86400),
|
||||
_ => bail!("unsupported Expires duration unit"),
|
||||
};
|
||||
Ok(Duration::from_secs(seconds))
|
||||
}
|
||||
|
||||
fn parse_condition(parts: &[String]) -> Result<RewriteCondition> {
|
||||
ensure!(parts.len() >= 3, "RewriteCond needs a test string and pattern");
|
||||
let condition_flags = flags(parts.get(3).map(String::as_str));
|
||||
let insensitive = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("NC"));
|
||||
let or_next = condition_flags.iter().any(|flag| flag.eq_ignore_ascii_case("OR"));
|
||||
for flag in &condition_flags {
|
||||
ensure!(flag.eq_ignore_ascii_case("NC") || flag.eq_ignore_ascii_case("OR"), "unsupported RewriteCond flag [{flag}]");
|
||||
}
|
||||
let raw = parts[2].as_str();
|
||||
let matcher = if let Some(value) = raw.strip_prefix("!=") {
|
||||
ConditionMatcher::Exact { value: value.to_owned(), negate: true, insensitive }
|
||||
} else if let Some(value) = raw.strip_prefix('=') {
|
||||
ConditionMatcher::Exact { value: value.to_owned(), negate: false, insensitive }
|
||||
} else {
|
||||
let (negate, raw) = raw.strip_prefix('!').map_or((false, raw), |v| (true, v));
|
||||
match raw {
|
||||
"-f" => ConditionMatcher::File { kind: FileTest::File, negate },
|
||||
"-d" => ConditionMatcher::File { kind: FileTest::Directory, negate },
|
||||
"-s" => ConditionMatcher::File { kind: FileTest::NonEmptyFile, negate },
|
||||
_ => ConditionMatcher::Regex { regex: ci_regex(raw, insensitive)?, negate },
|
||||
}
|
||||
};
|
||||
Ok(RewriteCondition { input: parts[1].clone(), matcher, or_next })
|
||||
}
|
||||
|
||||
fn parse_rewrite_rule(parts: &[String], conditions: Vec<RewriteCondition>, state: &mut ParseState) -> Result<RewriteRule> {
|
||||
ensure!(parts.len() >= 3, "RewriteRule needs a pattern and substitution");
|
||||
let rule_flags = flags(parts.get(3).map(String::as_str));
|
||||
let mut redirect = None;
|
||||
let mut forbidden = false;
|
||||
let mut last = false;
|
||||
let mut end = false;
|
||||
let mut qsa = false;
|
||||
let mut insensitive = false;
|
||||
for flag in rule_flags {
|
||||
let upper = flag.to_ascii_uppercase();
|
||||
match upper.as_str() {
|
||||
"L" => last = true,
|
||||
"END" => { end = true; last = true; }
|
||||
"QSA" => qsa = true,
|
||||
"NC" => insensitive = true,
|
||||
"F" => { forbidden = true; last = true; }
|
||||
"R" => { redirect = Some(StatusCode::FOUND); last = true; }
|
||||
"PT" | "NE" => {}
|
||||
_ if upper.starts_with("R=") => {
|
||||
let code: u16 = upper[2..].parse().context("invalid RewriteRule redirect status")?;
|
||||
ensure!(matches!(code, 301 | 302 | 307 | 308), "RewriteRule redirect status must be 301, 302, 307 or 308");
|
||||
redirect = Some(StatusCode::from_u16(code)?);
|
||||
last = true;
|
||||
}
|
||||
_ if upper.starts_with("E=") => state.unsupported.push(format!("RewriteRule environment flag [{flag}] is accepted as a no-op; request HTTP headers are already forwarded to PHP-FPM")),
|
||||
_ => bail!("unsupported RewriteRule flag [{flag}]"),
|
||||
}
|
||||
}
|
||||
Ok(RewriteRule {
|
||||
pattern: ci_regex(&parts[1], insensitive)?,
|
||||
replacement: parts[2].clone(),
|
||||
redirect,
|
||||
forbidden,
|
||||
last,
|
||||
end,
|
||||
qsa,
|
||||
conditions,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_extension(value: &str) -> Result<String> {
|
||||
let value = value.trim().trim_start_matches('.').to_ascii_lowercase();
|
||||
ensure!(!value.is_empty() && value.len() <= 32 && value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')), "invalid extension in .htaccess");
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn unsupported(state: &mut ParseState, config: &Config, file: &Path, line: usize, message: String) -> Result<()> {
|
||||
if config.strict {
|
||||
bail!("{}:{line}: {message}", file.display());
|
||||
}
|
||||
state.unsupported.push(format!("{}:{line}: {message}", file.display()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_htaccess_nofollow(path: &Path) -> Result<String> {
|
||||
#[cfg(unix)]
|
||||
let mut file = {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW)
|
||||
.open(path)
|
||||
.with_context(|| format!("cannot safely open {}", path.display()))?
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut file = {
|
||||
let metadata = fs::symlink_metadata(path)?;
|
||||
ensure!(!metadata.file_type().is_symlink(), "{} must not be a symlink", path.display());
|
||||
fs::File::open(path)?
|
||||
};
|
||||
let metadata = file.metadata()?;
|
||||
ensure!(metadata.is_file(), "{} is not a regular file", path.display());
|
||||
ensure!(metadata.len() <= MAX_HTACCESS_FILE_BYTES, "{} exceeds the 128 KiB .htaccess limit", path.display());
|
||||
let mut content = String::new();
|
||||
(&mut file)
|
||||
.take(MAX_HTACCESS_FILE_BYTES + 1)
|
||||
.read_to_string(&mut content)
|
||||
.with_context(|| format!("{} is not valid UTF-8", path.display()))?;
|
||||
ensure!(content.len() as u64 <= MAX_HTACCESS_FILE_BYTES, "{} exceeds the 128 KiB .htaccess limit", path.display());
|
||||
Ok(content)
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
fn parse_file(path: &Path, components: Vec<String>, config: &Config) -> Result<(Scope, Vec<String>)> {
|
||||
let content = read_htaccess_nofollow(path)?;
|
||||
let mut scope = Scope { components, ..Scope::default() };
|
||||
let mut state = ParseState { rewrite_engine: false, ..ParseState::default() };
|
||||
let mut rule_count = 0usize;
|
||||
let mut condition_count = 0usize;
|
||||
|
||||
for (line_no, raw) in content.lines().enumerate() {
|
||||
let line_no = line_no + 1;
|
||||
let line = raw.trim();
|
||||
if line.is_empty() || line.starts_with('#') { continue; }
|
||||
let lower_line = line.to_ascii_lowercase();
|
||||
if lower_line.starts_with("<ifmodule") || lower_line == "</ifmodule>" { continue; }
|
||||
if lower_line.starts_with("<files") || lower_line.starts_with("<filesmatch") {
|
||||
if lower_line.contains(".htaccess") || lower_line.contains(".htpasswd") || lower_line.contains("^\\.ht") {
|
||||
state.in_files_block = state.in_files_block.saturating_add(1);
|
||||
continue;
|
||||
}
|
||||
bail!("{}:{line_no}: general Files/FilesMatch semantics are not supported; refusing to ignore an access/handler scope", path.display());
|
||||
}
|
||||
if line.eq_ignore_ascii_case("</Files>") || line.eq_ignore_ascii_case("</FilesMatch>") {
|
||||
state.in_files_block = state.in_files_block.saturating_sub(1);
|
||||
continue;
|
||||
}
|
||||
if state.in_files_block != 0 {
|
||||
// RustProxy always blocks .htaccess/.htpasswd directly. More general Files/FilesMatch
|
||||
// access semantics remain intentionally outside the compatibility subset.
|
||||
continue;
|
||||
}
|
||||
let parts = tokens(line).with_context(|| format!("{}:{line_no}", path.display()))?;
|
||||
if parts.is_empty() { continue; }
|
||||
let directive = parts[0].to_ascii_lowercase();
|
||||
match directive.as_str() {
|
||||
"rewriteengine" => {
|
||||
ensure!(parts.len() == 2, "{}:{line_no}: RewriteEngine expects On or Off", path.display());
|
||||
state.rewrite_engine = if parts[1].eq_ignore_ascii_case("on") { true } else if parts[1].eq_ignore_ascii_case("off") { false } else { bail!("{}:{line_no}: RewriteEngine expects On or Off", path.display()) };
|
||||
if !state.rewrite_engine { state.pending_conditions.clear(); state.skip_next_rule = false; }
|
||||
}
|
||||
"rewritebase" => {
|
||||
// Per-directory substitutions are already resolved relative to the containing scope.
|
||||
}
|
||||
"rewritecond" if state.rewrite_engine => {
|
||||
condition_count += 1;
|
||||
ensure!(condition_count <= MAX_HTACCESS_CONDITIONS, "too many RewriteCond entries across one .htaccess file");
|
||||
match parse_condition(&parts) {
|
||||
Ok(condition) => state.pending_conditions.push(condition),
|
||||
Err(error) => {
|
||||
unsupported(&mut state, config, path, line_no, error.to_string())?;
|
||||
state.skip_next_rule = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
"rewriterule" if state.rewrite_engine => {
|
||||
rule_count += 1;
|
||||
ensure!(rule_count <= MAX_HTACCESS_RULES, "too many RewriteRule entries across one .htaccess file");
|
||||
let conditions = std::mem::take(&mut state.pending_conditions);
|
||||
if state.skip_next_rule {
|
||||
state.skip_next_rule = false;
|
||||
state.unsupported.push(format!("{}:{line_no}: RewriteRule skipped because a preceding RewriteCond was unsupported", path.display()));
|
||||
continue;
|
||||
}
|
||||
match parse_rewrite_rule(&parts, conditions, &mut state) {
|
||||
Ok(rule) => scope.rewrites.push(rule),
|
||||
Err(error) => unsupported(&mut state, config, path, line_no, error.to_string())?,
|
||||
}
|
||||
}
|
||||
"directoryindex" => {
|
||||
ensure!(parts.len() >= 2, "{}:{line_no}: DirectoryIndex needs at least one file", path.display());
|
||||
let mut index = Vec::new();
|
||||
for value in &parts[1..] {
|
||||
if value.eq_ignore_ascii_case("disabled") { index.clear(); break; }
|
||||
ensure!(!value.contains('/') && !value.contains('\\') && value != "." && value != "..", "{}:{line_no}: DirectoryIndex entries must be plain file names", path.display());
|
||||
index.push(value.clone());
|
||||
}
|
||||
ensure!(index.len() <= 16, "{}:{line_no}: DirectoryIndex allows at most 16 files", path.display());
|
||||
scope.directory_index = Some(index);
|
||||
}
|
||||
"options" => {
|
||||
for option in &parts[1..] {
|
||||
if option.eq_ignore_ascii_case("+Indexes") || option.eq_ignore_ascii_case("Indexes") { scope.directory_listing = Some(true); }
|
||||
else if option.eq_ignore_ascii_case("-Indexes") { scope.directory_listing = Some(false); }
|
||||
else if option.eq_ignore_ascii_case("-MultiViews") { /* RustProxy has no content negotiation; already effectively off. */ }
|
||||
else { unsupported(&mut state, config, path, line_no, format!("unsupported Options token {option}"))?; }
|
||||
}
|
||||
}
|
||||
"addtype" => {
|
||||
ensure!(parts.len() >= 3, "{}:{line_no}: AddType expects media/type and extension(s)", path.display());
|
||||
let mime = HeaderValue::from_str(&parts[1]).context("invalid AddType media type")?;
|
||||
for extension in &parts[2..] {
|
||||
ensure!(scope.mime_types.len() < MAX_HTACCESS_MIME_TYPES, "too many AddType extensions in .htaccess");
|
||||
scope.mime_types.insert(normalize_extension(extension)?, mime.clone());
|
||||
}
|
||||
}
|
||||
"addhandler" => {
|
||||
ensure!(parts.len() >= 3, "{}:{line_no}: AddHandler expects handler and extension(s)", path.display());
|
||||
if parts[1].to_ascii_lowercase().contains("php") {
|
||||
for extension in &parts[2..] { scope.php_extensions.insert(normalize_extension(extension)?); }
|
||||
} else {
|
||||
bail!("{}:{line_no}: non-PHP AddHandler is not supported and cannot be ignored safely", path.display());
|
||||
}
|
||||
}
|
||||
"header" => {
|
||||
ensure!(parts.len() >= 3, "{}:{line_no}: Header expects an action and name", path.display());
|
||||
ensure!(scope.headers.len() < MAX_HTACCESS_HEADERS, "too many Header directives in .htaccess");
|
||||
let mut action_index = 1usize;
|
||||
if parts[action_index].eq_ignore_ascii_case("always") || parts[action_index].eq_ignore_ascii_case("onsuccess") { action_index += 1; }
|
||||
ensure!(parts.len() > action_index + 1, "{}:{line_no}: Header expects an action and name", path.display());
|
||||
let action = parts[action_index].to_ascii_lowercase();
|
||||
let name = HeaderName::from_str(&parts[action_index + 1]).context("invalid Header name")?;
|
||||
ensure!(supported_response_header(&name), "protected response header cannot be set from .htaccess");
|
||||
match action.as_str() {
|
||||
"unset" => scope.headers.push(HeaderOp::Unset(name)),
|
||||
"set" | "add" => {
|
||||
ensure!(parts.len() > action_index + 2, "{}:{line_no}: Header {action} needs a value", path.display());
|
||||
let value = HeaderValue::from_str(&parts[action_index + 2..].join(" ")).context("invalid Header value")?;
|
||||
if action == "set" { scope.headers.push(HeaderOp::Set(name, value)); } else { scope.headers.push(HeaderOp::Add(name, value)); }
|
||||
}
|
||||
_ => unsupported(&mut state, config, path, line_no, format!("unsupported Header action {action}"))?,
|
||||
}
|
||||
}
|
||||
"expiresactive" => {
|
||||
ensure!(parts.len() == 2, "{}:{line_no}: ExpiresActive expects On or Off", path.display());
|
||||
scope.expires_active = parts[1].eq_ignore_ascii_case("on");
|
||||
}
|
||||
"expiresdefault" => {
|
||||
ensure!(parts.len() >= 2, "{}:{line_no}: ExpiresDefault needs a duration", path.display());
|
||||
scope.expires_default = Some(parse_duration(&parts[1..].join(" "))?);
|
||||
}
|
||||
"expiresbytype" => {
|
||||
ensure!(parts.len() >= 3, "{}:{line_no}: ExpiresByType needs media type and duration", path.display());
|
||||
scope.expires_by_type.push((parts[1].to_ascii_lowercase(), parse_duration(&parts[2..].join(" "))?));
|
||||
}
|
||||
"require" => {
|
||||
if parts.len() == 3 && parts[1].eq_ignore_ascii_case("all") && parts[2].eq_ignore_ascii_case("denied") { scope.deny_all = Some(true); }
|
||||
else if parts.len() == 3 && parts[1].eq_ignore_ascii_case("all") && parts[2].eq_ignore_ascii_case("granted") { scope.deny_all = Some(false); }
|
||||
else { bail!("{}:{line_no}: only 'Require all granted/denied' is supported; refusing to ignore an access-control directive", path.display()); }
|
||||
}
|
||||
"sethandler" | "removehandler" | "authtype" | "authname" | "authuserfile" | "authgroupfile" | "order" | "allow" | "deny" => {
|
||||
bail!("{}:{line_no}: security/handler directive {} is not supported and cannot be ignored safely", path.display(), parts[0]);
|
||||
}
|
||||
"errordocument" | "setenvif" | "redirect" | "redirectmatch" | "fallbackresource" => {
|
||||
unsupported(&mut state, config, path, line_no, format!("directive {} is not in the RustProxy compatibility subset", parts[0]))?;
|
||||
}
|
||||
_ => unsupported(&mut state, config, path, line_no, format!("unsupported directive {}", parts[0]))?,
|
||||
}
|
||||
}
|
||||
ensure!(state.pending_conditions.is_empty(), "{}: RewriteCond without following RewriteRule", path.display());
|
||||
Ok((scope, state.unsupported))
|
||||
}
|
||||
|
||||
fn discover(root: &Path, filename: &str, recursive: bool) -> Result<Vec<(PathBuf, Vec<String>)>> {
|
||||
let mut result = Vec::new();
|
||||
let mut stack = vec![(root.to_path_buf(), Vec::<String>::new(), 0usize)];
|
||||
while let Some((directory, components, depth)) = stack.pop() {
|
||||
let directory = fs::canonicalize(&directory)
|
||||
.with_context(|| format!("cannot canonicalize {} during .htaccess scan", directory.display()))?;
|
||||
ensure!(directory.starts_with(root), ".htaccess scan escaped the Web Service document root");
|
||||
let candidate = directory.join(filename);
|
||||
match fs::symlink_metadata(&candidate) {
|
||||
Ok(metadata) => {
|
||||
ensure!(!metadata.file_type().is_symlink(), "{} must not be a symlink", candidate.display());
|
||||
if metadata.is_file() {
|
||||
result.push((candidate, components.clone()));
|
||||
ensure!(result.len() <= MAX_HTACCESS_FILES, "Web Service .htaccess scan exceeds 128 files");
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error).with_context(|| format!("cannot inspect {}", candidate.display())),
|
||||
}
|
||||
if !recursive || depth >= MAX_HTACCESS_DEPTH { continue; }
|
||||
for entry in fs::read_dir(&directory).with_context(|| format!("cannot scan {} for .htaccess", directory.display()))? {
|
||||
let entry = entry?;
|
||||
let file_type = entry.file_type()?;
|
||||
if !file_type.is_dir() || file_type.is_symlink() { continue; }
|
||||
let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue; };
|
||||
if name == ".git" || name == "node_modules" { continue; }
|
||||
let mut child = components.clone();
|
||||
child.push(name);
|
||||
stack.push((entry.path(), child, depth + 1));
|
||||
}
|
||||
}
|
||||
result.sort_by(|a, b| a.1.len().cmp(&b.1.len()).then_with(|| a.1.cmp(&b.1)));
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn compile(root: &Path, config: &Config) -> Result<Option<Compiled>> {
|
||||
if !config.enabled { return Ok(None); }
|
||||
let files = discover(root, &config.filename, config.scan_subdirectories)?;
|
||||
let mut scopes = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut total = 0u64;
|
||||
for (file, components) in files {
|
||||
total = total.saturating_add(fs::metadata(&file)?.len());
|
||||
ensure!(total <= MAX_HTACCESS_TOTAL_BYTES, "Web Service .htaccess files exceed 1 MiB total");
|
||||
let (scope, mut file_warnings) = parse_file(&file, components, config)?;
|
||||
scopes.push(scope);
|
||||
warnings.append(&mut file_warnings);
|
||||
}
|
||||
Ok(Some(Compiled { filename: config.filename.clone(), files_loaded: scopes.len(), scopes, warnings }))
|
||||
}
|
||||
|
||||
pub fn adds_php_extensions(compiled: Option<&Compiled>) -> bool {
|
||||
compiled.is_some_and(|compiled| compiled.scopes.iter().any(|scope| !scope.php_extensions.is_empty()))
|
||||
}
|
||||
|
||||
pub fn protected_component(compiled: Option<&Compiled>, value: &str) -> bool {
|
||||
let lower = value.to_ascii_lowercase();
|
||||
if lower == ".htaccess" || lower == ".htpasswd" || lower.starts_with(".ht") { return true; }
|
||||
compiled.is_some_and(|compiled| value.eq_ignore_ascii_case(&compiled.filename))
|
||||
}
|
||||
|
||||
fn scope_matches(scope: &Scope, components: &[String]) -> bool {
|
||||
components.len() >= scope.components.len() && components[..scope.components.len()] == scope.components[..]
|
||||
}
|
||||
|
||||
fn matching_scopes<'a>(compiled: &'a Compiled, path: &str) -> Vec<&'a Scope> {
|
||||
let Ok(components) = path_components(path) else { return Vec::new(); };
|
||||
compiled.scopes.iter().filter(|scope| scope_matches(scope, &components)).collect()
|
||||
}
|
||||
|
||||
pub fn denied(compiled: Option<&Compiled>, path: &str) -> bool {
|
||||
let Some(compiled) = compiled else { return false; };
|
||||
matching_scopes(compiled, path).into_iter().filter_map(|scope| scope.deny_all).last().unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn index_files<'a>(compiled: Option<&'a Compiled>, directory_components: &[String], fallback: &'a [String]) -> &'a [String] {
|
||||
let Some(compiled) = compiled else { return fallback; };
|
||||
compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_index.as_deref()).last().unwrap_or(fallback)
|
||||
}
|
||||
|
||||
pub fn directory_listing(compiled: Option<&Compiled>, directory_components: &[String], fallback: bool) -> bool {
|
||||
let Some(compiled) = compiled else { return fallback; };
|
||||
compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_listing).last().unwrap_or(fallback)
|
||||
}
|
||||
|
||||
pub fn mime_type(compiled: Option<&Compiled>, path: &Path) -> Option<HeaderValue> {
|
||||
let compiled = compiled?;
|
||||
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||
let path_text = path.to_string_lossy();
|
||||
matching_scopes(compiled, &path_text).into_iter().filter_map(|scope| scope.mime_types.get(&extension).cloned()).last()
|
||||
}
|
||||
|
||||
pub fn php_extension(compiled: Option<&Compiled>, path: &Path) -> bool {
|
||||
let Some(compiled) = compiled else { return false; };
|
||||
let Some(extension) = path.extension().and_then(|v| v.to_str()).map(|v| v.to_ascii_lowercase()) else { return false; };
|
||||
let path_text = path.to_string_lossy();
|
||||
matching_scopes(compiled, &path_text).into_iter().any(|scope| scope.php_extensions.contains(&extension))
|
||||
}
|
||||
|
||||
pub fn apply_response(compiled: Option<&Compiled>, response: &mut Response, path: &str) {
|
||||
let Some(compiled) = compiled else { return; };
|
||||
let scopes = matching_scopes(compiled, path);
|
||||
for scope in &scopes {
|
||||
for op in &scope.headers {
|
||||
match op {
|
||||
HeaderOp::Set(name, value) => { response.headers_mut().insert(name.clone(), value.clone()); }
|
||||
HeaderOp::Add(name, value) => { response.headers_mut().append(name.clone(), value.clone()); }
|
||||
HeaderOp::Unset(name) => { response.headers_mut().remove(name); }
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(content_type) = response.headers().get(http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).map(|v| v.to_ascii_lowercase()) else { return; };
|
||||
let mut expiry = None;
|
||||
for scope in scopes {
|
||||
if !scope.expires_active { continue; }
|
||||
let specific = scope.expires_by_type.iter().find(|(mime, _)| content_type.starts_with(mime)).map(|(_, duration)| *duration);
|
||||
expiry = specific.or(scope.expires_default).or(expiry);
|
||||
}
|
||||
if let Some(duration) = expiry {
|
||||
let seconds = duration.as_secs();
|
||||
if let Ok(value) = HeaderValue::from_str(&format!("public, max-age={seconds}")) { response.headers_mut().insert(CACHE_CONTROL, value); }
|
||||
if let Ok(value) = HeaderValue::from_str(&httpdate::fmt_http_date(SystemTime::now() + duration)) { response.headers_mut().insert(EXPIRES, value); }
|
||||
}
|
||||
}
|
||||
|
||||
fn scope_relative_path(scope: &Scope, path: &str) -> String {
|
||||
let prefix = if scope.components.is_empty() { "/".to_owned() } else { format!("/{}/", scope.components.join("/")) };
|
||||
if scope.components.is_empty() { return path.trim_start_matches('/').to_owned(); }
|
||||
if path == prefix.trim_end_matches('/') { return String::new(); }
|
||||
path.strip_prefix(&prefix).unwrap_or(path.trim_start_matches('/')).to_owned()
|
||||
}
|
||||
|
||||
fn expand_condition_input(input: &str, service: &CompiledService, method: &Method, headers: &HeaderMap, path: &str, query: Option<&str>, uri: &Uri, context: &RequestContext, captures: &[String]) -> String {
|
||||
let mut value = input.to_owned();
|
||||
let request_filename = service.root.join(path.trim_start_matches('/')).to_string_lossy().to_string();
|
||||
let pairs = [
|
||||
("%{REQUEST_FILENAME}", request_filename),
|
||||
("%{REQUEST_URI}", path.to_owned()),
|
||||
("%{REQUEST_METHOD}", method.as_str().to_owned()),
|
||||
("%{QUERY_STRING}", query.unwrap_or("").to_owned()),
|
||||
("%{HTTP_HOST}", headers.get(http::header::HOST).and_then(|v| v.to_str().ok()).unwrap_or(&context.server_name).to_owned()),
|
||||
("%{HTTPS}", if context.tls { "on" } else { "off" }.to_owned()),
|
||||
("%{REQUEST_SCHEME}", if context.tls { "https" } else { "http" }.to_owned()),
|
||||
("%{SERVER_NAME}", context.server_name.clone()),
|
||||
("%{SERVER_PORT}", context.server_port.to_string()),
|
||||
("%{REMOTE_ADDR}", context.client_ip.to_string()),
|
||||
("%{HTTP_USER_AGENT}", headers.get(http::header::USER_AGENT).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned()),
|
||||
("%{HTTP_REFERER}", headers.get(http::header::REFERER).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned()),
|
||||
("%{HTTP_COOKIE}", headers.get(http::header::COOKIE).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned()),
|
||||
("%{DOCUMENT_ROOT}", service.root.to_string_lossy().to_string()),
|
||||
("%{THE_REQUEST}", format!("{} {} {}", method, uri.path_and_query().map(|v| v.as_str()).unwrap_or(path), super::request::server_protocol(context.version))),
|
||||
];
|
||||
for (needle, replacement) in pairs { value = value.replace(needle, &replacement); }
|
||||
let mut offset = 0usize;
|
||||
while let Some(start) = value[offset..].find("%{HTTP:") {
|
||||
let start = offset + start;
|
||||
let Some(end_rel) = value[start..].find('}') else { break; };
|
||||
let end = start + end_rel;
|
||||
let name = &value[start + 7..end];
|
||||
let replacement = HeaderName::from_str(name).ok().and_then(|name| headers.get(&name)).and_then(|v| v.to_str().ok()).unwrap_or("").to_owned();
|
||||
value.replace_range(start..=end, &replacement);
|
||||
offset = start + replacement.len();
|
||||
}
|
||||
for index in 1..=9 { value = value.replace(&format!("%{index}"), captures.get(index).map(String::as_str).unwrap_or("")); }
|
||||
value
|
||||
}
|
||||
|
||||
async fn file_test(service: &CompiledService, value: &str, kind: FileTest) -> bool {
|
||||
let path = PathBuf::from(value);
|
||||
let relative = if path.is_absolute() {
|
||||
match path.strip_prefix(&service.root) { Ok(value) => value, Err(_) => return false }
|
||||
} else { path.as_path() };
|
||||
let mut components = Vec::new();
|
||||
for part in relative.components() {
|
||||
match part {
|
||||
std::path::Component::Normal(value) => { let Some(value) = value.to_str() else { return false; }; components.push(value.to_owned()); }
|
||||
std::path::Component::CurDir => {}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
match open_components(service, components).await {
|
||||
Ok(opened) => match kind { FileTest::File => opened.metadata.is_file(), FileTest::Directory => opened.metadata.is_dir(), FileTest::NonEmptyFile => opened.metadata.is_file() && opened.metadata.len() > 0 },
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn condition_matches(condition: &RewriteCondition, service: &CompiledService, method: &Method, headers: &HeaderMap, path: &str, query: Option<&str>, uri: &Uri, context: &RequestContext, prior_captures: &[String]) -> (bool, Vec<String>) {
|
||||
let value = expand_condition_input(&condition.input, service, method, headers, path, query, uri, context, prior_captures);
|
||||
match &condition.matcher {
|
||||
ConditionMatcher::File { kind, negate } => {
|
||||
let matched = file_test(service, &value, *kind).await;
|
||||
(if *negate { !matched } else { matched }, Vec::new())
|
||||
}
|
||||
ConditionMatcher::Exact { value: wanted, negate, insensitive } => {
|
||||
let equal = if *insensitive { value.eq_ignore_ascii_case(wanted) } else { value.as_str() == wanted.as_str() };
|
||||
(if *negate { !equal } else { equal }, Vec::new())
|
||||
}
|
||||
ConditionMatcher::Regex { regex, negate } => {
|
||||
if let Some(captures) = regex.captures(&value) {
|
||||
let values = captures.iter().map(|value| value.map(|v| v.as_str().to_owned()).unwrap_or_default()).collect::<Vec<_>>();
|
||||
(if *negate { false } else { true }, if *negate { Vec::new() } else { values })
|
||||
} else { (if *negate { true } else { false }, Vec::new()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn conditions_match(rule: &RewriteRule, service: &CompiledService, method: &Method, headers: &HeaderMap, path: &str, query: Option<&str>, uri: &Uri, context: &RequestContext) -> (bool, Vec<String>) {
|
||||
let mut i = 0usize;
|
||||
let mut captures = Vec::new();
|
||||
while i < rule.conditions.len() {
|
||||
let mut group_ok = false;
|
||||
loop {
|
||||
let condition = &rule.conditions[i];
|
||||
let (matched, current) = condition_matches(condition, service, method, headers, path, query, uri, context, &captures).await;
|
||||
if matched {
|
||||
group_ok = true;
|
||||
if !current.is_empty() { captures = current; }
|
||||
}
|
||||
let chained = condition.or_next;
|
||||
i += 1;
|
||||
if !chained || i >= rule.conditions.len() { break; }
|
||||
}
|
||||
if !group_ok { return (false, Vec::new()); }
|
||||
}
|
||||
(true, captures)
|
||||
}
|
||||
|
||||
fn replacement_with_captures(rule: &RewriteRule, captures: ®ex::Captures<'_>, condition_captures: &[String]) -> String {
|
||||
let mut replacement = String::new();
|
||||
captures.expand(&rule.replacement, &mut replacement);
|
||||
for index in 1..=9 { replacement = replacement.replace(&format!("%{index}"), condition_captures.get(index).map(String::as_str).unwrap_or("")); }
|
||||
replacement
|
||||
}
|
||||
|
||||
fn scoped_target(scope: &Scope, replacement: &str) -> String {
|
||||
if replacement.starts_with('/') { return replacement.to_owned(); }
|
||||
if scope.components.is_empty() { format!("/{replacement}") } else if replacement.is_empty() { format!("/{}/", scope.components.join("/")) } else { format!("/{}/{}", scope.components.join("/"), replacement) }
|
||||
}
|
||||
|
||||
fn redirect_response(status: StatusCode, location: &str) -> Result<Response> {
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(LOCATION, HeaderValue::from_str(location).context("invalid .htaccess redirect location")?);
|
||||
Ok(response)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
pub async fn apply_rewrites(compiled: Option<&Compiled>, service: &CompiledService, method: &Method, headers: &HeaderMap, uri: &Uri, context: &RequestContext, initial_path: &str, initial_query: Option<&str>) -> Result<RewriteOutcome> {
|
||||
let Some(compiled) = compiled else { return Ok(RewriteOutcome::Internal { path: initial_path.to_owned(), query: initial_query.map(str::to_owned) }); };
|
||||
let mut path = initial_path.to_owned();
|
||||
let mut query = initial_query.map(str::to_owned);
|
||||
let mut exhausted = false;
|
||||
for pass in 0..8 {
|
||||
let scopes = matching_scopes(compiled, &path);
|
||||
let mut changed = false;
|
||||
let mut end = false;
|
||||
'scope: for scope in scopes {
|
||||
for rule in &scope.rewrites {
|
||||
let relative = scope_relative_path(scope, &path);
|
||||
if !rule.pattern.is_match(&relative) { continue; }
|
||||
let (conditions_ok, condition_captures) = conditions_match(rule, service, method, headers, &path, query.as_deref(), uri, context).await;
|
||||
if !conditions_ok { continue; }
|
||||
let Some(captures) = rule.pattern.captures(&relative) else { continue; };
|
||||
if rule.forbidden { return Ok(RewriteOutcome::Forbidden); }
|
||||
let replacement = replacement_with_captures(rule, &captures, &condition_captures);
|
||||
if replacement != "-" {
|
||||
let (replacement_path, replacement_query) = if let Some((target, query_part)) = replacement.split_once('?') { (target, Some(query_part.to_owned())) } else { (replacement.as_str(), None) };
|
||||
let target = if rule.redirect.is_some() && (replacement_path.starts_with("http://") || replacement_path.starts_with("https://")) { replacement_path.to_owned() } else { scoped_target(scope, replacement_path) };
|
||||
let next_query = if rule.qsa {
|
||||
match (replacement_query, query.clone()) {
|
||||
(Some(new), Some(old)) if !new.is_empty() && !old.is_empty() => Some(format!("{new}&{old}")),
|
||||
(Some(new), _) => Some(new),
|
||||
(None, old) => old,
|
||||
}
|
||||
} else { replacement_query.or_else(|| query.clone()) };
|
||||
if let Some(status) = rule.redirect {
|
||||
let mut location = target;
|
||||
if let Some(query) = next_query.filter(|q| !q.is_empty()) { location.push('?'); location.push_str(&query); }
|
||||
return Ok(RewriteOutcome::Redirect(redirect_response(status, &location)?));
|
||||
}
|
||||
ensure!(target.starts_with('/'), ".htaccess internal rewrite must stay on an absolute local path");
|
||||
if target != path || next_query != query { changed = true; path = target; query = next_query; }
|
||||
}
|
||||
if rule.end { end = true; break 'scope; }
|
||||
if rule.last { break 'scope; }
|
||||
}
|
||||
}
|
||||
if end || !changed { break; }
|
||||
if pass == 7 { exhausted = true; }
|
||||
}
|
||||
ensure!(!exhausted, ".htaccess rewrite exceeded the 8-pass safety limit");
|
||||
Ok(RewriteOutcome::Internal { path, query })
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! Static Web Server source integrated with the normal RustProxy vhost/location pipeline.
|
||||
//! Web services are reusable configuration resources. Vhosts and locations only reference them.
|
||||
//!
|
||||
//! Keep this module as the stable public facade. Configuration, filesystem confinement,
|
||||
//! HTTP responses, routing, FastCGI/PHP-FPM and request dispatch live in dedicated files.
|
||||
|
||||
mod config;
|
||||
mod fastcgi;
|
||||
mod filesystem;
|
||||
mod htaccess;
|
||||
mod request;
|
||||
mod response;
|
||||
mod routing;
|
||||
mod security;
|
||||
mod serve;
|
||||
|
||||
pub use config::{
|
||||
CacheRule, CompiledService, Hardening, Php, Precompressed, RewriteRule, Service, SymlinkPolicy,
|
||||
};
|
||||
pub use htaccess::Config as HtaccessConfig;
|
||||
pub use request::RequestContext;
|
||||
pub use serve::serve;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,23 @@
|
||||
use http::Version;
|
||||
use std::net::IpAddr;
|
||||
|
||||
pub struct RequestContext {
|
||||
pub client_ip: IpAddr,
|
||||
pub client_port: Option<u16>,
|
||||
pub server_name: String,
|
||||
pub server_port: u16,
|
||||
pub tls: bool,
|
||||
pub version: Version,
|
||||
}
|
||||
|
||||
|
||||
pub(super) fn server_protocol(version: http::Version) -> &'static str {
|
||||
match version {
|
||||
http::Version::HTTP_09 => "HTTP/0.9",
|
||||
http::Version::HTTP_10 => "HTTP/1.0",
|
||||
http::Version::HTTP_11 => "HTTP/1.1",
|
||||
http::Version::HTTP_2 => "HTTP/2.0",
|
||||
http::Version::HTTP_3 => "HTTP/3.0",
|
||||
_ => "HTTP/1.1",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
include!("response/headers.rs");
|
||||
include!("response/file.rs");
|
||||
@@ -0,0 +1,206 @@
|
||||
pub(super) async fn file_response(
|
||||
service: &CompiledService,
|
||||
opened: Opened,
|
||||
path: &Path,
|
||||
method: &Method,
|
||||
headers: &HeaderMap,
|
||||
content_encoding: Option<&'static str>,
|
||||
) -> Response {
|
||||
let length = opened.metadata.len();
|
||||
let (etag, modified) = metadata_validators(&opened.metadata);
|
||||
let mime = content_type(service, path);
|
||||
if not_modified(headers, etag.as_deref(), modified) {
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = StatusCode::NOT_MODIFIED;
|
||||
add_validators(&mut response, etag.as_deref(), modified);
|
||||
add_cache_control(&mut response, service, path, &mime);
|
||||
add_download_header(&mut response, service, path);
|
||||
if let Some(encoding) = content_encoding {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_ENCODING, HeaderValue::from_static(encoding));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(VARY, HeaderValue::from_static("Accept-Encoding"));
|
||||
}
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
return response;
|
||||
}
|
||||
|
||||
let range_header = headers.get(RANGE).and_then(|value| value.to_str().ok());
|
||||
let range = if range_header.is_some() && if_range_allows(headers, etag.as_deref(), modified) {
|
||||
match range_header.and_then(|value| parse_range(value, length)) {
|
||||
Some(range) => Some(range),
|
||||
None => {
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
|
||||
response.headers_mut().insert(
|
||||
CONTENT_RANGE,
|
||||
HeaderValue::from_str(&format!("bytes */{length}")).unwrap(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_LENGTH, HeaderValue::from_static("0"));
|
||||
add_validators(&mut response, etag.as_deref(), modified);
|
||||
add_cache_control(&mut response, service, path, &mime);
|
||||
add_download_header(&mut response, service, path);
|
||||
if let Some(encoding) = content_encoding {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_ENCODING, HeaderValue::from_static(encoding));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(VARY, HeaderValue::from_static("Accept-Encoding"));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (status, start, body_length) = match range {
|
||||
Some(range) => (
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
range.start,
|
||||
range.end - range.start + 1,
|
||||
),
|
||||
None => (StatusCode::OK, 0, length),
|
||||
};
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(CONTENT_TYPE, mime.clone());
|
||||
response.headers_mut().insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&body_length.to_string()).unwrap(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
|
||||
if let Some(range) = range {
|
||||
response.headers_mut().insert(
|
||||
CONTENT_RANGE,
|
||||
HeaderValue::from_str(&format!("bytes {}-{}/{length}", range.start, range.end)).unwrap(),
|
||||
);
|
||||
}
|
||||
add_validators(&mut response, etag.as_deref(), modified);
|
||||
add_cache_control(&mut response, service, path, &mime);
|
||||
add_download_header(&mut response, service, path);
|
||||
if let Some(encoding) = content_encoding {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_ENCODING, HeaderValue::from_static(encoding));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(VARY, HeaderValue::from_static("Accept-Encoding"));
|
||||
}
|
||||
if method == Method::HEAD || body_length == 0 {
|
||||
return response;
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::from_std(opened.file);
|
||||
if start != 0 {
|
||||
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
|
||||
return status_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Web Server seek failed\n",
|
||||
);
|
||||
}
|
||||
}
|
||||
let stream = ReaderStream::new(file.take(body_length));
|
||||
*response.body_mut() = Body::from_stream(stream);
|
||||
response
|
||||
}
|
||||
|
||||
fn percent_encode_segment(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
for byte in value.as_bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
||||
out.push(*byte as char);
|
||||
} else {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(&mut out, "%{byte:02X}");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn html_escape(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u8> {
|
||||
let title = html_escape(path);
|
||||
let mut html = String::with_capacity(4096 + entries.len() * 180);
|
||||
html.push_str(r#"<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Index of "#);
|
||||
html.push_str(&title);
|
||||
html.push_str(r#" · RustProxy</title><style>
|
||||
:root{color-scheme:dark;--bg:#131615;--panel:#1b1f1d;--panel2:#202622;--line:#323a34;--muted:#a0aaa0;--text:#edf0e9;--accent:#f69b74;--green:#bdd293;--row:#171b19;--mono:ui-monospace,SFMono-Regular,Consolas,monospace;--sans:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 var(--sans)}a{color:inherit}.shell{width:min(1040px,calc(100% - 32px));margin:0 auto;padding:34px 0}.brand{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:18px}.brand-main{display:flex;align-items:center;gap:10px}.brand-name{font:700 24px var(--mono);letter-spacing:-1px}.pill{border:1px solid #576847;color:var(--green);padding:3px 7px;font:9px var(--mono);letter-spacing:.08em}.path{max-width:60%;overflow-wrap:anywhere;color:var(--muted);font:11px/1.45 var(--mono)}.panel{border:1px solid var(--line);background:var(--panel);padding:18px}.heading{display:flex;justify-content:space-between;align-items:flex-end;gap:14px;padding-bottom:14px;border-bottom:1px solid var(--line)}.eyebrow{display:block;color:var(--muted);font:9px var(--mono);letter-spacing:.12em}.heading h1{margin:4px 0 0;font:600 20px/1.25 var(--sans);overflow-wrap:anywhere}.count{color:var(--muted);font:10px var(--mono);white-space:nowrap}.listing{display:grid;margin-top:10px;border:1px solid var(--line)}.entry{display:grid;grid-template-columns:52px minmax(0,1fr) 92px;gap:12px;align-items:center;min-height:44px;padding:8px 12px;text-decoration:none;background:var(--row);border-bottom:1px solid var(--line)}.entry:last-child{border-bottom:0}.entry:hover{background:var(--panel2)}.entry .type{color:var(--green);font:9px var(--mono);letter-spacing:.08em}.entry.file .type{color:var(--accent)}.entry .name{overflow-wrap:anywhere;font:12px/1.45 var(--mono)}.entry .kind{text-align:right;color:var(--muted);font:10px var(--mono)}.parent .name{color:var(--green)}.page-footer{display:flex;justify-content:space-between;gap:15px;padding:24px 0 0;margin-top:24px;border-top:1px solid var(--line);font:9px var(--mono);color:var(--muted);letter-spacing:.5px}.page-footer b{color:var(--accent);font-weight:400;margin-left:9px}
|
||||
@media(prefers-color-scheme:light){:root{color-scheme:light;--bg:#f4f6f2;--panel:#fff;--panel2:#edf1eb;--line:#cbd3c7;--muted:#5e685f;--text:#1d241f;--accent:#b85b38;--green:#5f783f;--row:#f9faf8}}
|
||||
@media(max-width:620px){.shell{width:min(100% - 20px,1040px);padding:20px 0}.brand,.heading,.page-footer{align-items:flex-start;flex-direction:column}.path{max-width:100%}.entry{grid-template-columns:44px minmax(0,1fr)}.entry .kind{display:none}.panel{padding:12px}}
|
||||
</style></head><body><main class="shell"><header class="brand"><div class="brand-main"><span class="brand-name">RustProxy</span><span class="pill">WEB SERVER</span></div><span class="path">"#);
|
||||
html.push_str(&title);
|
||||
html.push_str(r#"</span></header><section class="panel"><div class="heading"><div><span class="eyebrow">DIRECTORY LISTING</span><h1>Index of "#);
|
||||
html.push_str(&title);
|
||||
html.push_str("</h1></div><span class=\"count\">");
|
||||
html.push_str(&format!("{} entries", entries.len()));
|
||||
html.push_str("</span></div><div class=\"listing\">");
|
||||
if path != "/" {
|
||||
html.push_str(r#"<a class="entry parent" href="../"><span class="type">UP</span><span class="name">../</span><span class="kind">Parent</span></a>"#);
|
||||
}
|
||||
for entry in entries {
|
||||
let label = html_escape(&entry.name);
|
||||
let mut href = percent_encode_segment(&entry.name);
|
||||
if entry.directory {
|
||||
href.push('/');
|
||||
}
|
||||
let href = html_escape(&href);
|
||||
let slash = if entry.directory { "/" } else { "" };
|
||||
let class = if entry.directory { "entry directory" } else { "entry file" };
|
||||
let kind = if entry.directory { "Directory" } else { "File" };
|
||||
let type_label = if entry.directory { "DIR" } else { "FILE" };
|
||||
html.push_str(&format!(
|
||||
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
|
||||
));
|
||||
}
|
||||
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.25.1</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.into_bytes()
|
||||
}
|
||||
|
||||
pub(super) fn directory_redirect(uri: &Uri, method: &Method) -> Response {
|
||||
let mut target = uri.path().to_owned();
|
||||
if !target.ends_with('/') {
|
||||
target.push('/');
|
||||
}
|
||||
if let Some(query) = uri.query() {
|
||||
target.push('?');
|
||||
target.push_str(query);
|
||||
}
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = if *method == Method::GET || *method == Method::HEAD {
|
||||
StatusCode::MOVED_PERMANENTLY
|
||||
} else {
|
||||
StatusCode::PERMANENT_REDIRECT
|
||||
};
|
||||
if let Ok(value) = HeaderValue::from_str(&target) {
|
||||
response.headers_mut().insert(LOCATION, value);
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
use super::{
|
||||
config::CompiledCacheMatcher,
|
||||
filesystem::{DirectoryEntry, Opened},
|
||||
htaccess,
|
||||
CompiledService,
|
||||
};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{
|
||||
header::{
|
||||
ACCEPT_RANGES, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING,
|
||||
CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH,
|
||||
IF_RANGE, LAST_MODIFIED, LOCATION, RANGE, VARY,
|
||||
},
|
||||
HeaderMap, HeaderValue, Method, StatusCode, Uri,
|
||||
};
|
||||
use std::{
|
||||
io,
|
||||
path::Path,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
pub(super) fn status_response(status: StatusCode, message: &'static str) -> Response {
|
||||
let mut response = Response::new(Body::from(message));
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn builtin_error_response(status: StatusCode, method: &Method) -> Response {
|
||||
let html = crate::pages::builtin_gateway_error(status)
|
||||
.unwrap_or("<!doctype html><html><body><h1>Error</h1></body></html>");
|
||||
let mut response = Response::new(if *method == Method::HEAD {
|
||||
Body::empty()
|
||||
} else {
|
||||
Body::from(html)
|
||||
});
|
||||
*response.status_mut() = status;
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
if let Ok(length) = HeaderValue::from_str(&html.len().to_string()) {
|
||||
response.headers_mut().insert(CONTENT_LENGTH, length);
|
||||
}
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response.headers_mut().insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn io_response(error: &io::Error) -> Response {
|
||||
#[cfg(unix)]
|
||||
if matches!(error.raw_os_error(), Some(libc::ELOOP)) {
|
||||
return status_response(StatusCode::FORBIDDEN, "RustProxy: Web Server path is not allowed\n");
|
||||
}
|
||||
match error.kind() {
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory => {
|
||||
status_response(StatusCode::NOT_FOUND, "RustProxy: Web Server resource not found\n")
|
||||
}
|
||||
io::ErrorKind::PermissionDenied | io::ErrorKind::InvalidInput => {
|
||||
status_response(StatusCode::FORBIDDEN, "RustProxy: Web Server path is not allowed\n")
|
||||
}
|
||||
_ => status_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Web Server filesystem error\n",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_content_type(path: &Path) -> Option<&'static str> {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
Some(match ext.as_str() {
|
||||
"html" | "htm" => "text/html; charset=utf-8",
|
||||
"css" => "text/css; charset=utf-8",
|
||||
"js" | "mjs" | "cjs" => "text/javascript; charset=utf-8",
|
||||
"txt" | "log" | "md" => "text/plain; charset=utf-8",
|
||||
"csv" => "text/csv; charset=utf-8",
|
||||
"tsv" => "text/tab-separated-values; charset=utf-8",
|
||||
"json" | "map" => "application/json; charset=utf-8",
|
||||
"yaml" | "yml" => "application/yaml; charset=utf-8",
|
||||
"xml" => "application/xml; charset=utf-8",
|
||||
"rss" => "application/rss+xml; charset=utf-8",
|
||||
"atom" => "application/atom+xml; charset=utf-8",
|
||||
"ics" => "text/calendar; charset=utf-8",
|
||||
"vcf" => "text/vcard; charset=utf-8",
|
||||
"pdf" => "application/pdf",
|
||||
"zip" => "application/zip",
|
||||
"7z" => "application/x-7z-compressed",
|
||||
"rar" => "application/vnd.rar",
|
||||
"gz" => "application/gzip",
|
||||
"bz2" => "application/x-bzip2",
|
||||
"xz" => "application/x-xz",
|
||||
"tar" => "application/x-tar",
|
||||
"wasm" => "application/wasm",
|
||||
"webmanifest" => "application/manifest+json; charset=utf-8",
|
||||
"svg" => "image/svg+xml",
|
||||
"png" => "image/png",
|
||||
"apng" => "image/apng",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"gif" => "image/gif",
|
||||
"webp" => "image/webp",
|
||||
"avif" => "image/avif",
|
||||
"heic" => "image/heic",
|
||||
"heif" => "image/heif",
|
||||
"jxl" => "image/jxl",
|
||||
"ico" => "image/x-icon",
|
||||
"bmp" => "image/bmp",
|
||||
"mp3" => "audio/mpeg",
|
||||
"m4a" => "audio/mp4",
|
||||
"ogg" | "oga" => "audio/ogg",
|
||||
"wav" => "audio/wav",
|
||||
"flac" => "audio/flac",
|
||||
"mp4" | "m4v" => "video/mp4",
|
||||
"mov" => "video/quicktime",
|
||||
"mpeg" | "mpg" => "video/mpeg",
|
||||
"avi" => "video/x-msvideo",
|
||||
"webm" => "video/webm",
|
||||
"ogv" => "video/ogg",
|
||||
"woff" => "font/woff",
|
||||
"woff2" => "font/woff2",
|
||||
"ttf" => "font/ttf",
|
||||
"otf" => "font/otf",
|
||||
"eot" => "application/vnd.ms-fontobject",
|
||||
"doc" => "application/msword",
|
||||
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"xls" => "application/vnd.ms-excel",
|
||||
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"ppt" => "application/vnd.ms-powerpoint",
|
||||
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn content_type(service: &CompiledService, path: &Path) -> HeaderValue {
|
||||
if let Some(value) = htaccess::mime_type(service.htaccess.as_ref(), path) {
|
||||
return value;
|
||||
}
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
if let Some(value) = service.mime_types.get(&extension) {
|
||||
return value.clone();
|
||||
}
|
||||
if let Some(value) = builtin_content_type(path) {
|
||||
return HeaderValue::from_static(value);
|
||||
}
|
||||
service.default_mime_type.clone()
|
||||
}
|
||||
|
||||
fn cache_control_for(
|
||||
service: &CompiledService,
|
||||
path: &Path,
|
||||
content_type: &HeaderValue,
|
||||
) -> Option<HeaderValue> {
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let mime = content_type
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
for rule in &service.cache_rules {
|
||||
let matches = match &rule.matcher {
|
||||
CompiledCacheMatcher::Extension(value) => extension == *value,
|
||||
CompiledCacheMatcher::MimePrefix(value) => mime.starts_with(value),
|
||||
};
|
||||
if matches {
|
||||
return Some(rule.cache_control.clone());
|
||||
}
|
||||
}
|
||||
service.cache_control.clone()
|
||||
}
|
||||
|
||||
fn add_cache_control(
|
||||
response: &mut Response,
|
||||
service: &CompiledService,
|
||||
path: &Path,
|
||||
content_type: &HeaderValue,
|
||||
) {
|
||||
if let Some(value) = cache_control_for(service, path, content_type) {
|
||||
response.headers_mut().insert(CACHE_CONTROL, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn add_service_headers(response: &mut Response, service: &CompiledService) {
|
||||
for (name, value) in &service.response_headers {
|
||||
response.headers_mut().insert(name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn download_extension(service: &CompiledService, path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.to_ascii_lowercase())
|
||||
.is_some_and(|value| service.download_extensions.contains(&value))
|
||||
}
|
||||
|
||||
fn add_download_header(response: &mut Response, service: &CompiledService, path: &Path) {
|
||||
if !download_extension(service, path) {
|
||||
return;
|
||||
}
|
||||
let filename = path.file_name().and_then(|value| value.to_str()).unwrap_or("download");
|
||||
let value = format!("attachment; filename*=UTF-8''{}", percent_encode_segment(filename));
|
||||
if let Ok(value) = HeaderValue::from_str(&value) {
|
||||
response.headers_mut().insert(CONTENT_DISPOSITION, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn accepts_content_encoding(headers: &HeaderMap, wanted: &str) -> bool {
|
||||
let Some(value) = headers
|
||||
.get(http::header::ACCEPT_ENCODING)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let mut wildcard = None;
|
||||
for item in value.split(',') {
|
||||
let mut parts = item.trim().split(';');
|
||||
let encoding = parts.next().unwrap_or("").trim();
|
||||
let mut q = 1.0f32;
|
||||
for parameter in parts {
|
||||
if let Some(value) = parameter.trim().strip_prefix("q=") {
|
||||
q = value.parse::<f32>().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
if encoding.eq_ignore_ascii_case(wanted) {
|
||||
return q > 0.0;
|
||||
}
|
||||
if encoding == "*" {
|
||||
wildcard = Some(q > 0.0);
|
||||
}
|
||||
}
|
||||
wildcard.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn metadata_validators(metadata: &std::fs::Metadata) -> (Option<String>, Option<SystemTime>) {
|
||||
let modified = metadata.modified().ok();
|
||||
let etag = modified.and_then(|modified| {
|
||||
modified.duration_since(UNIX_EPOCH).ok().map(|stamp| {
|
||||
format!(
|
||||
"\"{:x}-{:x}-{:x}\"",
|
||||
metadata.len(),
|
||||
stamp.as_secs(),
|
||||
stamp.subsec_nanos()
|
||||
)
|
||||
})
|
||||
});
|
||||
(etag, modified)
|
||||
}
|
||||
|
||||
fn weak_etag_value(value: &str) -> &str {
|
||||
value.trim().strip_prefix("W/").unwrap_or(value.trim())
|
||||
}
|
||||
|
||||
fn if_none_match_matches(value: &HeaderValue, etag: &str) -> bool {
|
||||
value.to_str().ok().is_some_and(|value| {
|
||||
value.split(',').any(|candidate| {
|
||||
let candidate = candidate.trim();
|
||||
candidate == "*" || weak_etag_value(candidate) == weak_etag_value(etag)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn second_precision(value: SystemTime) -> Option<SystemTime> {
|
||||
value
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| UNIX_EPOCH + Duration::from_secs(duration.as_secs()))
|
||||
}
|
||||
|
||||
fn not_modified(headers: &HeaderMap, etag: Option<&str>, modified: Option<SystemTime>) -> bool {
|
||||
if let (Some(etag), Some(value)) = (etag, headers.get(IF_NONE_MATCH)) {
|
||||
return if_none_match_matches(value, etag);
|
||||
}
|
||||
if headers.contains_key(IF_NONE_MATCH) {
|
||||
return false;
|
||||
}
|
||||
let Some(modified) = modified.and_then(second_precision) else {
|
||||
return false;
|
||||
};
|
||||
headers
|
||||
.get(IF_MODIFIED_SINCE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| httpdate::parse_http_date(value).ok())
|
||||
.is_some_and(|since| modified <= since)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct ByteRange {
|
||||
start: u64,
|
||||
end: u64,
|
||||
}
|
||||
|
||||
fn parse_range(value: &str, length: u64) -> Option<ByteRange> {
|
||||
let value = value.strip_prefix("bytes=")?.trim();
|
||||
if value.contains(',') || value.is_empty() || length == 0 {
|
||||
return None;
|
||||
}
|
||||
let (start, end) = value.split_once('-')?;
|
||||
if start.is_empty() {
|
||||
let suffix: u64 = end.parse().ok()?;
|
||||
if suffix == 0 {
|
||||
return None;
|
||||
}
|
||||
let size = suffix.min(length);
|
||||
return Some(ByteRange {
|
||||
start: length - size,
|
||||
end: length - 1,
|
||||
});
|
||||
}
|
||||
let start: u64 = start.parse().ok()?;
|
||||
if start >= length {
|
||||
return None;
|
||||
}
|
||||
let end = if end.is_empty() {
|
||||
length - 1
|
||||
} else {
|
||||
end.parse::<u64>().ok()?.min(length - 1)
|
||||
};
|
||||
(end >= start).then_some(ByteRange { start, end })
|
||||
}
|
||||
|
||||
fn if_range_allows(headers: &HeaderMap, etag: Option<&str>, modified: Option<SystemTime>) -> bool {
|
||||
let Some(value) = headers.get(IF_RANGE).and_then(|value| value.to_str().ok()) else {
|
||||
return true;
|
||||
};
|
||||
let value = value.trim();
|
||||
if value.starts_with('"') || value.starts_with("W/") {
|
||||
return etag.is_some_and(|etag| !value.starts_with("W/") && value == etag);
|
||||
}
|
||||
let Some(modified) = modified.and_then(second_precision) else {
|
||||
return false;
|
||||
};
|
||||
httpdate::parse_http_date(value)
|
||||
.ok()
|
||||
.is_some_and(|date| modified <= date)
|
||||
}
|
||||
|
||||
fn add_validators(
|
||||
response: &mut Response,
|
||||
etag: Option<&str>,
|
||||
modified: Option<SystemTime>,
|
||||
) {
|
||||
if let Some(etag) = etag.and_then(|value| HeaderValue::from_str(value).ok()) {
|
||||
response.headers_mut().insert(ETAG, etag);
|
||||
}
|
||||
if let Some(modified) = modified {
|
||||
if let Ok(value) = HeaderValue::from_str(&httpdate::fmt_http_date(modified)) {
|
||||
response.headers_mut().insert(LAST_MODIFIED, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
use super::{
|
||||
filesystem::{path_components, secure_open_sync, Opened},
|
||||
response::accepts_content_encoding,
|
||||
CompiledService,
|
||||
};
|
||||
use anyhow::{ensure, Result};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode};
|
||||
use std::io;
|
||||
|
||||
pub(super) async fn open_components(service: &CompiledService, components: Vec<String>) -> io::Result<Opened> {
|
||||
let service = service.clone();
|
||||
tokio::task::spawn_blocking(move || secure_open_sync(&service, &components))
|
||||
.await
|
||||
.map_err(|_| io::Error::other("Web Server filesystem task failed"))?
|
||||
}
|
||||
|
||||
fn target_path_and_query(
|
||||
raw: &str,
|
||||
uri_path: &str,
|
||||
query: Option<&str>,
|
||||
) -> Result<(String, Option<String>)> {
|
||||
let query_string = query.unwrap_or("");
|
||||
let expanded = raw
|
||||
.replace("$uri", uri_path)
|
||||
.replace("$query_string", query_string);
|
||||
let (path, query) = match expanded.split_once('?') {
|
||||
Some((path, query)) => (path.to_owned(), Some(query.to_owned())),
|
||||
None => (expanded, query.map(str::to_owned)),
|
||||
};
|
||||
ensure!(path.starts_with('/'), "Web Server internal target must start with /");
|
||||
crate::acl::validate_decoded_routing_path(&path)
|
||||
.map_err(|_| anyhow::anyhow!("invalid Web Server internal target"))?;
|
||||
Ok((path, query))
|
||||
}
|
||||
|
||||
pub(super) enum RewriteOutcome {
|
||||
Internal { path: String, query: Option<String> },
|
||||
Redirect(Response),
|
||||
}
|
||||
|
||||
pub(super) fn apply_rewrites(
|
||||
service: &CompiledService,
|
||||
path: &str,
|
||||
query: Option<&str>,
|
||||
) -> Result<RewriteOutcome> {
|
||||
let mut path = path.to_owned();
|
||||
let mut query = query.map(str::to_owned);
|
||||
for rule in &service.rewrites {
|
||||
if !rule.pattern.is_match(&path) {
|
||||
continue;
|
||||
}
|
||||
let replacement = rule
|
||||
.pattern
|
||||
.replace(&path, rule.replacement.as_str())
|
||||
.to_string();
|
||||
let (next_path, next_query) = target_path_and_query(
|
||||
&replacement,
|
||||
&path,
|
||||
query.as_deref(),
|
||||
)?;
|
||||
if let Some(status) = rule.status {
|
||||
let mut target = next_path;
|
||||
if let Some(query) = next_query.filter(|value| !value.is_empty()) {
|
||||
target.push('?');
|
||||
target.push_str(&query);
|
||||
}
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = status;
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(LOCATION, HeaderValue::from_str(&target)?);
|
||||
return Ok(RewriteOutcome::Redirect(response));
|
||||
}
|
||||
path = next_path;
|
||||
query = next_query;
|
||||
}
|
||||
Ok(RewriteOutcome::Internal { path, query })
|
||||
}
|
||||
|
||||
pub(super) enum TryFilesOutcome {
|
||||
Path { path: String, query: Option<String> },
|
||||
Status(StatusCode),
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_try_files(
|
||||
service: &CompiledService,
|
||||
path: &str,
|
||||
query: Option<&str>,
|
||||
) -> Result<TryFilesOutcome> {
|
||||
if service.try_files.is_empty() {
|
||||
return Ok(TryFilesOutcome::Path {
|
||||
path: path.to_owned(),
|
||||
query: query.map(str::to_owned),
|
||||
});
|
||||
}
|
||||
for (index, raw) in service.try_files.iter().enumerate() {
|
||||
if let Some(status) = raw.strip_prefix('=') {
|
||||
let status = StatusCode::from_u16(status.parse::<u16>()?)?;
|
||||
return Ok(TryFilesOutcome::Status(status));
|
||||
}
|
||||
let (candidate, candidate_query) = target_path_and_query(raw, path, query)?;
|
||||
let last = index + 1 == service.try_files.len();
|
||||
if last {
|
||||
return Ok(TryFilesOutcome::Path {
|
||||
path: candidate,
|
||||
query: candidate_query,
|
||||
});
|
||||
}
|
||||
let components = path_components(&candidate)?;
|
||||
if open_components(service, components).await.is_ok() {
|
||||
return Ok(TryFilesOutcome::Path {
|
||||
path: candidate,
|
||||
query: candidate_query,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(TryFilesOutcome::Status(StatusCode::NOT_FOUND))
|
||||
}
|
||||
|
||||
pub(super) async fn select_precompressed(
|
||||
service: &CompiledService,
|
||||
components: &[String],
|
||||
headers: &HeaderMap,
|
||||
) -> Option<(Opened, &'static str)> {
|
||||
if !service.precompressed.enabled || components.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let candidates = [
|
||||
(service.precompressed.brotli, "br", ".br"),
|
||||
(service.precompressed.gzip, "gzip", ".gz"),
|
||||
];
|
||||
for (enabled, encoding, suffix) in candidates {
|
||||
if !enabled || !accepts_content_encoding(headers, encoding) {
|
||||
continue;
|
||||
}
|
||||
let mut sidecar = components.to_vec();
|
||||
let Some(last) = sidecar.last_mut() else { continue };
|
||||
last.push_str(suffix);
|
||||
if let Ok(opened) = open_components(service, sidecar).await {
|
||||
if opened.metadata.is_file() {
|
||||
return Some((opened, encoding));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
use super::{filesystem::path_components, CompiledService};
|
||||
use axum::response::Response;
|
||||
use http::Method;
|
||||
|
||||
pub(super) fn hardening_denied_path(service: &CompiledService, path: &str) -> bool {
|
||||
if !service.hardening.block_common_attacks {
|
||||
return false;
|
||||
}
|
||||
let lower = path.to_ascii_lowercase();
|
||||
if [
|
||||
"/vendor/phpunit/",
|
||||
"/_ignition/execute-solution",
|
||||
"/server-status",
|
||||
"/server-info",
|
||||
"/cgi-bin/",
|
||||
]
|
||||
.iter()
|
||||
.any(|needle| lower.contains(*needle))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let sensitive_names = [
|
||||
"wp-config.php",
|
||||
"wp-config-sample.php",
|
||||
"configuration.php",
|
||||
"settings.php",
|
||||
"services.yml",
|
||||
"php.ini",
|
||||
"web.config",
|
||||
"composer.json",
|
||||
"composer.lock",
|
||||
"auth.json",
|
||||
"id_rsa",
|
||||
"id_dsa",
|
||||
"id_ed25519",
|
||||
];
|
||||
let backup_suffixes = [
|
||||
".bak", ".backup", ".old", ".orig", ".save", ".swp", ".swo", ".sql",
|
||||
".sqlite", ".sqlite3", ".db",
|
||||
];
|
||||
path_components(path).ok().is_some_and(|components| {
|
||||
components.iter().any(|component| {
|
||||
let component = component.to_ascii_lowercase();
|
||||
(component.starts_with('.') && component != ".well-known")
|
||||
|| component == ".env"
|
||||
|| component.starts_with(".env.")
|
||||
|| sensitive_names.contains(&component.as_str())
|
||||
|| component.ends_with('~')
|
||||
|| backup_suffixes.iter().any(|suffix| component.ends_with(*suffix))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn hardening_denied_method(service: &CompiledService, method: &Method) -> bool {
|
||||
service.hardening.block_common_attacks
|
||||
&& (method == Method::CONNECT || method == Method::TRACE || method.as_str().eq_ignore_ascii_case("TRACK"))
|
||||
}
|
||||
|
||||
pub(super) fn strip_unnecessary_headers(response: &mut Response, service: &CompiledService) {
|
||||
if !service.hardening.strip_unnecessary_headers {
|
||||
return;
|
||||
}
|
||||
for name in [
|
||||
"server",
|
||||
"x-powered-by",
|
||||
"x-aspnet-version",
|
||||
"x-aspnetmvc-version",
|
||||
"x-runtime",
|
||||
"x-generator",
|
||||
"x-drupal-cache",
|
||||
"x-drupal-dynamic-cache",
|
||||
] {
|
||||
response.headers_mut().remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
use super::{
|
||||
fastcgi::{method_not_allowed_response, php_for_path, php_response, protected_script_without_handler},
|
||||
filesystem::{list_directory_sync, path_components},
|
||||
htaccess,
|
||||
response::{
|
||||
add_service_headers, builtin_error_response, directory_listing, directory_redirect,
|
||||
file_response, io_response, status_response,
|
||||
},
|
||||
routing::{
|
||||
apply_rewrites, open_components, resolve_try_files, select_precompressed, RewriteOutcome,
|
||||
TryFilesOutcome,
|
||||
},
|
||||
security::{hardening_denied_method, hardening_denied_path, strip_unnecessary_headers},
|
||||
CompiledService, RequestContext,
|
||||
};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{
|
||||
header::{CACHE_CONTROL, CONTENT_LENGTH, CONTENT_TYPE},
|
||||
HeaderMap, HeaderValue, Method, StatusCode, Uri,
|
||||
};
|
||||
use std::{io, path::PathBuf};
|
||||
|
||||
async fn serve_inner(
|
||||
service: &CompiledService,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
uri: Uri,
|
||||
path: &str,
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
) -> Response {
|
||||
if crate::acl::validate_decoded_routing_path(path).is_err() {
|
||||
return status_response(StatusCode::BAD_REQUEST, "RustProxy: invalid Web Server path\n");
|
||||
}
|
||||
if hardening_denied_method(service, &method) || hardening_denied_path(service, path) {
|
||||
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
||||
}
|
||||
if method != Method::GET && method != Method::HEAD && service.php.is_none() {
|
||||
return method_not_allowed_response();
|
||||
}
|
||||
|
||||
if path_components(path).ok().is_some_and(|components| components.iter().any(|component| htaccess::protected_component(service.htaccess.as_ref(), component)))
|
||||
|| htaccess::denied(service.htaccess.as_ref(), path)
|
||||
{
|
||||
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
||||
}
|
||||
|
||||
let (rewritten_path, rewritten_query) = match apply_rewrites(service, path, uri.query()) {
|
||||
Ok(RewriteOutcome::Redirect(response)) => return response,
|
||||
Ok(RewriteOutcome::Internal { path, query }) => (path, query),
|
||||
Err(_) => {
|
||||
return status_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid Web Server rewrite target\n",
|
||||
)
|
||||
}
|
||||
};
|
||||
let (rewritten_path, rewritten_query) = match htaccess::apply_rewrites(
|
||||
service.htaccess.as_ref(),
|
||||
service,
|
||||
&method,
|
||||
&headers,
|
||||
&uri,
|
||||
&context,
|
||||
&rewritten_path,
|
||||
rewritten_query.as_deref(),
|
||||
).await {
|
||||
Ok(htaccess::RewriteOutcome::Redirect(response)) => return response,
|
||||
Ok(htaccess::RewriteOutcome::Forbidden) => return builtin_error_response(StatusCode::FORBIDDEN, &method),
|
||||
Ok(htaccess::RewriteOutcome::Internal { path, query }) => (path, query),
|
||||
Err(_) => return status_response(StatusCode::BAD_REQUEST, "RustProxy: invalid .htaccess rewrite target\n"),
|
||||
};
|
||||
if crate::acl::validate_decoded_routing_path(&rewritten_path).is_err()
|
||||
|| hardening_denied_path(service, &rewritten_path)
|
||||
|| path_components(&rewritten_path).ok().is_some_and(|components| components.iter().any(|component| htaccess::protected_component(service.htaccess.as_ref(), component)))
|
||||
|| htaccess::denied(service.htaccess.as_ref(), &rewritten_path)
|
||||
{
|
||||
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
||||
}
|
||||
let (effective_path, effective_query) = match resolve_try_files(
|
||||
service,
|
||||
&rewritten_path,
|
||||
rewritten_query.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(TryFilesOutcome::Path { path, query }) => (path, query),
|
||||
Ok(TryFilesOutcome::Status(status)) => return builtin_error_response(status, &method),
|
||||
Err(_) => {
|
||||
return status_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"RustProxy: invalid Web Server try_files target\n",
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let components = match path_components(&effective_path) {
|
||||
Ok(components) => components,
|
||||
Err(error) => return io_response(&error),
|
||||
};
|
||||
if hardening_denied_path(service, &effective_path)
|
||||
|| components.iter().any(|component| htaccess::protected_component(service.htaccess.as_ref(), component))
|
||||
|| htaccess::denied(service.htaccess.as_ref(), &effective_path)
|
||||
{
|
||||
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
||||
}
|
||||
let opened = match open_components(service, components.clone()).await {
|
||||
Ok(opened) => opened,
|
||||
Err(error) => return io_response(&error),
|
||||
};
|
||||
let mut request_body = Some(body);
|
||||
|
||||
if opened.metadata.is_file() {
|
||||
let public_path = PathBuf::from(&effective_path);
|
||||
if let Some(php) = php_for_path(service, &public_path) {
|
||||
return php_response(
|
||||
service,
|
||||
php,
|
||||
method,
|
||||
headers,
|
||||
uri,
|
||||
effective_path,
|
||||
effective_query,
|
||||
request_body.take().unwrap_or_else(Body::empty),
|
||||
context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if protected_script_without_handler(service, &public_path) {
|
||||
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
||||
}
|
||||
if method != Method::GET && method != Method::HEAD {
|
||||
return method_not_allowed_response();
|
||||
}
|
||||
if let Some((compressed, encoding)) =
|
||||
select_precompressed(service, &components, &headers).await
|
||||
{
|
||||
return file_response(
|
||||
service,
|
||||
compressed,
|
||||
&public_path,
|
||||
&method,
|
||||
&headers,
|
||||
Some(encoding),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return file_response(service, opened, &public_path, &method, &headers, None).await;
|
||||
}
|
||||
|
||||
if !uri.path().ends_with('/') {
|
||||
return directory_redirect(&uri, &method);
|
||||
}
|
||||
|
||||
for index in htaccess::index_files(service.htaccess.as_ref(), &components, &service.index) {
|
||||
let mut candidate = components.clone();
|
||||
candidate.push(index.clone());
|
||||
match open_components(service, candidate.clone()).await {
|
||||
Ok(index_opened) if index_opened.metadata.is_file() => {
|
||||
let mut script_path = effective_path.trim_end_matches('/').to_owned();
|
||||
script_path.push('/');
|
||||
script_path.push_str(index);
|
||||
if hardening_denied_path(service, &script_path) {
|
||||
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
||||
}
|
||||
let public_path = PathBuf::from(&script_path);
|
||||
if let Some(php) = php_for_path(service, &public_path) {
|
||||
return php_response(
|
||||
service,
|
||||
php,
|
||||
method,
|
||||
headers,
|
||||
uri,
|
||||
script_path,
|
||||
effective_query,
|
||||
request_body.take().unwrap_or_else(Body::empty),
|
||||
context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if protected_script_without_handler(service, &public_path) {
|
||||
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
||||
}
|
||||
if method != Method::GET && method != Method::HEAD {
|
||||
return method_not_allowed_response();
|
||||
}
|
||||
if let Some((compressed, encoding)) =
|
||||
select_precompressed(service, &candidate, &headers).await
|
||||
{
|
||||
return file_response(
|
||||
service,
|
||||
compressed,
|
||||
&public_path,
|
||||
&method,
|
||||
&headers,
|
||||
Some(encoding),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
return file_response(
|
||||
service,
|
||||
index_opened,
|
||||
&public_path,
|
||||
&method,
|
||||
&headers,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(_) => continue,
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
|
||||
) =>
|
||||
{
|
||||
continue
|
||||
}
|
||||
Err(error) => return io_response(&error),
|
||||
}
|
||||
}
|
||||
|
||||
if method != Method::GET && method != Method::HEAD {
|
||||
return method_not_allowed_response();
|
||||
}
|
||||
if !htaccess::directory_listing(service.htaccess.as_ref(), &components, service.directory_listing) {
|
||||
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
||||
}
|
||||
let service_for_list = service.clone();
|
||||
let components_for_list = components.clone();
|
||||
let entries = match tokio::task::spawn_blocking(move || {
|
||||
list_directory_sync(&service_for_list, &components_for_list)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(entries)) => entries,
|
||||
Ok(Err(error)) => return io_response(&error),
|
||||
Err(_) => {
|
||||
return status_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"RustProxy: Web Server directory task failed\n",
|
||||
)
|
||||
}
|
||||
};
|
||||
let entries: Vec<_> = entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
if htaccess::protected_component(service.htaccess.as_ref(), &entry.name) {
|
||||
return false;
|
||||
}
|
||||
let child_path = if effective_path.ends_with('/') {
|
||||
format!("{effective_path}{}", entry.name)
|
||||
} else {
|
||||
format!("{effective_path}/{}", entry.name)
|
||||
};
|
||||
!hardening_denied_path(service, &child_path)
|
||||
})
|
||||
.collect();
|
||||
let listing = directory_listing(&effective_path, &entries);
|
||||
let mut response = Response::new(if method == Method::HEAD {
|
||||
Body::empty()
|
||||
} else {
|
||||
Body::from(listing.clone())
|
||||
});
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/html; charset=utf-8"),
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&listing.len().to_string()).unwrap(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
response
|
||||
}
|
||||
|
||||
/// Serve a decoded, validated routing path from a compiled Web Service.
|
||||
/// The caller is responsible for normal vhost/location ACL/auth/routing selection;
|
||||
/// this function performs filesystem handling plus optional Web Service routing,
|
||||
/// precompressed delivery and bounded FastCGI/PHP-FPM execution.
|
||||
pub async fn serve(
|
||||
service: &CompiledService,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
uri: Uri,
|
||||
path: &str,
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
) -> Response {
|
||||
let mut response = serve_inner(service, method, headers, uri, path, body, context).await;
|
||||
add_service_headers(&mut response, service);
|
||||
htaccess::apply_response(service.htaccess.as_ref(), &mut response, path);
|
||||
strip_unnecessary_headers(&mut response, service);
|
||||
response
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
include!("tests/part_01.rs");
|
||||
include!("tests/part_02.rs");
|
||||
include!("tests/part_03.rs");
|
||||
@@ -0,0 +1,413 @@
|
||||
use super::*;
|
||||
use super::fastcgi::{
|
||||
fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record, FCGI_END_REQUEST,
|
||||
FCGI_STDIN, FCGI_STDOUT,
|
||||
};
|
||||
use axum::{body::Body, response::Response};
|
||||
use http::{
|
||||
header::{
|
||||
ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LENGTH,
|
||||
CONTENT_RANGE, CONTENT_TYPE, ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, IF_RANGE,
|
||||
LAST_MODIFIED, LOCATION, RANGE, VARY,
|
||||
},
|
||||
HeaderMap, HeaderValue, Method, StatusCode, Uri, Version,
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use std::{collections::BTreeMap, path::Path, time::Duration};
|
||||
use tempfile::TempDir;
|
||||
use tokio::{io::AsyncWriteExt, time::Instant};
|
||||
|
||||
fn service(root: &Path, listing: bool) -> CompiledService {
|
||||
Service {
|
||||
id: "site".into(),
|
||||
name: "Site".into(),
|
||||
document_root: root.to_path_buf(),
|
||||
index: vec!["index.html".into(), "index.htm".into()],
|
||||
directory_listing: listing,
|
||||
..Default::default()
|
||||
}
|
||||
.compile()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn request(method: Method, uri: &str) -> http::Request<()> {
|
||||
http::Request::builder()
|
||||
.method(method)
|
||||
.uri(uri)
|
||||
.body(())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn serve_request(
|
||||
service: &CompiledService,
|
||||
request: &http::Request<()>,
|
||||
path: &str,
|
||||
) -> Response {
|
||||
serve_body_request(service, request, path, Body::empty()).await
|
||||
}
|
||||
|
||||
async fn serve_body_request(
|
||||
service: &CompiledService,
|
||||
request: &http::Request<()>,
|
||||
path: &str,
|
||||
body: Body,
|
||||
) -> Response {
|
||||
serve(
|
||||
service,
|
||||
request.method().clone(),
|
||||
request.headers().clone(),
|
||||
request.uri().clone(),
|
||||
path,
|
||||
body,
|
||||
RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: Some(12345),
|
||||
server_name: "example.test".into(),
|
||||
server_port: 80,
|
||||
tls: false,
|
||||
version: Version::HTTP_11,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_head_index_and_directory_redirect_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("dir")).unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"root").unwrap();
|
||||
std::fs::write(tmp.path().join("dir/index.html"), b"nested").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "root");
|
||||
|
||||
let response = serve_request(&service, &request(Method::HEAD, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CONTENT_LENGTH], "4");
|
||||
assert!(response.into_body().collect().await.unwrap().to_bytes().is_empty());
|
||||
|
||||
let response = serve_request(&service, &request(Method::GET, "/dir?x=1"), "/dir").await;
|
||||
assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
|
||||
assert_eq!(response.headers()[LOCATION], "/dir/?x=1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_files_and_disabled_directory_listing_return_normal_errors() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("empty")).unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
|
||||
let missing = serve_request(&service, &request(Method::GET, "/missing.txt"), "/missing.txt").await;
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
|
||||
let directory = serve_request(&service, &request(Method::GET, "/empty/"), "/empty/").await;
|
||||
assert_eq!(directory.status(), StatusCode::FORBIDDEN);
|
||||
assert_eq!(directory.headers()[CONTENT_TYPE], "text/html; charset=utf-8");
|
||||
let directory_body = directory.into_body().collect().await.unwrap().to_bytes();
|
||||
let directory_body = std::str::from_utf8(&directory_body).unwrap();
|
||||
assert!(directory_body.contains("403"));
|
||||
assert!(!directory_body.contains("directory listing is disabled"));
|
||||
|
||||
let directory_head = serve_request(&service, &request(Method::HEAD, "/empty/"), "/empty/").await;
|
||||
assert_eq!(directory_head.status(), StatusCode::FORBIDDEN);
|
||||
assert!(directory_head.into_body().collect().await.unwrap().to_bytes().is_empty());
|
||||
|
||||
let method = serve_request(&service, &request(Method::POST, "/missing.txt"), "/missing.txt").await;
|
||||
assert_eq!(method.status(), StatusCode::METHOD_NOT_ALLOWED);
|
||||
assert_eq!(method.headers()[ALLOW], "GET, HEAD");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn last_modified_condition_returns_304() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"validator").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await;
|
||||
let last_modified = first.headers()[LAST_MODIFIED].clone();
|
||||
assert_eq!(first.headers()[CONTENT_TYPE], "text/plain; charset=utf-8");
|
||||
assert_eq!(first.headers()[CONTENT_LENGTH], "9");
|
||||
|
||||
let mut conditional = request(Method::GET, "/file.txt");
|
||||
conditional
|
||||
.headers_mut()
|
||||
.insert(IF_MODIFIED_SINCE, last_modified);
|
||||
let response = serve_request(&service, &conditional, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
|
||||
assert!(response.headers().contains_key(LAST_MODIFIED));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validators_and_single_ranges_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let first = serve_request(&service, &request(Method::GET, "/file.txt"), "/file.txt").await;
|
||||
let etag = first.headers()[ETAG].clone();
|
||||
assert_eq!(first.headers()[ACCEPT_RANGES], "bytes");
|
||||
|
||||
let mut conditional = request(Method::GET, "/file.txt");
|
||||
conditional.headers_mut().insert(IF_NONE_MATCH, etag);
|
||||
assert_eq!(
|
||||
serve_request(&service, &conditional, "/file.txt").await.status(),
|
||||
StatusCode::NOT_MODIFIED
|
||||
);
|
||||
|
||||
let mut ranged = request(Method::GET, "/file.txt");
|
||||
ranged.headers_mut().insert(RANGE, "bytes=2-5".parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(response.headers()[CONTENT_RANGE], "bytes 2-5/10");
|
||||
assert_eq!(response.headers()[CONTENT_LENGTH], "4");
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "2345");
|
||||
|
||||
let mut invalid = request(Method::GET, "/file.txt");
|
||||
invalid.headers_mut().insert(RANGE, "bytes=99-100".parse().unwrap());
|
||||
let response = serve_request(&service, &invalid, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
||||
assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10");
|
||||
|
||||
let mut if_range_match = request(Method::GET, "/file.txt");
|
||||
if_range_match.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap());
|
||||
if_range_match.headers_mut().insert(IF_RANGE, first.headers()[ETAG].clone());
|
||||
assert_eq!(serve_request(&service, &if_range_match, "/file.txt").await.status(), StatusCode::PARTIAL_CONTENT);
|
||||
|
||||
let mut if_range_miss = request(Method::GET, "/file.txt");
|
||||
if_range_miss.headers_mut().insert(RANGE, "bytes=0-1".parse().unwrap());
|
||||
if_range_miss.headers_mut().insert(IF_RANGE, "\"different\"".parse().unwrap());
|
||||
let response = serve_request(&service, &if_range_miss, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CONTENT_LENGTH], "10");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_escapes_html_and_encodes_urls() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("a & b.txt"), b"x").unwrap();
|
||||
let service = service(tmp.path(), true);
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("a & b.txt"));
|
||||
assert!(body.contains("a%20%26%20b.txt"));
|
||||
assert!(!body.contains(tmp.path().to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn root_directory_listing_is_repeatable_across_refreshes() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("first.txt"), b"one").unwrap();
|
||||
std::fs::write(tmp.path().join("second.txt"), b"two").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "repeat-listing".into(),
|
||||
name: "Repeat listing".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: Vec::new(),
|
||||
directory_listing: true,
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
for _ in 0..2 {
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("first.txt"));
|
||||
assert!(body.contains("second.txt"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_precedes_listing_and_missing_index_falls_back_to_listing_per_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("with-index")).unwrap();
|
||||
std::fs::create_dir(tmp.path().join("without-index")).unwrap();
|
||||
std::fs::write(tmp.path().join("with-index/index.html"), b"INDEX-WINS").unwrap();
|
||||
std::fs::write(tmp.path().join("with-index/other.txt"), b"hidden by index").unwrap();
|
||||
std::fs::write(tmp.path().join("without-index/listed.txt"), b"listed").unwrap();
|
||||
let service = service(tmp.path(), true);
|
||||
|
||||
let indexed = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/with-index/"),
|
||||
"/with-index/",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(indexed.status(), StatusCode::OK);
|
||||
assert_eq!(
|
||||
indexed.into_body().collect().await.unwrap().to_bytes(),
|
||||
"INDEX-WINS"
|
||||
);
|
||||
|
||||
let listed = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/without-index/"),
|
||||
"/without-index/",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(listed.status(), StatusCode::OK);
|
||||
let body = listed.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("listed.txt"));
|
||||
assert!(body.contains("DIRECTORY LISTING"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mime_overrides_default_mime_and_cache_control_are_applied_to_files() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("asset.foo"), b"foo").unwrap();
|
||||
std::fs::write(tmp.path().join("asset.unknown"), b"unknown").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "mime".into(),
|
||||
name: "MIME".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
mime_types: BTreeMap::from([(".FOO".into(), "application/x-foo".into())]),
|
||||
default_mime_type: "application/x-fallback".into(),
|
||||
cache_control: Some("public, max-age=3600".into()),
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
assert!(configured.mime_types.contains_key("foo"));
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let custom = serve_request(&service, &request(Method::GET, "/asset.foo"), "/asset.foo").await;
|
||||
assert_eq!(custom.headers()[CONTENT_TYPE], "application/x-foo");
|
||||
assert_eq!(custom.headers()[CACHE_CONTROL], "public, max-age=3600");
|
||||
|
||||
let fallback = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/asset.unknown"),
|
||||
"/asset.unknown",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(fallback.headers()[CONTENT_TYPE], "application/x-fallback");
|
||||
assert_eq!(fallback.headers()[CACHE_CONTROL], "public, max-age=3600");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn try_files_rewrites_cache_rules_headers_and_downloads_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"SPA").unwrap();
|
||||
std::fs::write(tmp.path().join("style.css"), b"css").unwrap();
|
||||
std::fs::write(tmp.path().join("archive.zip"), b"zip").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "routing".into(),
|
||||
name: "Routing".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
try_files: vec!["$uri".into(), "/index.html?$query_string".into()],
|
||||
rewrites: vec![
|
||||
RewriteRule {
|
||||
pattern: "^/internal$".into(),
|
||||
replacement: "/style.css".into(),
|
||||
status: None,
|
||||
},
|
||||
RewriteRule {
|
||||
pattern: "^/old/(.*)$".into(),
|
||||
replacement: "/new/$1".into(),
|
||||
status: Some(301),
|
||||
},
|
||||
],
|
||||
cache_control: Some("no-cache".into()),
|
||||
cache_rules: vec![CacheRule {
|
||||
matcher: "ext:css".into(),
|
||||
cache_control: "public, max-age=3600".into(),
|
||||
}],
|
||||
response_headers: BTreeMap::from([("x-static-service".into(), "yes".into())]),
|
||||
download_extensions: vec!["zip".into()],
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let spa = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/missing?x=1"),
|
||||
"/missing",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(spa.status(), StatusCode::OK);
|
||||
assert_eq!(spa.headers()[CACHE_CONTROL], "no-cache");
|
||||
assert_eq!(spa.headers()["x-static-service"], "yes");
|
||||
assert_eq!(spa.into_body().collect().await.unwrap().to_bytes(), "SPA");
|
||||
|
||||
let internal = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/internal"),
|
||||
"/internal",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(internal.status(), StatusCode::OK);
|
||||
assert_eq!(internal.headers()[CONTENT_TYPE], "text/css; charset=utf-8");
|
||||
assert_eq!(internal.headers()[CACHE_CONTROL], "public, max-age=3600");
|
||||
|
||||
let redirect = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/old/path"),
|
||||
"/old/path",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(redirect.status(), StatusCode::MOVED_PERMANENTLY);
|
||||
assert_eq!(redirect.headers()[LOCATION], "/new/path");
|
||||
|
||||
let download = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/archive.zip"),
|
||||
"/archive.zip",
|
||||
)
|
||||
.await;
|
||||
assert!(download.headers()[CONTENT_DISPOSITION]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.starts_with("attachment;"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn precompressed_sidecars_keep_mime_validators_and_range_semantics() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("app.js"), b"ORIGINAL").unwrap();
|
||||
std::fs::write(tmp.path().join("app.js.br"), b"BROTLI").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "compressed".into(),
|
||||
name: "Compressed".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
precompressed: Precompressed {
|
||||
enabled: true,
|
||||
brotli: true,
|
||||
gzip: false,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let mut accepted = request(Method::GET, "/app.js");
|
||||
accepted
|
||||
.headers_mut()
|
||||
.insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap());
|
||||
let response = serve_request(&service, &accepted, "/app.js").await;
|
||||
assert_eq!(response.headers()[CONTENT_ENCODING], "br");
|
||||
assert_eq!(response.headers()[CONTENT_TYPE], "text/javascript; charset=utf-8");
|
||||
assert_eq!(response.headers()[VARY], "Accept-Encoding");
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "BROTLI");
|
||||
|
||||
let mut ranged = request(Method::GET, "/app.js");
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(http::header::ACCEPT_ENCODING, "br".parse().unwrap());
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(RANGE, "bytes=0-2".parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/app.js").await;
|
||||
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(response.headers()[CONTENT_RANGE], "bytes 0-2/6");
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "BRO");
|
||||
|
||||
let plain = serve_request(&service, &request(Method::GET, "/app.js"), "/app.js").await;
|
||||
assert!(!plain.headers().contains_key(CONTENT_ENCODING));
|
||||
assert_eq!(plain.into_body().collect().await.unwrap().to_bytes(), "ORIGINAL");
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
async fn fastcgi_fixture(response_body: &'static [u8]) -> std::net::SocketAddr {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap();
|
||||
if kind == FCGI_STDIN && content.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut stdout = b"Status: 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nX-PHP-FPM: fixture\r\n\r\n".to_vec();
|
||||
stdout.extend_from_slice(response_body);
|
||||
write_fcgi_record(&mut stream, FCGI_STDOUT, &stdout).await.unwrap();
|
||||
write_fcgi_record(&mut stream, FCGI_STDOUT, &[]).await.unwrap();
|
||||
write_fcgi_record(&mut stream, FCGI_END_REQUEST, &[0u8; 8]).await.unwrap();
|
||||
stream.flush().await.unwrap();
|
||||
});
|
||||
address
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'SOURCE';").unwrap();
|
||||
let address = fastcgi_fixture(b"PHP-OK").await;
|
||||
let mut configured = Service {
|
||||
id: "php".into(),
|
||||
name: "PHP".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.php".into()],
|
||||
try_files: vec!["$uri".into(), "/index.php?$query_string".into()],
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
connect_timeout_ms: 500,
|
||||
request_timeout_ms: 5_000,
|
||||
max_concurrent_requests: 4,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_body_request(
|
||||
&service,
|
||||
&request(Method::POST, "/missing?name=test"),
|
||||
"/missing",
|
||||
Body::from("payload"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["x-php-fpm"], "fixture");
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, "PHP-OK");
|
||||
assert!(!std::str::from_utf8(&body).unwrap().contains("SOURCE"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_fpm_executes_configured_directory_index() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'INDEX-SOURCE';").unwrap();
|
||||
let address = fastcgi_fixture(b"PHP-INDEX").await;
|
||||
let mut configured = Service {
|
||||
id: "php-index".into(),
|
||||
name: "PHP index".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.php".into()],
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
connect_timeout_ms: 500,
|
||||
request_timeout_ms: 5_000,
|
||||
max_concurrent_requests: 4,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, "PHP-INDEX");
|
||||
assert!(!String::from_utf8_lossy(&body).contains("INDEX-SOURCE"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_request_body_limit_returns_413_before_fastcgi_connect() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'secret';").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-body-limit".into(),
|
||||
name: "PHP body limit".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: "tcp:127.0.0.1:9".into(),
|
||||
max_request_body_bytes: 5,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let mut req = request(Method::POST, "/index.php");
|
||||
req.headers_mut().insert(CONTENT_LENGTH, HeaderValue::from_static("10"));
|
||||
let response = serve_body_request(&service, &req, "/index.php", Body::from("0123456789")).await;
|
||||
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_fpm_failure_returns_gateway_error_instead_of_php_source() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"SECRET-PHP-SOURCE").unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
let mut configured = Service {
|
||||
id: "php-fail".into(),
|
||||
name: "PHP fail".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
connect_timeout_ms: 100,
|
||||
request_timeout_ms: 500,
|
||||
max_concurrent_requests: 1,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/index.php"),
|
||||
"/index.php",
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(response.status(), StatusCode::BAD_GATEWAY | StatusCode::GATEWAY_TIMEOUT));
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(!String::from_utf8_lossy(&body).contains("SECRET-PHP-SOURCE"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn inside_root_symlink_policy_allows_only_targets_beneath_root() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("inside.txt"), b"inside").unwrap();
|
||||
std::fs::write(outside.path().join("outside.txt"), b"outside").unwrap();
|
||||
symlink(tmp.path().join("inside.txt"), tmp.path().join("inside-link.txt")).unwrap();
|
||||
symlink(outside.path().join("outside.txt"), tmp.path().join("outside-link.txt")).unwrap();
|
||||
let mut configured = Service {
|
||||
id: "links".into(),
|
||||
name: "Links".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
symlink_policy: SymlinkPolicy::InsideRoot,
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let inside = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/inside-link.txt"),
|
||||
"/inside-link.txt",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(inside.status(), StatusCode::OK);
|
||||
assert_eq!(inside.into_body().collect().await.unwrap().to_bytes(), "inside");
|
||||
|
||||
let outside = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/outside-link.txt"),
|
||||
"/outside-link.txt",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(outside.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_index_can_go_directly_to_directory_listing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"x").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "listing-only".into(),
|
||||
name: "Listing only".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: Vec::new(),
|
||||
directory_listing: true,
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("file.txt"));
|
||||
assert!(body.contains("DIRECTORY LISTING"));
|
||||
assert!(body.contains("RustProxy <b>0.25.1</b>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn traversal_and_symlink_escape_are_not_served() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("ok.txt"), b"ok").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
assert_eq!(
|
||||
serve_request(&service, &request(Method::GET, "/../secret"), "/../secret")
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap();
|
||||
symlink(outside.path().join("secret.txt"), tmp.path().join("escape.txt")).unwrap();
|
||||
let response = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/escape.txt"),
|
||||
"/escape.txt",
|
||||
)
|
||||
.await;
|
||||
assert_ne!(response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn htaccess_wordpress_style_front_controller_and_protected_files_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"front-controller").unwrap();
|
||||
std::fs::write(tmp.path().join("existing.txt"), b"existing").unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join(".htaccess"),
|
||||
br#"<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.html [L]
|
||||
</IfModule>
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-rewrite".into(),
|
||||
name: "htaccess rewrite".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let rewritten = serve_request(&service, &request(Method::GET, "/missing"), "/missing").await;
|
||||
assert_eq!(rewritten.status(), StatusCode::OK);
|
||||
assert_eq!(rewritten.into_body().collect().await.unwrap().to_bytes(), "front-controller");
|
||||
|
||||
let existing = serve_request(&service, &request(Method::GET, "/existing.txt"), "/existing.txt").await;
|
||||
assert_eq!(existing.status(), StatusCode::OK);
|
||||
assert_eq!(existing.into_body().collect().await.unwrap().to_bytes(), "existing");
|
||||
|
||||
let protected = serve_request(&service, &request(Method::GET, "/.htaccess"), "/.htaccess").await;
|
||||
assert_eq!(protected.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn htaccess_directory_index_mime_headers_expires_and_nested_scope_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("docs")).unwrap();
|
||||
std::fs::write(tmp.path().join("docs/home.foo"), b"nested").unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("docs/.htaccess"),
|
||||
br#"DirectoryIndex home.foo
|
||||
Options -Indexes
|
||||
AddType text/x-foo .foo
|
||||
Header always set X-Htaccess yes
|
||||
ExpiresActive On
|
||||
ExpiresByType text/x-foo "access plus 1 hour"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-nested".into(),
|
||||
name: "htaccess nested".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: true,
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/docs/"), "/docs/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CONTENT_TYPE], "text/x-foo");
|
||||
assert_eq!(response.headers()["x-htaccess"], "yes");
|
||||
assert_eq!(response.headers()[CACHE_CONTROL], "public, max-age=3600");
|
||||
assert!(response.headers().contains_key(http::header::EXPIRES));
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "nested");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_source_extensions_are_not_served_without_php_fpm() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'secret';").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let response = serve_request(&service, &request(Method::GET, "/index.php"), "/index.php").await;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(!String::from_utf8_lossy(&body).contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htaccess_php_addhandler_requires_php_fpm() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.phtml"), b"secret").unwrap();
|
||||
std::fs::write(tmp.path().join(".htaccess"), b"AddHandler application/x-httpd-php .phtml\n").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-handler".into(),
|
||||
name: "htaccess handler".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
assert!(configured.compile().unwrap_err().to_string().contains("requires PHP-FPM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htaccess_security_sensitive_unsupported_directives_fail_closed() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"secret").unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join(".htaccess"),
|
||||
br#"<FilesMatch "\.php$">
|
||||
SetHandler application/x-httpd-php
|
||||
</FilesMatch>
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-filesmatch".into(),
|
||||
name: "htaccess filesmatch".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, strict: false, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let error = configured.compile().unwrap_err().to_string();
|
||||
assert!(error.contains("Files/FilesMatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htaccess_strict_mode_rejects_unsupported_directives() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"ok").unwrap();
|
||||
std::fs::write(tmp.path().join(".htaccess"), b"ErrorDocument 404 /404.html\n").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-strict".into(),
|
||||
name: "htaccess strict".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, strict: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
assert!(configured.compile().unwrap_err().to_string().contains("ErrorDocument"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn htaccess_configuration_file_cannot_be_a_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(outside.path().join("outside.conf"), b"RewriteEngine On\n").unwrap();
|
||||
symlink(outside.path().join("outside.conf"), tmp.path().join(".htaccess")).unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-symlink".into(),
|
||||
name: "htaccess symlink".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let error = configured.compile().unwrap_err().to_string();
|
||||
assert!(error.contains("must not be a symlink"));
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
#[test]
|
||||
fn fastcgi_never_forwards_proxy_header_as_http_proxy() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "fastcgi-params".into(),
|
||||
name: "FastCGI params".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php { enabled: true, endpoint: "tcp:127.0.0.1:9000".into(), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let php = service.php.as_ref().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("proxy", HeaderValue::from_static("http://attacker.invalid:8080"));
|
||||
headers.insert("user-agent", HeaderValue::from_static("security-test"));
|
||||
let uri: Uri = "/index.php?q=1".parse().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
php,
|
||||
&Method::GET,
|
||||
&headers,
|
||||
&uri,
|
||||
"/index.php",
|
||||
Some("q=1"),
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: Some(12345),
|
||||
server_name: "example.test".into(),
|
||||
server_port: 80,
|
||||
tls: false,
|
||||
version: Version::HTTP_11,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!encoded.windows(b"HTTP_PROXY".len()).any(|window| window == b"HTTP_PROXY"));
|
||||
assert!(encoded.windows(b"HTTP_USER_AGENT".len()).any(|window| window == b"HTTP_USER_AGENT"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_custom_fastcgi_params_and_php_values_are_validated() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-custom".into(),
|
||||
name: "PHP custom".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
pass_authorization: false,
|
||||
custom_params: BTreeMap::from([("app_env".into(), "production".into())]),
|
||||
php_values: BTreeMap::from([("memory_limit".into(), "256M".into())]),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
assert!(configured.php.custom_params.contains_key("APP_ENV"));
|
||||
let service = configured.compile().unwrap();
|
||||
let php = service.php.as_ref().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("authorization", HeaderValue::from_static("Bearer secret"));
|
||||
let uri: Uri = "/index.php".parse().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
php,
|
||||
&Method::GET,
|
||||
&headers,
|
||||
&uri,
|
||||
"/index.php",
|
||||
None,
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: None,
|
||||
server_name: "example.test".into(),
|
||||
server_port: 443,
|
||||
tls: true,
|
||||
version: Version::HTTP_2,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(encoded.windows(b"APP_ENV".len()).any(|window| window == b"APP_ENV"));
|
||||
assert!(encoded.windows(b"production".len()).any(|window| window == b"production"));
|
||||
assert!(encoded.windows(b"PHP_VALUE".len()).any(|window| window == b"PHP_VALUE"));
|
||||
assert!(encoded.windows(b"memory_limit=256M".len()).any(|window| window == b"memory_limit=256M"));
|
||||
assert!(!encoded.windows(b"HTTP_AUTHORIZATION".len()).any(|window| window == b"HTTP_AUTHORIZATION"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_custom_fastcgi_params_cannot_override_core_or_php_admin_values() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for name in ["SCRIPT_FILENAME", "HTTP_HOST", "PHP_VALUE", "PHP_ADMIN_VALUE"] {
|
||||
let mut configured = Service {
|
||||
id: "php-reserved".into(),
|
||||
name: "PHP reserved".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
custom_params: BTreeMap::from([(name.into(), "bad".into())]),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
assert!(configured.validate().unwrap_err().to_string().contains("reserved name"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_custom_fastcgi_data_has_a_combined_bound() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let custom_params = (0..17)
|
||||
.map(|index| (format!("APP_{index}"), "x".repeat(8192)))
|
||||
.collect();
|
||||
let mut configured = Service {
|
||||
id: "php-custom-bound".into(),
|
||||
name: "PHP custom bound".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php { custom_params, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
assert!(configured
|
||||
.validate()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("at most 128 KiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_response_cannot_control_downstream_framing_or_hop_headers() {
|
||||
let (_, headers) = parse_cgi_headers(
|
||||
b"Content-Type: text/plain\r\nContent-Length: 999999\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\nTE: trailers\r\nTrailer: X-Test\r\nProxy-Authenticate: Basic realm=x\r\nProxy-Authorization: secret\r\nX-App: ok\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(headers.get("x-app").unwrap(), "ok");
|
||||
for name in [
|
||||
"content-length",
|
||||
"transfer-encoding",
|
||||
"connection",
|
||||
"te",
|
||||
"trailer",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
] {
|
||||
assert!(!headers.contains_key(name), "unexpected FastCGI framing header: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_response_rejects_informational_status_codes() {
|
||||
let error = parse_cgi_headers(b"Status: 101 Switching Protocols\r\nContent-Type: text/plain\r\n")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("unsupported informational/invalid status"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hardening_blocks_common_probe_paths_and_trace() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"ok").unwrap();
|
||||
std::fs::write(tmp.path().join(".env"), b"secret").unwrap();
|
||||
std::fs::write(tmp.path().join("backup.sql"), b"secret").unwrap();
|
||||
std::fs::write(tmp.path().join("public.txt"), b"public").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "hardened".into(),
|
||||
name: "hardened".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec![],
|
||||
directory_listing: true,
|
||||
hardening: Hardening { block_common_attacks: true, strip_unnecessary_headers: false },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
for path in ["/.env", "/wp-config.php", "/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php", "/backup.sql"] {
|
||||
let response = serve_request(&service, &request(Method::GET, path), path).await;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN, "{path}");
|
||||
}
|
||||
let trace = serve_request(&service, &request(Method::TRACE, "/"), "/").await;
|
||||
assert_eq!(trace.status(), StatusCode::FORBIDDEN);
|
||||
let listing = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
let listing = String::from_utf8_lossy(&listing.into_body().collect().await.unwrap().to_bytes()).to_string();
|
||||
assert!(listing.contains("public.txt"));
|
||||
assert!(!listing.contains(".env"));
|
||||
assert!(!listing.contains("backup.sql"));
|
||||
let well_known = serve_request(&service, &request(Method::GET, "/.well-known/missing"), "/.well-known/missing").await;
|
||||
assert_eq!(well_known.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hardening_strips_fingerprint_headers_after_source_rules() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"ok").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "headers".into(),
|
||||
name: "headers".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
response_headers: BTreeMap::from([
|
||||
("server".into(), "Example/1.0".into()),
|
||||
("x-powered-by".into(), "PHP/8.4".into()),
|
||||
("x-custom".into(), "kept".into()),
|
||||
]),
|
||||
hardening: Hardening { block_common_attacks: false, strip_unnecessary_headers: true },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/index.html"), "/index.html").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(!response.headers().contains_key("server"));
|
||||
assert!(!response.headers().contains_key("x-powered-by"));
|
||||
assert_eq!(response.headers()["x-custom"], "kept");
|
||||
}
|
||||
Reference in new issue
Block a user