456 lines
17 KiB
Rust
456 lines
17 KiB
Rust
use super::{
|
|
fastcgi::{
|
|
method_not_allowed_response, php_for_path, php_response, protected_script_without_handler,
|
|
},
|
|
filesystem::{list_directory_sync, path_components, FilesystemRoot},
|
|
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 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,
|
|
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);
|
|
}
|
|
// An existing PHP script followed by PATH_INFO is a valid virtual PHP
|
|
// request, even though the full URL is not a filesystem path. Dispatch it
|
|
// before try_files can rewrite /static.php/skins/... to /index.php (as in
|
|
// Roundcube 1.7), while still applying the original path's rewrites and
|
|
// access controls above.
|
|
if let Ok(components) = path_components(&rewritten_path) {
|
|
if let Some((script_path, path_info)) = resolve_php_path_info(service, &components).await {
|
|
let script_components = path_components(&script_path).unwrap_or_default();
|
|
if hardening_denied_path(service, &script_path)
|
|
|| script_components.iter().any(|component| {
|
|
htaccess::protected_component(service.htaccess.as_ref(), component)
|
|
})
|
|
|| htaccess::denied(service.htaccess.as_ref(), &script_path)
|
|
{
|
|
return builtin_error_response(StatusCode::FORBIDDEN, &method);
|
|
}
|
|
if let Some(php) = php_for_path(service, &PathBuf::from(&script_path)) {
|
|
return php_response(
|
|
service,
|
|
php,
|
|
method,
|
|
headers,
|
|
uri,
|
|
script_path,
|
|
Some(path_info),
|
|
rewritten_query,
|
|
body,
|
|
context,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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 script_components = path_components(&script_path).unwrap_or_default();
|
|
if hardening_denied_path(service, &script_path)
|
|
|| script_components.iter().any(|component| {
|
|
htaccess::protected_component(service.htaccess.as_ref(), component)
|
|
})
|
|
|| htaccess::denied(service.htaccess.as_ref(), &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,
|
|
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),
|
|
};
|
|
|
|
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,
|
|
None,
|
|
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,
|
|
None,
|
|
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 root = FilesystemRoot::for_service(service);
|
|
let components_for_list = components.clone();
|
|
let mut entries = match tokio::task::spawn_blocking(move || {
|
|
list_directory_sync(&root, &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",
|
|
)
|
|
}
|
|
};
|
|
// Filtering in place avoids a second Vec allocation for large directories
|
|
// while retaining exactly the same visibility and hardening checks.
|
|
entries.retain(|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)
|
|
});
|
|
let listing = directory_listing(&effective_path, &entries);
|
|
let listing_bytes = listing.len();
|
|
let mut response = Response::new(if method == Method::HEAD {
|
|
Body::empty()
|
|
} else {
|
|
// Move the generated listing into the response. Copying it doubles
|
|
// peak HTML allocation for large directory listings under load.
|
|
Body::from(listing)
|
|
});
|
|
response.headers_mut().insert(
|
|
CONTENT_TYPE,
|
|
HeaderValue::from_static("text/html; charset=utf-8"),
|
|
);
|
|
response.headers_mut().insert(
|
|
CONTENT_LENGTH,
|
|
HeaderValue::from_str(&listing_bytes.to_string()).unwrap(),
|
|
);
|
|
response
|
|
.headers_mut()
|
|
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
|
response
|
|
.extensions_mut()
|
|
.insert(crate::web_server::metrics::ResponseKind::Static);
|
|
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 {
|
|
use std::sync::atomic::Ordering;
|
|
let start = std::time::Instant::now();
|
|
let _in_flight =
|
|
crate::web_server::metrics::HeadersInFlight::new(&service.web_metrics.in_flight_headers);
|
|
let request_method = method.clone();
|
|
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);
|
|
let kind = response
|
|
.extensions()
|
|
.get::<crate::web_server::metrics::ResponseKind>()
|
|
.copied();
|
|
service.web_metrics.record_web(
|
|
response.status(),
|
|
start.elapsed().as_micros().min(u64::MAX as u128) as u64,
|
|
&request_method,
|
|
kind,
|
|
);
|
|
// Count bytes actually yielded by the Web Server body before downstream compression;
|
|
// never infer transferred bytes from Content-Length or count an unpolled response.
|
|
let web = service.web_metrics.clone();
|
|
let php = if matches!(kind, Some(crate::web_server::metrics::ResponseKind::Php)) {
|
|
service.php.as_ref().map(|p| p.metrics.clone())
|
|
} else {
|
|
None
|
|
};
|
|
let body = std::mem::replace(response.body_mut(), Body::empty());
|
|
*response.body_mut() = Body::new(http_body_util::BodyExt::map_frame(body, move |frame| {
|
|
if let Some(data) = frame.data_ref() {
|
|
let size = data.len() as u64;
|
|
web.response_bytes.fetch_add(size, Ordering::Relaxed);
|
|
if let Some(metrics) = php.as_ref() {
|
|
metrics.response_bytes.fetch_add(size, Ordering::Relaxed);
|
|
}
|
|
}
|
|
frame
|
|
}));
|
|
response
|
|
}
|