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,394 @@
|
||||
async fn fastcgi_fixture(response_body: &'static [u8]) -> std::net::SocketAddr {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let (kind, content) = read_fcgi_record(&mut stream, deadline).await.unwrap();
|
||||
if kind == FCGI_STDIN && content.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut stdout = b"Status: 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nX-PHP-FPM: fixture\r\n\r\n".to_vec();
|
||||
stdout.extend_from_slice(response_body);
|
||||
write_fcgi_record(&mut stream, FCGI_STDOUT, &stdout).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
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_fpm_executes_index_and_try_files_front_controller_without_source_disclosure() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'SOURCE';").unwrap();
|
||||
let address = fastcgi_fixture(b"PHP-OK").await;
|
||||
let mut configured = Service {
|
||||
id: "php".into(),
|
||||
name: "PHP".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.php".into()],
|
||||
try_files: vec!["$uri".into(), "/index.php?$query_string".into()],
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
connect_timeout_ms: 500,
|
||||
request_timeout_ms: 5_000,
|
||||
max_concurrent_requests: 4,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_body_request(
|
||||
&service,
|
||||
&request(Method::POST, "/missing?name=test"),
|
||||
"/missing",
|
||||
Body::from("payload"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["x-php-fpm"], "fixture");
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, "PHP-OK");
|
||||
assert!(!std::str::from_utf8(&body).unwrap().contains("SOURCE"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_fpm_executes_configured_directory_index() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'INDEX-SOURCE';").unwrap();
|
||||
let address = fastcgi_fixture(b"PHP-INDEX").await;
|
||||
let mut configured = Service {
|
||||
id: "php-index".into(),
|
||||
name: "PHP index".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.php".into()],
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
connect_timeout_ms: 500,
|
||||
request_timeout_ms: 5_000,
|
||||
max_concurrent_requests: 4,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, "PHP-INDEX");
|
||||
assert!(!String::from_utf8_lossy(&body).contains("INDEX-SOURCE"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_request_body_limit_returns_413_before_fastcgi_connect() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'secret';").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "php-body-limit".into(),
|
||||
name: "PHP body limit".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: "tcp:127.0.0.1:9".into(),
|
||||
max_request_body_bytes: 5,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let mut req = request(Method::POST, "/index.php");
|
||||
req.headers_mut().insert(CONTENT_LENGTH, HeaderValue::from_static("10"));
|
||||
let response = serve_body_request(&service, &req, "/index.php", Body::from("0123456789")).await;
|
||||
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_fpm_failure_returns_gateway_error_instead_of_php_source() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"SECRET-PHP-SOURCE").unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
drop(listener);
|
||||
let mut configured = Service {
|
||||
id: "php-fail".into(),
|
||||
name: "PHP fail".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php: Php {
|
||||
enabled: true,
|
||||
endpoint: format!("tcp:{address}"),
|
||||
connect_timeout_ms: 100,
|
||||
request_timeout_ms: 500,
|
||||
max_concurrent_requests: 1,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/index.php"),
|
||||
"/index.php",
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(response.status(), StatusCode::BAD_GATEWAY | StatusCode::GATEWAY_TIMEOUT));
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(!String::from_utf8_lossy(&body).contains("SECRET-PHP-SOURCE"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn inside_root_symlink_policy_allows_only_targets_beneath_root() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("inside.txt"), b"inside").unwrap();
|
||||
std::fs::write(outside.path().join("outside.txt"), b"outside").unwrap();
|
||||
symlink(tmp.path().join("inside.txt"), tmp.path().join("inside-link.txt")).unwrap();
|
||||
symlink(outside.path().join("outside.txt"), tmp.path().join("outside-link.txt")).unwrap();
|
||||
let mut configured = Service {
|
||||
id: "links".into(),
|
||||
name: "Links".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
symlink_policy: SymlinkPolicy::InsideRoot,
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let inside = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/inside-link.txt"),
|
||||
"/inside-link.txt",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(inside.status(), StatusCode::OK);
|
||||
assert_eq!(inside.into_body().collect().await.unwrap().to_bytes(), "inside");
|
||||
|
||||
let outside = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/outside-link.txt"),
|
||||
"/outside-link.txt",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(outside.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_index_can_go_directly_to_directory_listing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("file.txt"), b"x").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "listing-only".into(),
|
||||
name: "Listing only".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: Vec::new(),
|
||||
directory_listing: true,
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/"), "/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = std::str::from_utf8(&body).unwrap();
|
||||
assert!(body.contains("file.txt"));
|
||||
assert!(body.contains("DIRECTORY LISTING"));
|
||||
assert!(body.contains("RustProxy <b>0.25.1</b>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn traversal_and_symlink_escape_are_not_served() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("ok.txt"), b"ok").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
assert_eq!(
|
||||
serve_request(&service, &request(Method::GET, "/../secret"), "/../secret")
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::symlink;
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(outside.path().join("secret.txt"), b"secret").unwrap();
|
||||
symlink(outside.path().join("secret.txt"), tmp.path().join("escape.txt")).unwrap();
|
||||
let response = serve_request(
|
||||
&service,
|
||||
&request(Method::GET, "/escape.txt"),
|
||||
"/escape.txt",
|
||||
)
|
||||
.await;
|
||||
assert_ne!(response.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn htaccess_wordpress_style_front_controller_and_protected_files_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"front-controller").unwrap();
|
||||
std::fs::write(tmp.path().join("existing.txt"), b"existing").unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join(".htaccess"),
|
||||
br#"<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule . /index.html [L]
|
||||
</IfModule>
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-rewrite".into(),
|
||||
name: "htaccess rewrite".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
|
||||
let rewritten = serve_request(&service, &request(Method::GET, "/missing"), "/missing").await;
|
||||
assert_eq!(rewritten.status(), StatusCode::OK);
|
||||
assert_eq!(rewritten.into_body().collect().await.unwrap().to_bytes(), "front-controller");
|
||||
|
||||
let existing = serve_request(&service, &request(Method::GET, "/existing.txt"), "/existing.txt").await;
|
||||
assert_eq!(existing.status(), StatusCode::OK);
|
||||
assert_eq!(existing.into_body().collect().await.unwrap().to_bytes(), "existing");
|
||||
|
||||
let protected = serve_request(&service, &request(Method::GET, "/.htaccess"), "/.htaccess").await;
|
||||
assert_eq!(protected.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn htaccess_directory_index_mime_headers_expires_and_nested_scope_work() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("docs")).unwrap();
|
||||
std::fs::write(tmp.path().join("docs/home.foo"), b"nested").unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("docs/.htaccess"),
|
||||
br#"DirectoryIndex home.foo
|
||||
Options -Indexes
|
||||
AddType text/x-foo .foo
|
||||
Header always set X-Htaccess yes
|
||||
ExpiresActive On
|
||||
ExpiresByType text/x-foo "access plus 1 hour"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-nested".into(),
|
||||
name: "htaccess nested".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: true,
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let service = configured.compile().unwrap();
|
||||
let response = serve_request(&service, &request(Method::GET, "/docs/"), "/docs/").await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()[CONTENT_TYPE], "text/x-foo");
|
||||
assert_eq!(response.headers()["x-htaccess"], "yes");
|
||||
assert_eq!(response.headers()[CACHE_CONTROL], "public, max-age=3600");
|
||||
assert!(response.headers().contains_key(http::header::EXPIRES));
|
||||
assert_eq!(response.into_body().collect().await.unwrap().to_bytes(), "nested");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn php_source_extensions_are_not_served_without_php_fpm() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'secret';").unwrap();
|
||||
let service = service(tmp.path(), false);
|
||||
let response = serve_request(&service, &request(Method::GET, "/index.php"), "/index.php").await;
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(!String::from_utf8_lossy(&body).contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htaccess_php_addhandler_requires_php_fpm() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.phtml"), b"secret").unwrap();
|
||||
std::fs::write(tmp.path().join(".htaccess"), b"AddHandler application/x-httpd-php .phtml\n").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-handler".into(),
|
||||
name: "htaccess handler".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
assert!(configured.compile().unwrap_err().to_string().contains("requires PHP-FPM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htaccess_security_sensitive_unsupported_directives_fail_closed() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"secret").unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join(".htaccess"),
|
||||
br#"<FilesMatch "\.php$">
|
||||
SetHandler application/x-httpd-php
|
||||
</FilesMatch>
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-filesmatch".into(),
|
||||
name: "htaccess filesmatch".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, strict: false, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let error = configured.compile().unwrap_err().to_string();
|
||||
assert!(error.contains("Files/FilesMatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn htaccess_strict_mode_rejects_unsupported_directives() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"ok").unwrap();
|
||||
std::fs::write(tmp.path().join(".htaccess"), b"ErrorDocument 404 /404.html\n").unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-strict".into(),
|
||||
name: "htaccess strict".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, strict: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
assert!(configured.compile().unwrap_err().to_string().contains("ErrorDocument"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn htaccess_configuration_file_cannot_be_a_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
std::fs::write(outside.path().join("outside.conf"), b"RewriteEngine On\n").unwrap();
|
||||
symlink(outside.path().join("outside.conf"), tmp.path().join(".htaccess")).unwrap();
|
||||
let mut configured = Service {
|
||||
id: "htaccess-symlink".into(),
|
||||
name: "htaccess symlink".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
htaccess: htaccess::Config { enabled: true, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
configured.validate().unwrap();
|
||||
let error = configured.compile().unwrap_err().to_string();
|
||||
assert!(error.contains("must not be a symlink"));
|
||||
}
|
||||
Reference in new issue
Block a user