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

+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(),
}
}
}