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::() .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 }