403 lines
14 KiB
Rust
403 lines
14 KiB
Rust
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,
|
|
/// Safely split `/script.php/extra/path` into SCRIPT_FILENAME + PATH_INFO.
|
|
/// Disabled by default because it changes URL-to-script resolution semantics.
|
|
pub path_info: 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>,
|
|
pub php_admin_values: BTreeMap<String, String>,
|
|
}
|
|
|
|
impl Default for Php {
|
|
fn default() -> Self {
|
|
Self {
|
|
enabled: false,
|
|
path_info: 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(),
|
|
php_admin_values: BTreeMap::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reusable PHP-FPM configuration. A Web Service can reference one profile or
|
|
/// keep its local `php` settings. Profiles are part of the revisioned Config.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
#[serde(default, deny_unknown_fields)]
|
|
pub struct PhpFpmProfile {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub php: Php,
|
|
}
|
|
|
|
#[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,
|
|
/// Optional downstream dynamic-compression override. Codecs remain global.
|
|
pub compression: crate::compression::Overrides,
|
|
/// Extensions that should be downloaded as attachments.
|
|
pub download_extensions: Vec<String>,
|
|
/// Symlink handling. The conservative default is `deny`.
|
|
pub symlink_policy: SymlinkPolicy,
|
|
/// Optional reference to a shared FPM profile; `php` stays the standalone
|
|
/// configuration and is preserved when attaching/detaching a profile.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub php_profile_id: Option<String>,
|
|
/// 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(),
|
|
compression: crate::compression::Overrides::default(),
|
|
download_extensions: Vec::new(),
|
|
symlink_policy: SymlinkPolicy::Deny,
|
|
php_profile_id: None,
|
|
php: Php::default(),
|
|
htaccess: HtaccessConfig::default(),
|
|
hardening: Hardening::default(),
|
|
}
|
|
}
|
|
}
|