diff --git a/.config/nextest.toml b/.config/nextest.toml index 090b52119..45a039ea6 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -188,7 +188,7 @@ test-group = 'ecstore-serial-flaky' [profile.e2e-smoke] default-filter = """ package(e2e_test) & ( - test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_source_invalid_date|content_encoding|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload)_test::/) + test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_source_invalid_date|content_encoding|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke)_test::/) | test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/) | test(/^reliant::lifecycle::/) | test(/^reliant::tiering::/) diff --git a/crates/e2e_test/src/console_smoke_test.rs b/crates/e2e_test/src/console_smoke_test.rs new file mode 100644 index 000000000..cca86b7e3 --- /dev/null +++ b/crates/e2e_test/src/console_smoke_test.rs @@ -0,0 +1,191 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Over-the-wire smoke net for the embedded console listener (backlog#1154 +//! peri-4). The console is a second HTTP listener that serves the full S3/admin +//! surface plus the unauthenticated console routes under `/rustfs/console`; +//! before this test its behaviour was only covered by in-process router tests +//! (`rustfs/src/admin/console.rs`), so a misconfiguration that exposed +//! credentials through the public console endpoints or fail-opened the +//! protected surface on the console port had no regression net. +//! +//! Pinned wire contract (real binary, real TCP): +//! * `/rustfs/console/version` and `/rustfs/console/license` answer 200 JSON +//! without authentication, with complete fields and no credential material. +//! * `/rustfs/console/license` exposes only the coarse `licensed` flag. +//! * the console SPA prefix dispatches to the static handler, never to the +//! S3 API (no S3 error XML), whether or not console assets are embedded. +//! * unauthenticated requests to the admin API and the S3 root on the console +//! listener are denied (403 AccessDenied) — the extra listener does not +//! fail-open the protected surface. +//! * a listener with the console disabled (the main S3 port here) does not +//! serve the unauthenticated console endpoints at all. + +use crate::common::{RustFSTestEnvironment, init_logging, local_http_client}; +use serial_test::serial; +use std::error::Error; +use tokio::time::{Duration, sleep}; + +type TestResult = Result<(), Box>; + +/// Polls the console version endpoint until the console listener accepts +/// requests. The harness readiness check only covers the S3 listener; the +/// console listener of the same process may come up moments later. +async fn wait_for_console_ready(console_base: &str) -> Result> { + let client = local_http_client(); + let url = format!("{console_base}/rustfs/console/version"); + let mut last_err = String::new(); + for _ in 0..40 { + match client.get(&url).send().await { + Ok(response) if response.status() == reqwest::StatusCode::OK => return Ok(response), + Ok(response) => last_err = format!("status {}", response.status()), + Err(err) => last_err = err.to_string(), + } + sleep(Duration::from_millis(250)).await; + } + Err(format!("console listener at {console_base} never became ready: {last_err}").into()) +} + +#[tokio::test] +#[serial] +async fn test_console_over_the_wire_smoke() -> TestResult { + init_logging(); + + let mut env = RustFSTestEnvironment::new().await?; + // A unique secret makes the credential-leak assertions below sharp: any + // occurrence of it in a console response body is a genuine leak, not a + // collision with a common default string. + env.access_key = format!("peri4ak{}", uuid::Uuid::new_v4().simple()); + env.secret_key = format!("peri4sk{}", uuid::Uuid::new_v4().simple()); + + let console_port = RustFSTestEnvironment::find_available_port().await?; + let console_address = format!("127.0.0.1:{console_port}"); + let console_base = format!("http://{console_address}"); + env.start_rustfs_server_with_env( + vec![], + &[ + ("RUSTFS_CONSOLE_ENABLE", "true"), + ("RUSTFS_CONSOLE_ADDRESS", console_address.as_str()), + ], + ) + .await?; + + let client = local_http_client(); + + // --- /rustfs/console/version: 200 JSON, complete fields, no secrets ------ + let version_response = wait_for_console_ready(&console_base).await?; + let content_type = version_response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + assert!(content_type.starts_with("application/json"), "version content-type: {content_type}"); + let version_body = version_response.text().await?; + let version_json: serde_json::Value = serde_json::from_str(&version_body)?; + for field in ["version", "version_info", "date"] { + assert!( + version_json[field].as_str().is_some_and(|v| !v.is_empty()), + "console version field {field} missing or empty: {version_body}" + ); + } + assert!( + !version_body.contains(&env.secret_key) && !version_body.contains(&env.access_key), + "console version response leaks credentials: {version_body}" + ); + + // --- /rustfs/console/license: coarse licensed flag only ------------------ + let license_response = client.get(format!("{console_base}/rustfs/console/license")).send().await?; + assert_eq!(license_response.status(), reqwest::StatusCode::OK); + let license_body = license_response.text().await?; + let license_json: serde_json::Value = serde_json::from_str(&license_body)?; + assert!( + license_json["licensed"].is_boolean(), + "license payload must expose a licensed bool: {license_body}" + ); + let license_keys: Vec<&String> = license_json.as_object().map(|o| o.keys().collect()).unwrap_or_default(); + assert_eq!( + license_keys, + vec!["licensed"], + "public license endpoint must not expose license metadata: {license_body}" + ); + assert!( + !license_body.contains(&env.secret_key) && !license_body.contains(&env.access_key), + "console license response leaks credentials: {license_body}" + ); + + // --- console SPA prefix dispatches to the static handler, not the S3 API - + // With release console assets embedded this is 200 text/html; a from-source + // binary embeds an empty static dir and serves the handler's 404 fallback. + // Either way it must never fall through to an S3 handler (S3 error XML). + let spa_response = client.get(format!("{console_base}/rustfs/console/")).send().await?; + let spa_status = spa_response.status(); + let spa_content_type = spa_response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_string(); + let spa_body = spa_response.text().await?; + assert!( + !spa_body.contains(""), + "console SPA route fell through to the S3 API: status {spa_status}, body {spa_body}" + ); + match spa_status { + reqwest::StatusCode::OK => { + assert!( + spa_content_type.starts_with("text/html"), + "embedded console index must be html, got {spa_content_type}" + ); + } + reqwest::StatusCode::NOT_FOUND => { + assert!( + spa_body.contains("RustFS"), + "asset-less console 404 must come from the console static handler: {spa_body}" + ); + } + other => panic!("console SPA route returned unexpected status {other}: {spa_body}"), + } + + // --- protected surface on the console listener stays authenticated ------- + let admin_response = client.get(format!("{console_base}/rustfs/admin/v3/info")).send().await?; + assert_eq!( + admin_response.status(), + reqwest::StatusCode::FORBIDDEN, + "unauthenticated admin API on the console listener must be denied" + ); + let admin_body = admin_response.text().await?; + assert!(admin_body.contains("AccessDenied"), "admin denial must be AccessDenied: {admin_body}"); + + let s3_root_response = client.get(format!("{console_base}/")).send().await?; + assert_eq!( + s3_root_response.status(), + reqwest::StatusCode::FORBIDDEN, + "unauthenticated S3 root on the console listener must be denied" + ); + + // --- console disabled on a listener means no console surface at all ------ + // The main S3 listener of this same process runs with the console disabled, + // so its console paths must not answer with the unauthenticated console + // payloads (they fall through to the authenticated S3 surface instead). + let s3_listener_console = client.get(format!("{}/rustfs/console/version", env.url)).send().await?; + assert_ne!( + s3_listener_console.status(), + reqwest::StatusCode::OK, + "console endpoints must not be served on a console-disabled listener" + ); + + env.stop_server(); + Ok(()) +} diff --git a/crates/e2e_test/src/lib.rs b/crates/e2e_test/src/lib.rs index a8d4d9345..ff0386e3b 100644 --- a/crates/e2e_test/src/lib.rs +++ b/crates/e2e_test/src/lib.rs @@ -203,6 +203,12 @@ mod notification_webhook_test; #[cfg(test)] mod tls_hot_reload_test; +// Console listener over-the-wire smoke (backlog#1154 peri-4): public console +// endpoints answer without credentials or leaks, the SPA prefix never falls +// through to the S3 API, and the protected surface stays authenticated. +#[cfg(test)] +mod console_smoke_test; + // Replication extension end-to-end regression tests #[cfg(test)] mod replication_extension_test;