This commit is contained in:
Mateusz Gruszczyński committed 2026-10-09 14:07:41 +02:00
1 parent dd8c195600
commit 3e33982cd3
20 files changed
+287 -52

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.29.4",
"version": "0.29.5",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
+1 -1
View File
@@ -25,7 +25,7 @@ use tokio::{
const FCGI_VERSION_1: u8 = 1;
const FCGI_BEGIN_REQUEST: u8 = 1;
pub(super) const FCGI_END_REQUEST: u8 = 3;
const FCGI_PARAMS: u8 = 4;
pub(super) const FCGI_PARAMS: u8 = 4;
pub(super) const FCGI_STDIN: u8 = 5;
pub(super) const FCGI_STDOUT: u8 = 6;
const FCGI_STDERR: u8 = 7;
+39
View File
@@ -106,6 +106,38 @@ async fn serve_inner(
{
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,
@@ -140,6 +172,13 @@ async fn serve_inner(
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(
+1
View File
@@ -1,3 +1,4 @@
include!("tests/part_01.rs");
include!("tests/part_02.rs");
include!("tests/part_03.rs");
include!("tests/part_04.rs");
+1 -1
View File
@@ -1,7 +1,7 @@
use super::*;
use super::fastcgi::{
fastcgi_params, parse_cgi_headers, read_fcgi_record, write_fcgi_record, FCGI_END_REQUEST,
FCGI_STDIN, FCGI_STDOUT,
FCGI_PARAMS, FCGI_STDIN, FCGI_STDOUT,
};
use axum::{body::Body, response::Response};
use http::{
+126
View File
@@ -0,0 +1,126 @@
// Decode the actual FastCGI PARAMS records received by a mock PHP-FPM peer.
// This tests the Web Service dispatch and parameter generation together.
fn decode_fastcgi_params(mut bytes: &[u8]) -> BTreeMap<String, String> {
fn read_len(bytes: &mut &[u8]) -> usize {
let prefix = bytes[0];
if prefix & 0x80 == 0 {
*bytes = &bytes[1..];
prefix as usize
} else {
let value = u32::from_be_bytes(bytes[..4].try_into().unwrap()) & 0x7fff_ffff;
*bytes = &bytes[4..];
value as usize
}
}
let mut result = BTreeMap::new();
while !bytes.is_empty() {
let name_len = read_len(&mut bytes);
let value_len = read_len(&mut bytes);
let name = std::str::from_utf8(&bytes[..name_len]).unwrap().to_owned();
let value = std::str::from_utf8(&bytes[name_len..name_len + value_len]).unwrap().to_owned();
bytes = &bytes[name_len + value_len..];
result.insert(name, value);
}
result
}
async fn fastcgi_fixture_record_params() -> (
std::net::SocketAddr,
tokio::sync::oneshot::Receiver<BTreeMap<String, String>>,
) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let (tx, rx) = tokio::sync::oneshot::channel();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
let mut params = Vec::new();
loop {
let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap();
if kind == FCGI_PARAMS {
params.extend_from_slice(&content);
}
if kind == FCGI_STDIN && content.is_empty() {
break;
}
}
let _ = tx.send(decode_fastcgi_params(&params));
write_fcgi_record(&mut stream, FCGI_STDOUT,
b"Status: 200 OK\r\nContent-Type: text/css\r\n\r\nPHP-STATIC-OK").await.unwrap();
write_fcgi_record(&mut stream, FCGI_STDOUT, &[]).await.unwrap();
write_fcgi_record(&mut stream, FCGI_END_REQUEST, &[0u8; 8]).await.unwrap();
stream.flush().await.unwrap();
});
(address, rx)
}
#[tokio::test]
async fn roundcube_static_php_path_info_runs_before_front_controller_try_files() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("static.php"), b"<?php // static asset handler").unwrap();
std::fs::write(tmp.path().join("index.php"), b"<?php // front controller").unwrap();
// This is the Roundcube v1.7 URL layout; /static.php/skins/... is
// virtual PATH_INFO, not a readable directory on disk.
let uri = "/static.php/skins/elastic/images/favicon.ico?s=1783249197";
let (address, received) = fastcgi_fixture_record_params().await;
let mut configured = Service {
id: "roundcube".into(),
name: "Roundcube".into(),
document_root: tmp.path().to_path_buf(),
index: vec!["index.php".into()],
try_files: vec!["$uri".into(), "$uri/".into(), "/index.php?$query_string".into()],
php: Php {
enabled: true,
path_info: true,
endpoint: format!("tcp:{address}"),
..Default::default()
},
..Default::default()
};
configured.validate().unwrap();
let service = configured.compile().unwrap();
let response = serve_request(&service, &request(Method::GET, uri),
"/static.php/skins/elastic/images/favicon.ico").await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[CONTENT_TYPE], "text/css");
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "PHP-STATIC-OK");
let params = received.await.unwrap();
assert_eq!(params["SCRIPT_FILENAME"].as_str(), tmp.path().join("static.php").to_string_lossy().as_ref());
assert_eq!(params["SCRIPT_NAME"], "/static.php");
assert_eq!(params["PATH_INFO"], "/skins/elastic/images/favicon.ico");
assert_eq!(params["QUERY_STRING"], "s=1783249197");
assert_eq!(params["REQUEST_URI"], uri);
}
#[tokio::test]
async fn without_php_path_info_try_files_keeps_legacy_front_controller_fallback() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("static.php"), b"<?php // static asset handler").unwrap();
std::fs::write(tmp.path().join("index.php"), b"<?php // front controller").unwrap();
let (address, received) = fastcgi_fixture_record_params().await;
let mut configured = Service {
id: "roundcube-no-path-info".into(),
name: "Roundcube without PATH_INFO".into(),
document_root: tmp.path().to_path_buf(),
try_files: vec!["$uri".into(), "/index.php?$query_string".into()],
php: Php {
enabled: true,
path_info: false,
endpoint: format!("tcp:{address}"),
..Default::default()
},
..Default::default()
};
configured.validate().unwrap();
let service = configured.compile().unwrap();
let uri = "/static.php/plugins/zipdownload/zipdownload.min.js?s=1783249197";
let response = serve_request(&service, &request(Method::GET, uri),
"/static.php/plugins/zipdownload/zipdownload.min.js").await;
assert_eq!(response.status(), StatusCode::OK);
let _ = response.into_body().collect().await.unwrap().to_bytes();
let params = received.await.unwrap();
assert_eq!(params["SCRIPT_FILENAME"].as_str(), tmp.path().join("index.php").to_string_lossy().as_ref());
assert_eq!(params["SCRIPT_NAME"], "/index.php");
assert!(!params.contains_key("PATH_INFO"));
}