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"0.27.3")); } #[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#" RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.html [L] "#, ) .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" SetHandler application/x-httpd-php "#, ) .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")); }