// 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. use crate::plugin::PluginEvent; use crate::{ StoreError, Target, arn::TargetID, error::TargetError, runtime::tls::{ ReloadableTargetTls, TargetTlsInputSet, TlsReloadAdapter, config::ReloadApplyMode, fingerprint::TargetTlsGeneration, validate::validate_tls_material, }, store::{Key, Store}, target::{ ChannelTargetType, EntityTarget, QueuedPayload, QueuedPayloadMeta, TargetDeliveryCounters, TargetDeliverySnapshot, TargetHealth, TargetHealthReason, TargetHealthState, TargetTlsState, TargetType, build_queued_payload, build_target_tls_fingerprint, open_target_queue_store, persist_queued_payload_to_store, redacted_secret, }, }; use async_trait::async_trait; use parking_lot::Mutex; use reqwest::{Client, StatusCode, Url}; use rustfs_tls_runtime::load_cert_bundle_der_bytes; use rustfs_utils::egress::OutboundPolicy; use std::{ error::Error as StdError, fmt, marker::PhantomData, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, time::Duration, }; use tokio::sync::mpsc; use tracing::{debug, error, info, instrument, warn}; const LOG_COMPONENT_TARGETS: &str = "targets"; const LOG_SUBSYSTEM_WEBHOOK: &str = "webhook"; const EVENT_WEBHOOK_TARGET_STATE: &str = "webhook_target_state"; const EVENT_WEBHOOK_DELIVERY_STATE: &str = "webhook_delivery_state"; const WEBHOOK_HEALTH_TIMEOUT: Duration = Duration::from_secs(5); fn classify_probe_error(err: &reqwest::Error) -> TargetHealthReason { if err.is_timeout() { return TargetHealthReason::TimedOut; } let mut source = err.source(); while let Some(cause) = source { if cause.downcast_ref::().is_some() { return TargetHealthReason::TlsFailure; } if let Some(io_error) = cause.downcast_ref::() { match io_error.kind() { std::io::ErrorKind::ConnectionRefused => return TargetHealthReason::ConnectionRefused, std::io::ErrorKind::NotFound | std::io::ErrorKind::AddrNotAvailable => { return TargetHealthReason::DnsFailure; } _ => {} } } let label = cause.to_string().to_ascii_lowercase(); if label.contains("dns error") || label.contains("failed to lookup") || label.contains("name or service not known") { return TargetHealthReason::DnsFailure; } if label.contains("certificate") || label.contains("tls") { return TargetHealthReason::TlsFailure; } source = cause.source(); } TargetHealthReason::Unreachable } #[hotpath::measure] async fn probe_health_url(client: &Client, health_check_url: &Url) -> TargetHealth { match tokio::time::timeout(WEBHOOK_HEALTH_TIMEOUT, client.head(health_check_url.as_str()).send()).await { Ok(Ok(_)) => TargetHealth::online(TargetHealthReason::Reachable), Ok(Err(err)) => TargetHealth::error(classify_probe_error(&err)), Err(_) => TargetHealth::error(TargetHealthReason::TimedOut), } } fn classify_delivery_status(status: StatusCode) -> Result<(), TargetError> { if status.is_success() { Ok(()) } else if status.is_redirection() { Err(TargetError::Request(format!( "Webhook endpoint returned redirect '{}'; redirects are not followed for webhook delivery", status ))) } else if status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED { Err(TargetError::Authentication(format!( "Webhook endpoint returned '{}', please check if your auth token is correctly set", status ))) } else { Err(TargetError::Request(format!( "Webhook endpoint returned '{}', please check your endpoint configuration", status ))) } } /// Arguments for configuring a Webhook target #[derive(Clone)] pub struct WebhookArgs { /// Whether the target is enabled pub enable: bool, /// The endpoint URL to send events to pub endpoint: Url, /// The authorization token for the endpoint pub auth_token: String, /// The directory to store events in case of failure pub queue_dir: String, /// The maximum number of events to store pub queue_limit: u64, /// The client certificate for TLS (PEM format) pub client_cert: String, /// The client key for TLS (PEM format) pub client_key: String, /// The path to a custom client root CA certificate file (PEM format) to trust the server. pub client_ca: String, /// Skip TLS certificate verification. DANGEROUS: for testing only. pub skip_tls_verify: bool, /// the target type pub target_type: TargetType, } impl fmt::Debug for WebhookArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("WebhookArgs") .field("enable", &self.enable) .field("endpoint_origin", &self.endpoint.origin().ascii_serialization()) .field("auth_token", &redacted_secret(&self.auth_token)) .field("queue_dir", &self.queue_dir) .field("queue_limit", &self.queue_limit) .field("client_cert", &self.client_cert) .field("client_key", &redacted_secret(&self.client_key)) .field("client_ca", &self.client_ca) .field("skip_tls_verify", &self.skip_tls_verify) .field("target_type", &self.target_type) .finish() } } impl WebhookArgs { /// WebhookArgs verification method pub fn validate(&self) -> Result<(), TargetError> { if !self.enable { return Ok(()); } if self.endpoint.as_str().is_empty() { return Err(TargetError::Configuration("endpoint empty".to_string())); } outbound_policy()? .validate_url(&self.endpoint) .map_err(|err| TargetError::Configuration(format!("webhook endpoint is not allowed: {err}")))?; if !self.queue_dir.is_empty() { let path = std::path::Path::new(&self.queue_dir); if !path.is_absolute() { return Err(TargetError::Configuration("webhook queue_dir path should be absolute".to_string())); } } if !self.client_cert.is_empty() && self.client_key.is_empty() || self.client_cert.is_empty() && !self.client_key.is_empty() { return Err(TargetError::Configuration("cert and key must be specified as a pair".to_string())); } if self.skip_tls_verify && !self.client_ca.is_empty() { return Err(TargetError::Configuration( "skip_tls_verify and client_ca are mutually exclusive; remove client_ca or disable skip_tls_verify".to_string(), )); } Ok(()) } } /// A target that sends events to a webhook pub struct WebhookTarget where E: PluginEvent, { id: TargetID, args: WebhookArgs, health_check_url: Option, http_client: Arc>, tls_state: Arc>, /// When present, the adapter provides coordinator-managed TLS material; /// otherwise the inline fingerprint path is used as a fallback. tls_adapter: Option>, // Add Send + Sync constraints to ensure thread safety store: Option + Send + Sync>>, initialized: AtomicBool, cancel_sender: mpsc::Sender<()>, delivery_counters: Arc, _phantom: PhantomData, } impl WebhookTarget where E: PluginEvent, { /// Clones the WebhookTarget, creating a new instance with the same configuration pub fn clone_box(&self) -> Box + Send + Sync> { Box::new(WebhookTarget:: { id: self.id.clone(), args: self.args.clone(), health_check_url: self.health_check_url.clone(), http_client: Arc::clone(&self.http_client), tls_state: Arc::clone(&self.tls_state), tls_adapter: self.tls_adapter.clone(), store: self.store.as_ref().map(|s| s.boxed_clone()), initialized: AtomicBool::new(self.initialized.load(Ordering::SeqCst)), cancel_sender: self.cancel_sender.clone(), delivery_counters: Arc::clone(&self.delivery_counters), _phantom: PhantomData, }) } /// Creates a new WebhookTarget #[instrument(skip(args), fields(target_id = %id))] pub fn new(id: String, args: WebhookArgs) -> Result { // First verify the parameters args.validate()?; // Create a TargetID let target_id = TargetID::new(id, ChannelTargetType::Webhook.as_str().to_string()); let health_check_url = if args.enable { Some(Self::health_check_url(&args.endpoint)?) } else { None }; let http_client = if args.enable { Self::build_http_client(&args)? } else { Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()) .build() .map_err(|e| TargetError::Configuration(format!("Failed to build disabled webhook HTTP client: {e}")))? }; let http_client = Arc::new(Mutex::new(http_client)); let queue_store = open_target_queue_store( &args.queue_dir, args.queue_limit, args.target_type, ChannelTargetType::Webhook.as_str(), &target_id, "Failed to open store for Webhook target", )?; // Create a cancel channel let (cancel_sender, _) = mpsc::channel(1); info!( event = EVENT_WEBHOOK_TARGET_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %target_id.id, state = "created", "webhook target state" ); Ok(WebhookTarget:: { id: target_id, args, health_check_url, http_client, tls_state: Arc::new(Mutex::new(TargetTlsState::default())), tls_adapter: None, store: queue_store, initialized: AtomicBool::new(false), cancel_sender, delivery_counters: Arc::new(TargetDeliveryCounters::default()), _phantom: PhantomData, }) } fn build_http_client(args: &WebhookArgs) -> Result { let resolver = outbound_policy()? .resolver_for(&args.endpoint) .map_err(|err| TargetError::Configuration(format!("webhook endpoint is not allowed: {err}")))?; Self::build_http_client_with_resolver(args, resolver) } fn build_http_client_with_resolver( args: &WebhookArgs, resolver: impl reqwest::dns::Resolve + 'static, ) -> Result { let mut client_builder = Client::builder() .no_proxy() .dns_resolver(resolver) .timeout(Duration::from_secs(30)) // SSRF hardening (backlog#974): never follow HTTP redirects on webhook delivery. // reqwest follows up to 10 redirects by default, which lets a malicious or // compromised endpoint use a 3xx response to bounce the outbound request to an // internal address (e.g. the cloud metadata service at 169.254.169.254), // bypassing the outbound-endpoint validation performed on the configured URL. .redirect(reqwest::redirect::Policy::none()) .user_agent(crate::get_user_agent(crate::ServiceType::Basis)); // 1. Configure server certificate verification if args.skip_tls_verify { // DANGEROUS: For testing only, skip all certificate verification client_builder = client_builder.danger_accept_invalid_certs(true); warn!( event = EVENT_WEBHOOK_TARGET_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, endpoint_origin = %args.endpoint.origin().ascii_serialization(), state = "tls_verification_skipped", fallback = "danger_accept_invalid_certs", "webhook target state" ); } else if !args.client_ca.is_empty() { // Use user-provided custom CA certificate let certs_der = load_cert_bundle_der_bytes(&args.client_ca) .map_err(|e| TargetError::Configuration(format!("Failed to parse root CA cert: {e}")))?; if certs_der.is_empty() { return Err(TargetError::Configuration( "Webhook client_ca did not contain any parsable certificates".to_string(), )); } for cert_der in certs_der { let ca_cert = reqwest::Certificate::from_der(&cert_der) .map_err(|e| TargetError::Configuration(format!("Failed to load root CA cert: {e}")))?; client_builder = client_builder.add_root_certificate(ca_cert); } } // If neither is set, use the system's default trust store // 2. Configure client certificate (mTLS) if !args.client_cert.is_empty() && !args.client_key.is_empty() { let cert = std::fs::read(&args.client_cert) .map_err(|e| TargetError::Configuration(format!("Failed to read client cert: {e}")))?; let key = std::fs::read(&args.client_key) .map_err(|e| TargetError::Configuration(format!("Failed to read client key: {e}")))?; let identity = reqwest::Identity::from_pem(&[cert, key].concat()) .map_err(|e| TargetError::Configuration(format!("Failed to create identity for mTLS: {e}")))?; client_builder = client_builder.identity(identity); } client_builder .build() .map_err(|e| TargetError::Configuration(format!("Failed to build HTTP client: {e}"))) } async fn refresh_tls(&self) -> Result<(), TargetError> { let next_fingerprint = build_target_tls_fingerprint(&self.args.client_ca, &self.args.client_cert, &self.args.client_key).await?; let tls_changed = { let tls_state_guard = self.tls_state.lock(); tls_state_guard.fingerprint.as_ref() != Some(&next_fingerprint) }; if !tls_changed { return Ok(()); } let new_client = Self::build_http_client(&self.args)?; { let mut tls_state_guard = self.tls_state.lock(); if tls_state_guard.fingerprint.as_ref() == Some(&next_fingerprint) { return Ok(()); } *self.http_client.lock() = new_client; tls_state_guard.refresh(next_fingerprint); } Ok(()) } fn health_check_url(endpoint: &Url) -> Result { endpoint .host() .ok_or_else(|| TargetError::Configuration("Webhook endpoint is missing a host".to_string()))?; let mut health_check_url = endpoint.clone(); health_check_url .set_username("") .map_err(|_| TargetError::Configuration("Webhook endpoint contains invalid user information".to_string()))?; health_check_url .set_password(None) .map_err(|_| TargetError::Configuration("Webhook endpoint contains invalid user information".to_string()))?; health_check_url.set_path("/"); health_check_url.set_query(None); health_check_url.set_fragment(None); Ok(health_check_url) } async fn probe_health(&self) -> TargetHealth { let Some(health_check_url) = self.health_check_url.as_ref() else { return TargetHealth::offline(TargetHealthReason::Unreachable); }; let client = self.http_client.lock().clone(); let health = probe_health_url(&client, health_check_url).await; if health.state == TargetHealthState::Online { debug!( event = EVENT_WEBHOOK_TARGET_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id, state = "reachability_probe_succeeded", "webhook target state" ); } health } async fn probe_reachability(&self) -> Result { let health = self.probe_health().await; match health.state { TargetHealthState::Online => Ok(true), TargetHealthState::Offline | TargetHealthState::Disabled => Ok(false), TargetHealthState::Error => match health.reason { TargetHealthReason::TimedOut => Err(TargetError::Timeout("Webhook health check timed out".to_string())), _ => Err(TargetError::Network(format!("Webhook health check failed: {}", health.reason.as_str()))), }, } } async fn init_inner(&self) -> Result<(), TargetError> { if self.initialized.load(Ordering::SeqCst) { return Ok(()); } if !self.args.enable { return Ok(()); } // Use the configured reqwest client against the origin URL so proxy and TLS // behavior matches real delivery while avoiding path-specific false negatives. match self.probe_reachability().await { Ok(true) => { debug!( event = EVENT_WEBHOOK_TARGET_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id, state = "reachable", "webhook target state" ); } Ok(false) => { return Err(TargetError::NotConnected); } Err(err) => { return Err(err); } } self.initialized.store(true, Ordering::SeqCst); info!( event = EVENT_WEBHOOK_TARGET_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id, state = "initialized", "webhook target state" ); Ok(()) } fn build_queued_payload(&self, event: &EntityTarget) -> Result { build_queued_payload(event) } #[hotpath::measure] async fn send_body(&self, body: Vec, meta: &QueuedPayloadMeta) -> Result<(), TargetError> { debug!( event = EVENT_WEBHOOK_DELIVERY_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id, bucket = %meta.bucket_name, object = %meta.object_name, payload_event = %meta.event_name, payload_len = body.len(), state = "sending", "webhook delivery state" ); // When a TLS reload adapter is attached, it drives client rebuilds in // the background. The inline per-send fingerprint check is skipped. if self.tls_adapter.is_none() { self.refresh_tls().await?; } let client = self.http_client.lock().clone(); let mut req_builder = client .post(self.args.endpoint.as_str()) .header("Content-Type", meta.content_type.as_str()); if !self.args.auth_token.is_empty() { // Split auth_token string to check if the authentication type is included match self.args.auth_token.split_whitespace().count() { 2 => { // Already include authentication type and token, such as "Bearer token123" req_builder = req_builder.header("Authorization", &self.args.auth_token); } 1 => { // Only tokens, need to add "Bearer" prefix req_builder = req_builder.header("Authorization", format!("Bearer {}", self.args.auth_token)); } _ => { // Empty string or other situations, no authentication header is added } } } // Send a request let resp = req_builder.body(body).send().await.map_err(|e| { if e.is_timeout() || e.is_connect() { TargetError::NotConnected } else { TargetError::Request("Webhook delivery request failed".to_string()) } })?; let status = resp.status(); // Drain the response body so the underlying connection is returned to the // pool and can be reused (keep-alive) instead of being closed mid-stream // (backlog#983). The body content is not needed for delivery accounting. let _ = resp.bytes().await; let result = classify_delivery_status(status); if result.is_ok() { debug!( event = EVENT_WEBHOOK_DELIVERY_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id, status = %status, state = "sent", "webhook delivery state" ); self.delivery_counters.record_success(); } result } } fn outbound_policy() -> Result<&'static OutboundPolicy, TargetError> { OutboundPolicy::from_env_cached().map_err(|err| TargetError::Configuration(format!("invalid outbound policy: {err}"))) } #[async_trait] impl Target for WebhookTarget where E: PluginEvent, { fn id(&self) -> TargetID { self.id.clone() } async fn is_active(&self) -> Result { if !self.args.enable { return Ok(false); } self.probe_reachability().await } async fn health(&self) -> TargetHealth { if !self.args.enable { return TargetHealth::disabled(); } self.probe_health().await } async fn save(&self, event: Arc>) -> Result<(), TargetError> { let queued = match self.build_queued_payload(&event) { Ok(queued) => queued, Err(err) => { self.delivery_counters.record_final_failure(); return Err(err); } }; if let Some(store) = &self.store { if let Err(e) = persist_queued_payload_to_store(store.as_ref(), &queued) { self.delivery_counters.record_final_failure(); return Err(e); } debug!( event = EVENT_WEBHOOK_DELIVERY_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id, state = "store_enqueued", "webhook delivery state" ); Ok(()) } else { match self.init().await { Ok(_) => (), Err(e) => { error!( event = EVENT_WEBHOOK_TARGET_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id.id, state = "init_failed", error = %e, "webhook target state" ); self.delivery_counters.record_final_failure(); return Err(TargetError::NotConnected); } } if let Err(err) = self.send_body(queued.body, &queued.meta).await { self.delivery_counters.record_final_failure(); return Err(err); } Ok(()) } } async fn send_raw_from_store(&self, key: Key, body: Vec, meta: QueuedPayloadMeta) -> Result<(), TargetError> { debug!( event = EVENT_WEBHOOK_DELIVERY_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id, key = %key, state = "store_replay_started", "webhook delivery state" ); match self.init().await { Ok(_) => {} Err(e) => { error!( event = EVENT_WEBHOOK_TARGET_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id.id, state = "init_failed", error = %e, "webhook target state" ); return Err(TargetError::NotConnected); } } if let Err(e) = self.send_body(body, &meta).await { if let TargetError::NotConnected = e { return Err(TargetError::NotConnected); } return Err(e); } debug!( event = EVENT_WEBHOOK_DELIVERY_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id, key = %key, state = "store_replay_sent", "webhook delivery state" ); Ok(()) } async fn close(&self) -> Result<(), TargetError> { // Send cancel signal to background tasks let _ = self.cancel_sender.try_send(()); // Adapter cleanup is done by the coordinator; no local state to reset. info!( event = EVENT_WEBHOOK_TARGET_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id, state = "closed", "webhook target state" ); Ok(()) } fn store(&self) -> Option<&(dyn Store + Send + Sync)> { // Returns the reference to the internal store self.store.as_deref() } fn clone_dyn(&self) -> Box + Send + Sync> { self.clone_box() } async fn init(&self) -> Result<(), TargetError> { if !self.is_enabled() { debug!( event = EVENT_WEBHOOK_TARGET_STATE, component = LOG_COMPONENT_TARGETS, subsystem = LOG_SUBSYSTEM_WEBHOOK, target_id = %self.id, state = "disabled", "webhook target state" ); return Ok(()); } self.init_inner().await } fn is_enabled(&self) -> bool { self.args.enable } fn delivery_snapshot(&self) -> TargetDeliverySnapshot { self.delivery_counters.snapshot( self.store.as_deref().map_or(0, |store| store.len() as u64), // Webhook targets record no terminal failures and keep no failed store. 0, ) } fn record_final_failure(&self) { self.delivery_counters.record_final_failure(); } } /// Coordinated TLS hot-reload implementation for Webhook targets. /// /// The coordinator calls these methods on a background poll loop to detect /// TLS file changes and rebuild the HTTP client without restarting. #[async_trait] impl ReloadableTargetTls for WebhookTarget where E: PluginEvent, { type Material = Client; fn tls_input_set(&self) -> TargetTlsInputSet { TargetTlsInputSet { ca_path: self.args.client_ca.clone(), client_cert_path: self.args.client_cert.clone(), client_key_path: self.args.client_key.clone(), target_label: format!("webhook:{}", self.id.id), } } async fn build_tls_material(&self) -> Result { // build_http_client is synchronous (reads files + configures reqwest). // The coordinator already runs this in a background task, so the // synchronous file I/O does not block the send path. Self::build_http_client(&self.args) } async fn apply_tls_material( &self, _generation: TargetTlsGeneration, material: Arc, _mode: ReloadApplyMode, ) -> Result<(), TargetError> { *self.http_client.lock() = (*material).clone(); Ok(()) } async fn validate_tls_files(&self) -> Result<(), TargetError> { validate_tls_material(&self.args.client_ca, &self.args.client_cert, &self.args.client_key) } } #[cfg(test)] mod tests { use super::{WebhookArgs, WebhookTarget, classify_delivery_status, probe_health_url}; use crate::target::{REDACTED_SECRET, Target, TargetHealthReason, TargetHealthState, TargetType, decode_object_name}; use std::net::{IpAddr, SocketAddr}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use url::Url; use url::form_urlencoded; #[derive(Clone)] struct StaticResolver(IpAddr); impl reqwest::dns::Resolve for StaticResolver { fn resolve(&self, _name: reqwest::dns::Name) -> reqwest::dns::Resolving { let address = SocketAddr::new(self.0, 0); Box::pin(async move { Ok(Box::new(std::iter::once(address)) as reqwest::dns::Addrs) }) } } #[derive(Clone)] struct FailingResolver; impl reqwest::dns::Resolve for FailingResolver { fn resolve(&self, _name: reqwest::dns::Name) -> reqwest::dns::Resolving { Box::pin(async { Err(std::io::Error::new(std::io::ErrorKind::NotFound, "test DNS failure").into()) }) } } #[derive(Clone, Default)] struct CapturedLog(std::sync::Arc>>); struct CapturedLogWriter(std::sync::Arc>>); impl std::io::Write for CapturedLogWriter { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.0.lock().expect("captured log lock").extend_from_slice(buf); Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for CapturedLog { type Writer = CapturedLogWriter; fn make_writer(&'writer self) -> Self::Writer { CapturedLogWriter(self.0.clone()) } } impl CapturedLog { fn contents(&self) -> String { String::from_utf8(self.0.lock().expect("captured log lock").clone()).expect("captured logs must be UTF-8") } } fn base_args() -> WebhookArgs { WebhookArgs { enable: true, endpoint: Url::parse("https://example.com/hook").unwrap(), auth_token: String::new(), queue_dir: String::new(), queue_limit: 0, client_cert: String::new(), client_key: String::new(), client_ca: String::new(), skip_tls_verify: false, target_type: TargetType::NotifyEvent, } } async fn http_status_url(status: u16) -> Url { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind test server"); let address = listener.local_addr().expect("test server address"); tokio::spawn(async move { let (mut stream, _) = listener.accept().await.expect("accept health probe"); let mut request = [0u8; 1024]; let _ = stream.read(&mut request).await; stream .write_all(format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()) .await .expect("write health response"); }); Url::parse(&format!("http://{address}/")).expect("health probe URL") } #[test] fn debug_redacts_webhook_secret_fields() { let args = WebhookArgs { endpoint: Url::parse("https://user:password@example.com/private?token=query-secret").expect("debug URL"), auth_token: "webhook-token".to_string(), client_key: "/etc/rustfs/webhook.key".to_string(), ..base_args() }; let rendered = format!("{args:?}"); assert!(!rendered.contains("webhook-token")); assert!(!rendered.contains("/etc/rustfs/webhook.key")); assert!(!rendered.contains("password")); assert!(!rendered.contains("/private")); assert!(!rendered.contains("query-secret")); assert!(rendered.contains(REDACTED_SECRET)); assert!(rendered.contains("https://example.com")); assert!(rendered.contains("WebhookArgs")); } #[tokio::test] async fn webhook_client_uses_the_supplied_connection_resolver() { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind resolver test listener"); let address = listener.local_addr().expect("resolver test listener address"); let server = tokio::spawn(async move { let (mut stream, _) = listener.accept().await.expect("accept resolved webhook request"); let mut request = [0_u8; 1024]; let read = stream.read(&mut request).await.expect("read webhook request"); assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /hook HTTP/1.1")); stream .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") .await .expect("write webhook response"); }); let args = WebhookArgs { endpoint: Url::parse(&format!("http://webhook.test:{}/hook", address.port())).expect("endpoint should parse"), ..base_args() }; let client = WebhookTarget::::build_http_client_with_resolver(&args, StaticResolver(address.ip())) .expect("webhook client should build"); let response = client .get(args.endpoint) .send() .await .expect("resolver should route request to test listener"); assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); server.await.expect("resolver test server should finish"); } #[test] fn webhook_client_ignores_environment_proxies_before_dns_filtering() { const CHILD_ENV: &str = "RUSTFS_TEST_WEBHOOK_PROXY_CHILD"; const TARGET_URL_ENV: &str = "RUSTFS_TEST_WEBHOOK_PROXY_TARGET_URL"; const TARGET_ADDR_ENV: &str = "RUSTFS_TEST_WEBHOOK_PROXY_TARGET_ADDR"; if std::env::var_os(CHILD_ENV).is_some() { let endpoint = Url::parse(&std::env::var(TARGET_URL_ENV).expect("child target URL")).expect("target URL"); let address = std::env::var(TARGET_ADDR_ENV) .expect("child target address") .parse::() .expect("target address"); let args = WebhookArgs { endpoint, ..base_args() }; let client = WebhookTarget::::build_http_client_with_resolver(&args, StaticResolver(address.ip())) .expect("webhook client should build"); tokio::runtime::Runtime::new().expect("child runtime").block_on(async { let response = client .get(args.endpoint) .send() .await .expect("webhook request should bypass environment proxy"); assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); }); return; } use std::io::{Read, Write}; use std::net::TcpListener as StdTcpListener; let target_listener = StdTcpListener::bind("127.0.0.1:0").expect("bind webhook target listener"); let target_address = target_listener.local_addr().expect("webhook target address"); let target = std::thread::spawn(move || { let (mut stream, _) = target_listener.accept().expect("accept direct webhook request"); let mut request = [0_u8; 1024]; let read = stream.read(&mut request).expect("read direct webhook request"); assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /hook HTTP/1.1")); stream .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") .expect("write direct webhook response"); }); let proxy_listener = StdTcpListener::bind("127.0.0.1:0").expect("reserve refused proxy address"); let proxy_address = proxy_listener.local_addr().expect("proxy address"); drop(proxy_listener); let target_url = format!("http://webhook.test:{}/hook", target_address.port()); let proxy_url = format!("http://{proxy_address}"); let output = std::process::Command::new(std::env::current_exe().expect("resolve current test executable")) .arg("webhook_client_ignores_environment_proxies_before_dns_filtering") .arg("--nocapture") .env(CHILD_ENV, "1") .env(TARGET_URL_ENV, target_url) .env(TARGET_ADDR_ENV, target_address.to_string()) .env("HTTP_PROXY", &proxy_url) .env("HTTPS_PROXY", &proxy_url) .env("ALL_PROXY", &proxy_url) .env("NO_PROXY", "") .output() .expect("run isolated proxy test child"); assert!( output.status.success(), "proxy test child failed: stdout={} stderr={}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); target.join().expect("direct webhook target should finish"); } #[test] fn test_validate_skip_tls_verify_and_client_ca_mutually_exclusive() { let args = WebhookArgs { skip_tls_verify: true, client_ca: "/path/to/ca.pem".to_string(), ..base_args() }; let result = args.validate(); assert!(result.is_err()); let err_msg = result.unwrap_err().to_string(); assert!( err_msg.contains("skip_tls_verify") && err_msg.contains("client_ca"), "Error message should mention both fields, got: {err_msg}" ); } #[test] fn test_validate_skip_tls_verify_without_client_ca_is_ok() { let args = WebhookArgs { skip_tls_verify: true, ..base_args() }; assert!(args.validate().is_ok()); } #[test] fn test_validate_client_ca_without_skip_tls_verify_is_ok() { let args = WebhookArgs { client_ca: "/path/to/ca.pem".to_string(), ..base_args() }; assert!(args.validate().is_ok()); } #[test] fn webhook_tls_warning_redacts_endpoint_details() { let captured = CapturedLog::default(); let subscriber = tracing_subscriber::fmt() .with_ansi(false) .without_time() .with_max_level(tracing::Level::WARN) .with_writer(captured.clone()) .finish(); let args = WebhookArgs { endpoint: Url::parse("https://webhook.test/private?token=secret").expect("webhook endpoint"), skip_tls_verify: true, ..base_args() }; tracing::subscriber::with_default(subscriber, || { WebhookTarget::::build_http_client_with_resolver( &args, StaticResolver(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), ) .expect("webhook client should build"); }); let logs = captured.contents(); assert!(logs.contains("https://webhook.test")); for secret in ["/private", "token=secret"] { assert!(!logs.contains(secret), "TLS warning leaked {secret}: {logs}"); } } #[test] fn test_validate_rejects_loopback_endpoint() { let args = WebhookArgs { endpoint: Url::parse("https://127.0.0.1/hook").expect("loopback endpoint should parse"), ..base_args() }; let err = args.validate().expect_err("loopback endpoint should be rejected"); assert!(err.to_string().contains("not allowed")); } #[test] fn test_decode_object_name_with_spaces() { // Test case from the issue: "greeting file (2).csv" let object_name = "greeting file (2).csv"; // Simulate what event.rs does: form-urlencoded encoding (spaces become +) let form_encoded = form_urlencoded::byte_serialize(object_name.as_bytes()).collect::(); assert_eq!(form_encoded, "greeting+file+%282%29.csv"); // Test the decode_object_name helper function let decoded = decode_object_name(&form_encoded).unwrap(); assert_eq!(decoded, object_name); assert!(!decoded.contains('+'), "Decoded string should not contain + symbols"); } #[test] fn test_decode_object_name_with_special_chars() { // Test with various special characters let test_cases = vec![ ("folder/greeting file (2).csv", "folder%2Fgreeting+file+%282%29.csv"), ("test file.txt", "test+file.txt"), ("my file (copy).pdf", "my+file+%28copy%29.pdf"), ("file with spaces and (parentheses).doc", "file+with+spaces+and+%28parentheses%29.doc"), ]; for (original, form_encoded) in test_cases { // Test the decode_object_name helper function let decoded = decode_object_name(form_encoded).unwrap(); assert_eq!(decoded, original, "Failed to decode: {}", form_encoded); } } #[test] fn test_decode_object_name_without_spaces() { // Test that files without spaces still work correctly let object_name = "simple-file.txt"; let form_encoded = form_urlencoded::byte_serialize(object_name.as_bytes()).collect::(); let decoded = decode_object_name(&form_encoded).unwrap(); assert_eq!(decoded, object_name); } #[test] fn test_health_check_url_ignores_endpoint_path() { let endpoint = Url::parse("https://user:password@example.com:9443/hook/path?token=secret").expect("webhook endpoint URL"); let health_check_url = WebhookTarget::::health_check_url(&endpoint).expect("webhook health-check URL"); assert_eq!(health_check_url.as_str(), "https://example.com:9443/"); } #[tokio::test] async fn head_http_responses_only_measure_reachability() { let client = WebhookTarget::::build_http_client(&base_args()).expect("build client"); for status in [401, 404, 500] { let health = probe_health_url(&client, &http_status_url(status).await).await; assert_eq!(health.state, TargetHealthState::Online, "HEAD {status} is reachable"); assert_eq!(health.reason, TargetHealthReason::Reachable); } } #[tokio::test] async fn refused_connection_has_stable_health_reason() { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve refused port"); let address = listener.local_addr().expect("refused address"); drop(listener); let url = Url::parse(&format!("http://{address}/")).expect("refused URL"); let client = WebhookTarget::::build_http_client(&base_args()).expect("build client"); let health = probe_health_url(&client, &url).await; assert_eq!(health.state, TargetHealthState::Error); assert_eq!(health.reason, TargetHealthReason::ConnectionRefused); } #[tokio::test] async fn dns_failure_has_stable_health_reason() { let url = Url::parse("http://unresolvable.test/").expect("invalid test domain URL"); let client = WebhookTarget::::build_http_client_with_resolver(&base_args(), FailingResolver) .expect("build client"); let health = probe_health_url(&client, &url).await; assert_eq!(health.state, TargetHealthState::Error); // On systems with DNS interception (common on macOS), `.invalid` may resolve // to an interception address, producing `Unreachable` instead of `DnsFailure`. assert!( matches!(health.reason, TargetHealthReason::DnsFailure | TargetHealthReason::Unreachable), "expected DnsFailure or Unreachable, got {:?}", health.reason ); } #[tokio::test(start_paused = true)] async fn webhook_health_probe_has_a_five_second_total_budget() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind stalled server"); let address = listener.local_addr().expect("stalled server address"); let server = tokio::spawn(async move { let (_stream, _) = listener.accept().await.expect("accept stalled probe"); std::future::pending::<()>().await; }); let url = Url::parse(&format!("http://{address}/")).expect("stalled URL"); let client = WebhookTarget::::build_http_client(&base_args()).expect("build client"); let started = tokio::time::Instant::now(); let health = probe_health_url(&client, &url).await; assert_eq!(started.elapsed(), super::WEBHOOK_HEALTH_TIMEOUT); assert_eq!(health.state, TargetHealthState::Error); assert_eq!(health.reason, TargetHealthReason::TimedOut); server.abort(); } #[tokio::test] async fn tls_failure_has_stable_health_reason() { use rustls::{ ServerConfig, ServerConnection, StreamOwned, pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}, }; use std::io::Read; use std::sync::{Arc, Once}; static INSTALL_CRYPTO_PROVIDER: Once = Once::new(); INSTALL_CRYPTO_PROVIDER.call_once(|| { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); }); let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).expect("cert should generate"); let server_config = Arc::new( ServerConfig::builder() .with_no_client_auth() .with_single_cert( vec![cert.der().clone()], PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der())), ) .expect("server cert should be valid"), ); let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind TLS server"); let address = listener.local_addr().expect("TLS server address"); let server = std::thread::spawn(move || { let (stream, _) = listener.accept().expect("accept TLS client"); let connection = ServerConnection::new(server_config).expect("server connection"); let mut tls_stream = StreamOwned::new(connection, stream); let mut request = [0u8; 1024]; let _ = tls_stream.read(&mut request); }); let url = Url::parse(&format!("https://localhost:{}/", address.port())).expect("TLS health URL"); let client = WebhookTarget::::build_http_client_with_resolver(&base_args(), StaticResolver(address.ip())) .expect("build client"); let health = probe_health_url(&client, &url).await; assert_eq!(health.state, TargetHealthState::Error); assert_eq!(health.reason, TargetHealthReason::TlsFailure); server.join().expect("TLS server thread"); } #[test] fn post_status_classification_requires_success() { assert!(classify_delivery_status(reqwest::StatusCode::NO_CONTENT).is_ok()); for status in [ reqwest::StatusCode::MOVED_PERMANENTLY, reqwest::StatusCode::UNAUTHORIZED, reqwest::StatusCode::INTERNAL_SERVER_ERROR, ] { assert!(classify_delivery_status(status).is_err(), "POST {status} must fail"); } } #[tokio::test] async fn test_disabled_target_can_be_constructed_without_origin_probe() { let args = WebhookArgs { enable: false, endpoint: Url::parse("about:blank").unwrap(), ..base_args() }; let target = WebhookTarget::::new("disabled-target".to_string(), args).unwrap(); assert!(!target.is_active().await.unwrap()); } #[test] fn test_origin_reachability_probe_requires_non_local_endpoint() { let args = WebhookArgs { endpoint: Url::parse("http://127.0.0.1/hook").unwrap(), ..base_args() }; let err = match WebhookTarget::::new("path-probe".to_string(), args) { Ok(_) => panic!("loopback origin probes should now be rejected at construction time"), Err(err) => err, }; assert!(err.to_string().contains("not allowed")); } // SSRF hardening regression (backlog#974): the delivery client must not follow HTTP // redirects, otherwise a 3xx from the endpoint could bounce the outbound request to an // internal address (e.g. the cloud metadata service) and bypass endpoint validation. #[tokio::test] async fn test_webhook_client_does_not_follow_redirects() { use std::io::{Read, Write}; use std::net::TcpListener; // Minimal HTTP server on an ephemeral loopback port. let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server"); let addr = listener.local_addr().expect("local addr"); // Serve exactly one request with a 302 pointing at an internal metadata address. // If the client followed the redirect it would issue a second request to that // (unreachable) target instead of returning the 3xx status. let handle = std::thread::spawn(move || { if let Ok((mut stream, _)) = listener.accept() { let mut buf = [0u8; 1024]; let _ = stream.read(&mut buf); let response = "HTTP/1.1 302 Found\r\nLocation: http://169.254.169.254/latest/meta-data/\r\nContent-Length: 0\r\n\r\n"; let _ = stream.write_all(response.as_bytes()); let _ = stream.flush(); } }); let client = WebhookTarget::::build_http_client(&base_args()).expect("build client"); let resp = client .post(format!("http://{addr}/hook")) .body("{}") .send() .await .expect("request should complete without following the redirect"); // Redirects are disabled, so the 3xx is surfaced as-is rather than chased to the // internal Location target. assert_eq!(resp.status().as_u16(), 302, "webhook client must not follow redirects"); handle.join().expect("mock server thread"); } #[tokio::test] async fn test_webhook_client_reaches_https_origin_with_custom_ca() { use rustls::{ ServerConfig, ServerConnection, StreamOwned, pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}, }; use std::io::{Read, Write}; use std::net::TcpListener; use std::sync::{Arc, Once}; static INSTALL_CRYPTO_PROVIDER: Once = Once::new(); INSTALL_CRYPTO_PROVIDER.call_once(|| { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); }); let rcgen::CertifiedKey { cert, signing_key } = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).expect("cert should generate"); let temp_dir = tempfile::tempdir().expect("tempdir"); let ca_path = temp_dir.path().join("webhook-ca.pem"); std::fs::write(&ca_path, cert.pem()).expect("write ca pem"); let cert_chain = vec![cert.der().clone()]; let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(signing_key.serialize_der())); let server_config = Arc::new( ServerConfig::builder() .with_no_client_auth() .with_single_cert(cert_chain, key_der) .expect("server cert should be valid"), ); let listener = TcpListener::bind("127.0.0.1:0").expect("bind tls server"); let addr = listener.local_addr().expect("local addr"); let handle = std::thread::spawn(move || { let (stream, _) = listener.accept().expect("accept tls client"); let connection = ServerConnection::new(server_config).expect("server connection"); let mut tls_stream = StreamOwned::new(connection, stream); let mut buf = [0u8; 1024]; let _ = tls_stream.read(&mut buf); let response = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; tls_stream.write_all(response.as_bytes()).expect("write response"); tls_stream.flush().expect("flush response"); }); let args = WebhookArgs { endpoint: Url::parse(&format!("https://localhost:{}/hook", addr.port())).expect("endpoint should parse"), client_ca: ca_path.to_string_lossy().into_owned(), ..base_args() }; let client = WebhookTarget::::build_http_client_with_resolver(&args, StaticResolver(addr.ip())) .expect("build https client"); let resp = client .head(args.endpoint) .send() .await .expect("https webhook probe should trust configured ca"); assert_eq!(resp.status(), reqwest::StatusCode::OK); assert!(resp.bytes().await.expect("read response body").is_empty()); handle.join().expect("tls server thread"); } }