use std::collections::HashMap; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use sqlx::{types::Json as SqlxJson, FromRow}; use windmill_api_auth::ApiAuthed; use windmill_common::{ error::{Error, Result}, jobs::JobTriggerKind, triggers::{TriggerKind, TriggerMetadata}, worker::to_raw_value, DB, }; use windmill_queue::PushArgsOwned; use windmill_trigger::trigger_helpers::{ trigger_runnable_and_wait_for_raw_result_with_error_ctx, TriggerJobArgs, }; pub mod handler; pub mod listener; pub mod proxy; #[derive(Copy, Clone)] pub struct WebsocketTrigger; impl TriggerJobArgs for WebsocketTrigger { type Payload = String; const TRIGGER_KIND: TriggerKind = TriggerKind::Websocket; fn v1_payload_fn(payload: &Self::Payload) -> HashMap> { HashMap::from([("msg".to_string(), to_raw_value(&payload))]) } } fn default_filter_logic() -> String { "and".to_string() } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebsocketHeartbeat { pub interval_secs: u64, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub state_field: Option, } #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] pub struct WebsocketConfig { pub url: String, #[serde(default)] pub filters: Vec>>, #[serde(default = "default_filter_logic")] pub filter_logic: String, #[serde(skip_serializing_if = "Option::is_none")] pub initial_messages: Option>>>, #[serde(skip_serializing_if = "Option::is_none")] pub url_runnable_args: Option>>, #[serde(default)] pub can_return_message: bool, #[serde(default)] pub can_return_error_result: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub heartbeat: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebsocketConfigRequest { url: String, filters: Vec, #[serde(default = "default_filter_logic")] filter_logic: String, initial_messages: Option>, url_runnable_args: Option, can_return_message: bool, can_return_error_result: bool, pub heartbeat: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TestWebsocketConfig { url: String, url_runnable_args: Option, } pub fn value_to_args_hashmap( args: Option<&Box>, ) -> Result>> { let args = if let Some(args) = args { let args_map: Option> = serde_json::from_str(args.get()) .map_err(|e| Error::BadRequest(format!("invalid json: {}", e)))?; args_map .unwrap_or_else(HashMap::new) .into_iter() .map(|(k, v)| { let raw_value = serde_json::value::to_raw_value(&v).map_err(|e| { Error::BadRequest(format!("failed to convert to raw value: {}", e)) })?; Ok((k, raw_value)) }) .collect::>>>() } else { Ok(HashMap::new()) }?; Ok(args) } /// Env var that opts a deployment out of SSRF validation for WebSocket trigger /// URLs, permitting connections to private/internal addresses. Off by default. pub const ALLOW_PRIVATE_WEBSOCKET_URLS_ENV: &str = "ALLOW_PRIVATE_WEBSOCKET_URLS"; /// Reject WebSocket URLs that target (or resolve to) a private/internal address, /// blocking SSRF probes of the host's internal network and cloud metadata /// endpoints. /// /// `ws://`/`wss://` are mapped to `http`/`https` so the shared /// `validate_url_for_ssrf` host + DNS-resolution checks apply. The /// security-critical call sites are the outbound connects (the test handler and /// every listener (re)connect): validating the *resolved* URL there means a /// `$flow:`/`$script:` URL is checked on its returned value and re-checked on /// each reconnect (DNS rebinding). `validate_config` also calls this at save /// time to reject static URLs early. pub async fn validate_websocket_url_for_ssrf(url: &str) -> Result<()> { if std::env::var(ALLOW_PRIVATE_WEBSOCKET_URLS_ENV) .ok() .is_some_and(|v| v == "true" || v == "1") { return Ok(()); } // `ws`/`wss` aren't recognised by `validate_url_for_ssrf`'s scheme check, so // map them to the http(s) equivalent the same connection would tunnel over. // The prefixes are ASCII, so byte-slicing at their length stays on a char // boundary. let lower = url.to_ascii_lowercase(); let http_url = if lower.starts_with("wss://") { format!("https://{}", &url["wss://".len()..]) } else if lower.starts_with("ws://") { format!("http://{}", &url["ws://".len()..]) } else { url.to_string() }; windmill_common::ssrf::validate_url_for_ssrf(&http_url) .await .map_err(|e| match e { // The env-var hint is only actionable for a well-formed URL blocked // for targeting a private address; a malformed URL or bad scheme // surfaces its real error so the user fixes the URL (see #9171). e @ windmill_common::ssrf::SsrfValidationError::Private { .. } => { Error::BadRequest(format!( "{e}. If you need to connect to private/internal WebSocket endpoints, \ set the {ALLOW_PRIVATE_WEBSOCKET_URLS_ENV}=true environment variable" )) } e => Error::from(e), }) } pub async fn get_url_from_runnable_value( path: &str, is_flow: bool, db: &DB, authed: ApiAuthed, args: Option<&Box>, workspace_id: &str, ) -> Result { tracing::info!( "Running {} {} to get WebSocket URL", if is_flow { "flow" } else { "script" }, path ); let args = value_to_args_hashmap(args)?; let result = trigger_runnable_and_wait_for_raw_result_with_error_ctx( db, None, authed, workspace_id, path, is_flow, PushArgsOwned { args, extra: None }, None, None, None, "".to_string(), // doesn't matter as no retry/error handler TriggerMetadata::new(Some(path.to_owned()), JobTriggerKind::Websocket), ) .await?; serde_json::from_str::(result.get()).map_err(|_| { Error::BadConfig(format!( "{} {} did not return a string", if is_flow { "Flow" } else { "Script" }, path, )) }) } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn ssrf_blocks_private_and_metadata_ws_urls() { // ws:// → http:// mapping must still reach the IP-literal block. let err = validate_websocket_url_for_ssrf("ws://127.0.0.1:6379/") .await .unwrap_err(); assert!(matches!(err, Error::BadRequest(_))); // Private errors carry the opt-out hint so operators can allow internal // targets deliberately. assert!(err.to_string().contains(ALLOW_PRIVATE_WEBSOCKET_URLS_ENV)); // wss:// → https:// mapping blocks the cloud metadata endpoint. assert!( validate_websocket_url_for_ssrf("wss://169.254.169.254/latest/meta-data") .await .is_err() ); assert!(validate_websocket_url_for_ssrf("ws://10.0.0.5:6379/") .await .is_err()); } #[tokio::test] async fn ssrf_rejects_non_ws_scheme_without_private_hint() { // A non-ws scheme isn't mapped and fails the scheme check; it must not // get the "set ALLOW_PRIVATE_WEBSOCKET_URLS" hint (issue #9171). let err = validate_websocket_url_for_ssrf("file:///etc/passwd") .await .unwrap_err(); assert!(!err.to_string().contains(ALLOW_PRIVATE_WEBSOCKET_URLS_ENV)); } }