v0.29.2
This commit is contained in:
1 parent
76cb4acef0
commit
68678e98be
84 files changed
+1519
-325
No files matched your search
@@ -242,11 +242,14 @@ impl Service {
|
||||
);
|
||||
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))?;
|
||||
self.php.php_admin_values = normalize_php_values(std::mem::take(&mut self.php.php_admin_values))?;
|
||||
ensure!(!self.php.php_values.keys().any(|key| self.php.php_admin_values.contains_key(key)), "PHP_VALUE and PHP_ADMIN_VALUE must not define the same directive");
|
||||
let php_custom_bytes = self
|
||||
.php
|
||||
.custom_params
|
||||
.iter()
|
||||
.chain(self.php.php_values.iter())
|
||||
.chain(self.php.php_admin_values.iter())
|
||||
.map(|(name, value)| name.len() + value.len() + 2)
|
||||
.sum::<usize>();
|
||||
ensure!(
|
||||
@@ -350,6 +353,7 @@ impl Service {
|
||||
let php = if self.php.enabled {
|
||||
Some(CompiledPhp {
|
||||
extensions: self.php.extensions.iter().cloned().collect(),
|
||||
path_info: self.php.path_info,
|
||||
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),
|
||||
@@ -360,11 +364,14 @@ impl Service {
|
||||
stderr_log_bytes: self.php.stderr_log_bytes,
|
||||
custom_params: self.php.custom_params.clone(),
|
||||
php_values: self.php.php_values.clone(),
|
||||
php_admin_values: self.php.php_admin_values.clone(),
|
||||
metrics: Arc::new(crate::web_server::metrics::PhpMetrics::default()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(CompiledService {
|
||||
web_metrics: Arc::new(crate::web_server::metrics::PhpMetrics::default()),
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
root,
|
||||
|
||||
@@ -257,6 +257,9 @@ impl Default for Precompressed {
|
||||
#[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`.
|
||||
@@ -282,12 +285,14 @@ pub struct Php {
|
||||
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(),
|
||||
@@ -299,6 +304,7 @@ impl Default for Php {
|
||||
stderr_log_bytes: default_php_stderr_log_bytes(),
|
||||
custom_params: BTreeMap::new(),
|
||||
php_values: BTreeMap::new(),
|
||||
php_admin_values: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,23 +24,27 @@ impl FastCgiEndpoint {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct CompiledPhp {
|
||||
pub(crate) struct CompiledPhp {
|
||||
pub(super) extensions: HashSet<String>,
|
||||
pub(super) path_info: bool,
|
||||
pub(super) endpoint: FastCgiEndpoint,
|
||||
pub(super) connect_timeout: Duration,
|
||||
pub(super) request_timeout: Duration,
|
||||
pub(super) permits: Arc<Semaphore>,
|
||||
pub(crate) 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>,
|
||||
pub(super) php_admin_values: BTreeMap<String, String>,
|
||||
pub(crate) metrics: Arc<crate::web_server::metrics::PhpMetrics>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledService {
|
||||
pub id: String,
|
||||
pub(crate) web_metrics: Arc<crate::web_server::metrics::PhpMetrics>,
|
||||
pub name: String,
|
||||
pub(super) root: PathBuf,
|
||||
pub(super) index: Vec<String>,
|
||||
@@ -57,7 +61,7 @@ pub struct CompiledService {
|
||||
pub(super) download_extensions: HashSet<String>,
|
||||
pub(super) symlink_policy: SymlinkPolicy,
|
||||
pub(super) script_extensions: HashSet<String>,
|
||||
pub(super) php: Option<CompiledPhp>,
|
||||
pub(crate) php: Option<CompiledPhp>,
|
||||
pub(super) htaccess: Option<htaccess::Compiled>,
|
||||
pub(super) hardening: Hardening,
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -123,6 +123,7 @@ pub(super) fn fastcgi_params(
|
||||
headers: &HeaderMap,
|
||||
original_uri: &Uri,
|
||||
script_path: &str,
|
||||
path_info: Option<&str>,
|
||||
query: Option<&str>,
|
||||
context: &RequestContext,
|
||||
) -> Result<Vec<u8>> {
|
||||
@@ -141,6 +142,9 @@ pub(super) fn fastcgi_params(
|
||||
params.insert("REQUEST_METHOD".into(), method.as_str().into());
|
||||
params.insert("SCRIPT_FILENAME".into(), script_filename);
|
||||
params.insert("SCRIPT_NAME".into(), script_path.into());
|
||||
if let Some(path_info) = path_info.filter(|value| !value.is_empty()) {
|
||||
params.insert("PATH_INFO".into(), path_info.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);
|
||||
@@ -159,7 +163,7 @@ pub(super) fn fastcgi_params(
|
||||
if context.tls { "on" } else { "off" }.into(),
|
||||
);
|
||||
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.29.0".into());
|
||||
params.insert("SERVER_SOFTWARE".into(), concat!("RustProxy/", env!("CARGO_PKG_VERSION")).into());
|
||||
params.insert("REDIRECT_STATUS".into(), "200".into());
|
||||
if let Some(value) = headers
|
||||
.get(CONTENT_TYPE)
|
||||
@@ -213,6 +217,10 @@ pub(super) fn fastcgi_params(
|
||||
}
|
||||
params.insert("PHP_VALUE".into(), value);
|
||||
}
|
||||
if !php.php_admin_values.is_empty() {
|
||||
let value = php.php_admin_values.iter().map(|(name, item)| format!("{name}={item}")).collect::<Vec<_>>().join("\n");
|
||||
params.insert("PHP_ADMIN_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);
|
||||
|
||||
@@ -5,6 +5,25 @@ pub(super) async fn php_response(
|
||||
headers: HeaderMap,
|
||||
original_uri: Uri,
|
||||
script_path: String,
|
||||
path_info: Option<String>,
|
||||
query: Option<String>,
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
let response = php_response_inner( service, php, method, headers, original_uri, script_path, path_info, query, body, context).await;
|
||||
php.metrics.record(response.status(), start.elapsed().as_micros().min(u64::MAX as u128) as u64);
|
||||
response
|
||||
}
|
||||
|
||||
async fn php_response_inner(
|
||||
service: &CompiledService,
|
||||
php: &CompiledPhp,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
original_uri: Uri,
|
||||
script_path: String,
|
||||
path_info: Option<String>,
|
||||
query: Option<String>,
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
@@ -45,6 +64,7 @@ pub(super) async fn php_response(
|
||||
&headers,
|
||||
&original_uri,
|
||||
&script_path,
|
||||
path_info.as_deref(),
|
||||
query.as_deref(),
|
||||
&context,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Fixed-cardinality PHP counters. Values are per compiled Web Service snapshot.
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct PhpMetrics {
|
||||
pub(crate) requests: AtomicU64,
|
||||
pub(crate) status_2xx: AtomicU64,
|
||||
pub(crate) status_3xx: AtomicU64,
|
||||
pub(crate) status_4xx: AtomicU64,
|
||||
pub(crate) status_5xx: AtomicU64,
|
||||
pub(crate) response_headers_us: AtomicU64,
|
||||
}
|
||||
|
||||
impl PhpMetrics {
|
||||
pub fn record(&self, status: http::StatusCode, elapsed_us: u64) {
|
||||
self.requests.fetch_add(1, Ordering::Relaxed);
|
||||
let counter = match status.as_u16() / 100 {
|
||||
2 => Some(&self.status_2xx),
|
||||
3 => Some(&self.status_3xx),
|
||||
4 => Some(&self.status_4xx),
|
||||
5 => Some(&self.status_5xx),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(counter) = counter { counter.fetch_add(1, Ordering::Relaxed); }
|
||||
self.response_headers_us.fetch_add(elapsed_us, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod config;
|
||||
mod fastcgi;
|
||||
mod filesystem;
|
||||
mod htaccess;
|
||||
mod metrics;
|
||||
mod request;
|
||||
mod response;
|
||||
mod routing;
|
||||
|
||||
@@ -30,9 +30,9 @@ pub(super) async fn file_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),
|
||||
let ranges = if range_header.is_some() && if_range_allows(headers, etag.as_deref(), modified) {
|
||||
match range_header.and_then(|value| parse_ranges(value, length)) {
|
||||
Some(ranges) => Some(ranges),
|
||||
None => {
|
||||
let mut response = Response::new(Body::empty());
|
||||
*response.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
|
||||
@@ -64,25 +64,49 @@ pub(super) async fn file_response(
|
||||
None
|
||||
};
|
||||
|
||||
let (status, start, body_length) = match range {
|
||||
Some(range) => (
|
||||
StatusCode::PARTIAL_CONTENT,
|
||||
range.start,
|
||||
range.end - range.start + 1,
|
||||
),
|
||||
let multipart = ranges.as_ref().is_some_and(|ranges| ranges.len() > 1);
|
||||
let single_range = ranges.as_ref().and_then(|ranges| (ranges.len() == 1).then_some(ranges[0]));
|
||||
let (status, start, body_length) = match single_range {
|
||||
Some(range) => (StatusCode::PARTIAL_CONTENT, range.start, range.end - range.start + 1),
|
||||
None if multipart => (StatusCode::PARTIAL_CONTENT, 0, 0),
|
||||
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(),
|
||||
);
|
||||
let boundary = multipart.then(|| format!("rustproxy-{:016x}", rand::random::<u64>()));
|
||||
if let Some(boundary) = &boundary {
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_str(&format!("multipart/byteranges; boundary={boundary}")).unwrap(),
|
||||
);
|
||||
let mime_text = mime.to_str().unwrap_or("application/octet-stream");
|
||||
let mut total = 0u64;
|
||||
for range in ranges.as_ref().expect("multipart range set") {
|
||||
let header = format!(
|
||||
"--{boundary}\r\nContent-Type: {mime_text}\r\nContent-Range: bytes {}-{}/{length}\r\n\r\n",
|
||||
range.start, range.end
|
||||
);
|
||||
total = total
|
||||
.saturating_add(header.len() as u64)
|
||||
.saturating_add(range.end - range.start + 1)
|
||||
.saturating_add(2);
|
||||
}
|
||||
total = total.saturating_add(format!("--{boundary}--\r\n").len() as u64);
|
||||
response.headers_mut().insert(
|
||||
CONTENT_LENGTH,
|
||||
HeaderValue::from_str(&total.to_string()).unwrap(),
|
||||
);
|
||||
} else {
|
||||
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 {
|
||||
if let Some(range) = single_range {
|
||||
response.headers_mut().insert(
|
||||
CONTENT_RANGE,
|
||||
HeaderValue::from_str(&format!("bytes {}-{}/{length}", range.start, range.end))
|
||||
@@ -100,11 +124,39 @@ pub(super) async fn file_response(
|
||||
.headers_mut()
|
||||
.insert(VARY, HeaderValue::from_static("Accept-Encoding"));
|
||||
}
|
||||
if method == Method::HEAD || body_length == 0 {
|
||||
if method == Method::HEAD || (!multipart && body_length == 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::from_std(opened.file);
|
||||
if let Some(boundary) = boundary {
|
||||
let ranges = ranges.expect("multipart ranges");
|
||||
let mime_text = mime.to_str().unwrap_or("application/octet-stream").to_owned();
|
||||
let (mut writer, reader) = tokio::io::duplex(64 * 1024);
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
for range in ranges {
|
||||
let header = format!(
|
||||
"--{boundary}\r\nContent-Type: {mime_text}\r\nContent-Range: bytes {}-{}/{length}\r\n\r\n",
|
||||
range.start, range.end
|
||||
);
|
||||
if writer.write_all(header.as_bytes()).await.is_err()
|
||||
|| file.seek(std::io::SeekFrom::Start(range.start)).await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut limited = (&mut file).take(range.end - range.start + 1);
|
||||
if tokio::io::copy(&mut limited, &mut writer).await.is_err()
|
||||
|| writer.write_all(b"\r\n").await.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = writer.write_all(format!("--{boundary}--\r\n").as_bytes()).await;
|
||||
});
|
||||
*response.body_mut() = Body::from_stream(ReaderStream::new(reader));
|
||||
return response;
|
||||
}
|
||||
if start != 0 {
|
||||
if file.seek(std::io::SeekFrom::Start(start)).await.is_err() {
|
||||
return status_response(
|
||||
@@ -185,7 +237,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
|
||||
"<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.29.0</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
|
||||
html.push_str(&format!(r#"</div></section><footer class="page-footer"><span>RustProxy <b>{}</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#, env!("CARGO_PKG_VERSION")));
|
||||
html.into_bytes()
|
||||
}
|
||||
|
||||
|
||||
@@ -308,33 +308,52 @@ struct ByteRange {
|
||||
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;
|
||||
const MAX_BYTE_RANGES: usize = 16;
|
||||
|
||||
fn parse_range_item(value: &str, length: u64) -> Result<Option<ByteRange>, ()> {
|
||||
if length == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let (start, end) = value.split_once('-')?;
|
||||
let (start, end) = value.split_once('-').ok_or(())?;
|
||||
if start.is_empty() {
|
||||
let suffix: u64 = end.parse().ok()?;
|
||||
let suffix: u64 = end.parse().map_err(|_| ())?;
|
||||
if suffix == 0 {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let size = suffix.min(length);
|
||||
return Some(ByteRange {
|
||||
return Ok(Some(ByteRange {
|
||||
start: length - size,
|
||||
end: length - 1,
|
||||
});
|
||||
}));
|
||||
}
|
||||
let start: u64 = start.parse().ok()?;
|
||||
let start: u64 = start.parse().map_err(|_| ())?;
|
||||
if start >= length {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let end = if end.is_empty() {
|
||||
length - 1
|
||||
} else {
|
||||
end.parse::<u64>().ok()?.min(length - 1)
|
||||
end.parse::<u64>().map_err(|_| ())?.min(length - 1)
|
||||
};
|
||||
(end >= start).then_some(ByteRange { start, end })
|
||||
Ok((end >= start).then_some(ByteRange { start, end }))
|
||||
}
|
||||
|
||||
fn parse_ranges(value: &str, length: u64) -> Option<Vec<ByteRange>> {
|
||||
let value = value.strip_prefix("bytes=")?.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut ranges = Vec::new();
|
||||
for (index, item) in value.split(',').enumerate() {
|
||||
if index >= MAX_BYTE_RANGES {
|
||||
return None;
|
||||
}
|
||||
match parse_range_item(item.trim(), length).ok()? {
|
||||
Some(range) => ranges.push(range),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
(!ranges.is_empty()).then_some(ranges)
|
||||
}
|
||||
|
||||
fn if_range_allows(headers: &HeaderMap, etag: Option<&str>, modified: Option<SystemTime>) -> bool {
|
||||
|
||||
@@ -126,6 +126,16 @@ pub(super) async fn select_precompressed(
|
||||
if !service.precompressed.enabled || components.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Multipart byte ranges mix MIME boundaries with representation bytes. Keep
|
||||
// those responses on the identity representation so Content-Encoding never
|
||||
// describes only the individual file fragments.
|
||||
if headers
|
||||
.get("range")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(|value| value.contains(','))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let candidates = [
|
||||
(service.precompressed.brotli, "br", ".br"),
|
||||
(service.precompressed.gzip, "gzip", ".gz"),
|
||||
|
||||
+57
-1
@@ -20,6 +20,35 @@ use http::{
|
||||
};
|
||||
use std::{io, path::PathBuf};
|
||||
|
||||
async fn resolve_php_path_info(
|
||||
service: &CompiledService,
|
||||
components: &[String],
|
||||
) -> Option<(String, String)> {
|
||||
let php = service.php.as_ref().filter(|php| php.path_info)?;
|
||||
if components.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
for script_end in 0..components.len() - 1 {
|
||||
let script_path = format!("/{}", components[..=script_end].join("/"));
|
||||
if !php.extensions.iter().any(|extension| {
|
||||
PathBuf::from(&script_path)
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(extension))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
match open_components(service, components[..=script_end].to_vec()).await {
|
||||
Ok(opened) if opened.metadata.is_file() => {
|
||||
let path_info = format!("/{}", components[script_end + 1..].join("/"));
|
||||
return Some((script_path, path_info));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn serve_inner(
|
||||
service: &CompiledService,
|
||||
method: Method,
|
||||
@@ -104,11 +133,34 @@ async fn serve_inner(
|
||||
{
|
||||
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
||||
}
|
||||
let mut request_body = Some(body);
|
||||
let opened = match open_components(service, components.clone()).await {
|
||||
Ok(opened) => opened,
|
||||
Err(error)
|
||||
if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) =>
|
||||
{
|
||||
if let Some((script_path, path_info)) = resolve_php_path_info(service, &components).await {
|
||||
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,
|
||||
Some(path_info),
|
||||
effective_query,
|
||||
request_body.take().unwrap_or_else(Body::empty),
|
||||
context,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
return io_response(&error);
|
||||
}
|
||||
Err(error) => return io_response(&error),
|
||||
};
|
||||
let mut request_body = Some(body);
|
||||
|
||||
if opened.metadata.is_file() {
|
||||
let public_path = PathBuf::from(&effective_path);
|
||||
@@ -120,6 +172,7 @@ async fn serve_inner(
|
||||
headers,
|
||||
uri,
|
||||
effective_path,
|
||||
None,
|
||||
effective_query,
|
||||
request_body.take().unwrap_or_else(Body::empty),
|
||||
context,
|
||||
@@ -172,6 +225,7 @@ async fn serve_inner(
|
||||
headers,
|
||||
uri,
|
||||
script_path,
|
||||
None,
|
||||
effective_query,
|
||||
request_body.take().unwrap_or_else(Body::empty),
|
||||
context,
|
||||
@@ -289,9 +343,11 @@ pub async fn serve(
|
||||
body: Body,
|
||||
context: RequestContext,
|
||||
) -> Response {
|
||||
let start = std::time::Instant::now();
|
||||
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);
|
||||
service.web_metrics.record(response.status(), start.elapsed().as_micros().min(u64::MAX as u128) as u64);
|
||||
response
|
||||
}
|
||||
@@ -182,6 +182,43 @@ async fn validators_and_single_ranges_work() {
|
||||
assert_eq!(response.headers()[CONTENT_LENGTH], "10");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_byte_ranges_stream_each_requested_part() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let mut ranged = request(Method::GET, "/file.txt");
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(RANGE, "bytes=0-1,6-8".parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
let content_type = response.headers()[CONTENT_TYPE].to_str().unwrap().to_owned();
|
||||
assert!(content_type.starts_with("multipart/byteranges; boundary="));
|
||||
assert!(!response.headers().contains_key(CONTENT_RANGE));
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(body.contains("Content-Range: bytes 0-1/10"));
|
||||
assert!(body.contains("Content-Range: bytes 6-8/10"));
|
||||
assert!(body.contains("\r\n01\r\n"));
|
||||
assert!(body.contains("\r\n678\r\n"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn too_many_byte_ranges_are_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let ranges = std::iter::repeat("0-0").take(17).collect::<Vec<_>>().join(",");
|
||||
let mut ranged = request(Method::GET, "/file.txt");
|
||||
ranged
|
||||
.headers_mut()
|
||||
.insert(RANGE, format!("bytes={ranges}").parse().unwrap());
|
||||
let response = serve_request(&service, &ranged, "/file.txt").await;
|
||||
assert_eq!(response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
||||
assert_eq!(response.headers()[CONTENT_RANGE], "bytes */10");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_escapes_html_and_encodes_urls() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("file.txt"));
|
||||
assert!(body.contains("DIRECTORY LISTING"));
|
||||
assert!(body.contains("RustProxy <b>0.29.0</b>"));
|
||||
assert!(body.contains(&format!("RustProxy <b>{}</b>", env!("CARGO_PKG_VERSION"))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -22,6 +22,7 @@ fn fastcgi_never_forwards_proxy_header_as_http_proxy() {
|
||||
&headers,
|
||||
&uri,
|
||||
"/index.php",
|
||||
None,
|
||||
Some("q=1"),
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
@@ -69,6 +70,7 @@ fn php_custom_fastcgi_params_and_php_values_are_validated() {
|
||||
&uri,
|
||||
"/index.php",
|
||||
None,
|
||||
None,
|
||||
&RequestContext {
|
||||
client_ip: "127.0.0.1".parse().unwrap(),
|
||||
client_port: None,
|
||||
@@ -86,6 +88,49 @@ fn php_custom_fastcgi_params_and_php_values_are_validated() {
|
||||
assert!(!encoded.windows(b"HTTP_AUTHORIZATION".len()).any(|window| window == b"HTTP_AUTHORIZATION"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastcgi_path_info_keeps_script_filename_separate() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-path-info".into(),
|
||||
name: "PHP PATH_INFO".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
path_info: true,
|
||||
endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
assert!(service.php.as_ref().unwrap().path_info);
|
||||
let uri: Uri = "/index.php/foo/bar?q=1".parse().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service,
|
||||
service.php.as_ref().unwrap(),
|
||||
&Method::GET,
|
||||
&HeaderMap::new(),
|
||||
&uri,
|
||||
"/index.php",
|
||||
Some("/foo/bar"),
|
||||
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"PATH_INFO".len()).any(|window| window == b"PATH_INFO"));
|
||||
assert!(encoded.windows(b"/foo/bar".len()).any(|window| window == b"/foo/bar"));
|
||||
assert!(encoded.windows(b"SCRIPT_FILENAME".len()).any(|window| window == b"SCRIPT_FILENAME"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_custom_fastcgi_params_cannot_override_core_or_php_admin_values() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -209,3 +254,34 @@ async fn hardening_strips_fingerprint_headers_after_source_rules() {
|
||||
assert!(!response.headers().contains_key("x-powered-by"));
|
||||
assert_eq!(response.headers()["x-custom"], "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_admin_values_are_operator_only_and_serialized() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-admin".into(), name: "PHP admin".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true, endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
php_admin_values: BTreeMap::from([("open_basedir".into(), "/var/www/webmail/:/tmp/".into())]),
|
||||
..Default::default()
|
||||
}, ..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let encoded = fastcgi_params(
|
||||
&service, service.php.as_ref().unwrap(), &Method::GET, &HeaderMap::new(),
|
||||
&"/index.php".parse().unwrap(), "/index.php", None, None,
|
||||
&RequestContext { client_ip: "127.0.0.1".parse().unwrap(), client_port: None,
|
||||
server_name: "webmail.example.test".into(), server_port: 8589, tls: false,
|
||||
version: Version::HTTP_11 },
|
||||
).unwrap();
|
||||
assert!(encoded.windows(b"PHP_ADMIN_VALUE".len()).any(|w| w == b"PHP_ADMIN_VALUE"));
|
||||
assert!(encoded.windows(b"open_basedir=/var/www/webmail/:/tmp/".len())
|
||||
.any(|w| w == b"open_basedir=/var/www/webmail/:/tmp/"));
|
||||
configured.php.php_values.insert("open_basedir".into(), "/".into());
|
||||
assert!(configured.validate().is_err());
|
||||
configured.php.php_values.clear();
|
||||
configured.php.php_admin_values.insert("unsafe".into(), "value\nother=1".into());
|
||||
assert!(configured.validate().is_err());
|
||||
}
|
||||
Reference in new issue
Block a user