This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 11:15:58 +02:00
1 parent 87e571c170
commit fa7abc9e53
308 files changed
+49746 -50237

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
include!("config/model.rs");
include!("config/compile.rs");
include!("config/validation.rs");
+427
View File
@@ -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),
}
+378
View File
@@ -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(),
}
}
}
+64
View File
@@ -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>,
}
+2
View File
@@ -0,0 +1,2 @@
include!("fastcgi/protocol.rs");
include!("fastcgi/response.rs");
+364
View File
@@ -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)
}
}
+198
View File
@@ -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))
}
+268
View File
@@ -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)
}
File diff suppressed because it is too large. Load diff
+359
View File
@@ -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)
}
+395
View File
@@ -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: &regex::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)
}
+46
View File
@@ -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 })
}
+25
View File
@@ -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;
+23
View File
@@ -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",
}
}
+2
View File
@@ -0,0 +1,2 @@
include!("response/headers.rs");
include!("response/file.rs");
+206
View File
@@ -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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => 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
}
+369
View File
@@ -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);
}
}
}
+148
View File
@@ -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
}
+76
View File
@@ -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);
}
}
+297
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
include!("tests/part_01.rs");
include!("tests/part_02.rs");
include!("tests/part_03.rs");
+413
View File
@@ -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 &amp; 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");
}
+394
View File
@@ -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"));
}
+211
View File
@@ -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");
}