diff --git a/channels-src/feishu/feishu.capabilities.json b/channels-src/feishu/feishu.capabilities.json index a228cc4e..1b07b419 100644 --- a/channels-src/feishu/feishu.capabilities.json +++ b/channels-src/feishu/feishu.capabilities.json @@ -27,7 +27,7 @@ { "name": "feishu_verification_token", "prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription webhook settings)", - "optional": true + "optional": false } ], "setup_url": "https://open.feishu.cn/app" @@ -70,6 +70,7 @@ "config": { "app_id": null, "app_secret": null, + "verification_token": null, "api_base": "https://open.feishu.cn", "owner_id": null, "dm_policy": "pairing", diff --git a/channels-src/feishu/src/lib.rs b/channels-src/feishu/src/lib.rs index 62440d2c..2c78ba82 100644 --- a/channels-src/feishu/src/lib.rs +++ b/channels-src/feishu/src/lib.rs @@ -23,7 +23,8 @@ //! - App credentials (app_id, app_secret) are injected by the host into //! the config JSON during startup for token exchange //! - Bearer token for API calls is obtained via token exchange and cached -//! - Verification token validated by host for webhook requests +//! - Webhook requests must be authenticated by the host or by a matching +//! Feishu verification token in the request body // Generate bindings from the WIT file wit_bindgen::generate!({ @@ -50,6 +51,7 @@ const ALLOW_FROM_PATH: &str = "allow_from"; const API_BASE_PATH: &str = "api_base"; const APP_ID_PATH: &str = "app_id"; const APP_SECRET_PATH: &str = "app_secret"; +const VERIFICATION_TOKEN_PATH: &str = "verification_token"; const TOKEN_PATH: &str = "tenant_access_token"; const TOKEN_EXPIRY_PATH: &str = "token_expiry"; @@ -251,6 +253,9 @@ struct FeishuConfig { /// Feishu App Secret (for token exchange). app_secret: Option, + /// Feishu Event Subscription verification token. + verification_token: Option, + /// API base URL. Defaults to "https://open.feishu.cn" (use /// "https://open.larksuite.com" for Lark international). #[serde(default = "default_api_base")] @@ -300,6 +305,11 @@ impl Guest for FeishuChannel { if let Some(ref app_secret) = config.app_secret { let _ = channel_host::workspace_write(APP_SECRET_PATH, app_secret); } + if let Some(ref verification_token) = config.verification_token { + let _ = channel_host::workspace_write(VERIFICATION_TOKEN_PATH, verification_token); + } else { + let _ = channel_host::workspace_write(VERIFICATION_TOKEN_PATH, ""); + } if let Some(owner_id) = &config.owner_id { let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id); @@ -376,6 +386,23 @@ impl Guest for FeishuChannel { } }; + let configured_token = + channel_host::workspace_read(VERIFICATION_TOKEN_PATH).filter(|token| !token.is_empty()); + if !is_authenticated_webhook( + req.secret_validated, + configured_token.as_deref(), + event.token.as_deref(), + ) { + channel_host::log( + channel_host::LogLevel::Warn, + "Rejecting unauthenticated Feishu webhook request", + ); + return json_response( + 401, + serde_json::json!({"error": "Webhook authentication failed"}), + ); + } + // Handle URL verification challenge (initial webhook setup). if event.event_type.as_deref() == Some("url_verification") { if let Some(challenge) = &event.challenge { @@ -839,6 +866,21 @@ fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse { } } +fn is_authenticated_webhook( + secret_validated: bool, + configured_token: Option<&str>, + request_token: Option<&str>, +) -> bool { + if secret_validated { + return true; + } + + match (configured_token, request_token) { + (Some(expected), Some(provided)) => expected == provided, + _ => false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -862,7 +904,10 @@ mod tests { fn parse_token_response_rejects_missing_token() { let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#; let result: Result = serde_json::from_str(json); - assert!(result.is_err(), "should fail when tenant_access_token is missing"); + assert!( + result.is_err(), + "should fail when tenant_access_token is missing" + ); } #[test] @@ -894,4 +939,32 @@ mod tests { assert_eq!(resp.code, 10003); assert!(resp.tenant_access_token.is_empty()); } + + #[test] + fn webhook_auth_requires_host_auth_or_matching_verification_token() { + assert!( + !is_authenticated_webhook(false, None, Some("token")), + "requests without any configured verification mechanism must be rejected" + ); + assert!( + !is_authenticated_webhook(false, Some("expected"), None), + "requests missing the Feishu token must be rejected when host auth did not pass" + ); + assert!( + !is_authenticated_webhook(false, Some("expected"), Some("wrong")), + "requests with the wrong Feishu token must be rejected" + ); + assert!( + is_authenticated_webhook(false, Some("expected"), Some("expected")), + "matching Feishu verification token should authenticate the request" + ); + assert!( + is_authenticated_webhook(true, None, None), + "host-authenticated requests should still be accepted" + ); + assert!( + is_authenticated_webhook(true, Some("expected"), Some("wrong")), + "host authentication should take precedence over body token checks" + ); + } } diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index 7f0bb8fb..2883588a 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -139,13 +139,14 @@ async fn register_channel( }; let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); + let host_webhook_secret = host_managed_webhook_secret(&channel_name, webhook_secret.clone()); let webhook_path = format!("/webhook/{}", channel_name); let endpoints = vec![RegisteredEndpoint { channel_name: channel_name.clone(), path: webhook_path, methods: vec!["POST".to_string()], - require_secret: webhook_secret.is_some(), + require_secret: host_webhook_secret.is_some(), }]; let channel_arc = Arc::new(loaded.channel.with_owner_actor_id(owner_actor_id.clone())); @@ -205,7 +206,7 @@ async fn register_channel( tracing::info!( channel = %channel_name, - has_webhook_secret = webhook_secret.is_some(), + has_webhook_secret = host_webhook_secret.is_some(), secret_header = ?secret_header, "Registering channel with router" ); @@ -214,7 +215,7 @@ async fn register_channel( .register( Arc::clone(&channel_arc), endpoints, - webhook_secret.clone(), + host_webhook_secret.clone(), secret_header, ) .await; @@ -384,6 +385,17 @@ pub async fn inject_channel_credentials( Ok(count) } +fn host_managed_webhook_secret( + channel_name: &str, + webhook_secret: Option, +) -> Option { + if channel_name == "feishu" { + None + } else { + webhook_secret + } +} + /// Inject channel-specific secrets into the config JSON. /// /// Some channels (e.g., Feishu) need raw credential values in their config @@ -392,8 +404,9 @@ pub async fn inject_channel_credentials( /// placeholders in URLs and headers, so this function fills config fields /// that map to secret names. /// -/// Mapping: for a channel named "feishu", secrets `feishu_app_id` and -/// `feishu_app_secret` are injected as config keys `app_id` and `app_secret`. +/// Mapping: for a channel named "feishu", secrets `feishu_app_id`, +/// `feishu_app_secret`, and `feishu_verification_token` are injected as config +/// keys `app_id`, `app_secret`, and `verification_token`. async fn inject_channel_secrets_into_config( channel_name: &str, secrets_store: &Option>, @@ -404,6 +417,7 @@ async fn inject_channel_secrets_into_config( "feishu" => &[ ("app_id", "feishu_app_id"), ("app_secret", "feishu_app_secret"), + ("verification_token", "feishu_verification_token"), ], _ => return, };