v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -0,0 +1,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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user