From 1663cd3cb265c5fdf2b5d338a526be4e5bd466c6 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Wed, 25 Mar 2026 10:17:25 +0300 Subject: [PATCH 1/3] fix: require Feishu webhook authentication --- channels-src/feishu/feishu.capabilities.json | 3 +- channels-src/feishu/src/lib.rs | 77 +++++++++++++++++++- src/channels/wasm/setup.rs | 24 ++++-- 3 files changed, 96 insertions(+), 8 deletions(-) diff --git a/channels-src/feishu/feishu.capabilities.json b/channels-src/feishu/feishu.capabilities.json index a228cc4e5b2..1b07b4198b3 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 62440d2c074..2c78ba82cb6 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 7f0bb8fb14a..2883588a0d8 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, }; From 810cb180450e70032844283387679e87033e3aa7 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Wed, 25 Mar 2026 22:09:36 +0300 Subject: [PATCH 2/3] fix: handle Feishu v2 webhook token auth --- channels-src/feishu/Cargo.lock | 7 +++ channels-src/feishu/Cargo.toml | 1 + channels-src/feishu/feishu.capabilities.json | 3 +- channels-src/feishu/src/lib.rs | 58 +++++++++++++++++--- src/channels/wasm/loader.rs | 8 +++ src/channels/wasm/schema.rs | 40 ++++++++++++++ src/channels/wasm/setup.rs | 17 ++---- 7 files changed, 114 insertions(+), 20 deletions(-) diff --git a/channels-src/feishu/Cargo.lock b/channels-src/feishu/Cargo.lock index 60f68fccaf5..4e95f3fe86f 100644 --- a/channels-src/feishu/Cargo.lock +++ b/channels-src/feishu/Cargo.lock @@ -44,6 +44,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "subtle", "wit-bindgen", ] @@ -208,6 +209,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" diff --git a/channels-src/feishu/Cargo.toml b/channels-src/feishu/Cargo.toml index 53b9357df38..957624108e9 100644 --- a/channels-src/feishu/Cargo.toml +++ b/channels-src/feishu/Cargo.toml @@ -15,6 +15,7 @@ wit-bindgen = "0.36" # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +subtle = "2.6" # Exclude from parent workspace (this is a standalone WASM component) diff --git a/channels-src/feishu/feishu.capabilities.json b/channels-src/feishu/feishu.capabilities.json index 1b07b4198b3..cf344d74b36 100644 --- a/channels-src/feishu/feishu.capabilities.json +++ b/channels-src/feishu/feishu.capabilities.json @@ -63,7 +63,8 @@ }, "webhook": { "secret_header": "X-Feishu-Verification-Token", - "secret_name": "feishu_verification_token" + "secret_name": "feishu_verification_token", + "managed_by_host": false } } }, diff --git a/channels-src/feishu/src/lib.rs b/channels-src/feishu/src/lib.rs index 2c78ba82cb6..1666732f3d0 100644 --- a/channels-src/feishu/src/lib.rs +++ b/channels-src/feishu/src/lib.rs @@ -33,6 +33,7 @@ wit_bindgen::generate!({ }); use serde::{Deserialize, Serialize}; +use subtle::ConstantTimeEq; // Re-export generated types use exports::near::agent::channel::{ @@ -104,6 +105,10 @@ struct FeishuEventHeader { /// Tenant key. #[serde(default)] tenant_key: Option, + + /// Verification token for v2 event payloads. + #[serde(default)] + token: Option, } /// Message receive event payload (im.message.receive_v1). @@ -305,11 +310,8 @@ 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, ""); - } + let verification_token = config.verification_token.as_deref().unwrap_or(""); + let _ = channel_host::workspace_write(VERIFICATION_TOKEN_PATH, verification_token); if let Some(owner_id) = &config.owner_id { let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id); @@ -391,7 +393,7 @@ impl Guest for FeishuChannel { if !is_authenticated_webhook( req.secret_validated, configured_token.as_deref(), - event.token.as_deref(), + request_verification_token(&event), ) { channel_host::log( channel_host::LogLevel::Warn, @@ -876,11 +878,21 @@ fn is_authenticated_webhook( } match (configured_token, request_token) { - (Some(expected), Some(provided)) => expected == provided, + (Some(expected), Some(provided)) => { + bool::from(expected.as_bytes().ct_eq(provided.as_bytes())) + } _ => false, } } +fn request_verification_token(event: &FeishuEvent) -> Option<&str> { + event + .header + .as_ref() + .and_then(|header| header.token.as_deref()) + .or(event.token.as_deref()) +} + #[cfg(test)] mod tests { use super::*; @@ -967,4 +979,36 @@ mod tests { "host authentication should take precedence over body token checks" ); } + + #[test] + fn request_verification_token_prefers_v2_header_token() { + let event: FeishuEvent = serde_json::from_str( + r#"{ + "schema": "2.0", + "header": { + "event_id": "evt_123", + "event_type": "im.message.receive_v1", + "token": "header-token" + }, + "event": {} + }"#, + ) + .unwrap(); + + assert_eq!(request_verification_token(&event), Some("header-token")); + } + + #[test] + fn request_verification_token_falls_back_to_top_level_token() { + let event: FeishuEvent = serde_json::from_str( + r#"{ + "type": "url_verification", + "challenge": "abc", + "token": "top-level-token" + }"#, + ) + .unwrap(); + + assert_eq!(request_verification_token(&event), Some("top-level-token")); + } } diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 6329428fea1..41ecfac137a 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -317,6 +317,14 @@ impl LoadedChannel { .map(|f| f.webhook_secret_name()) .unwrap_or_else(|| format!("{}_webhook_secret", self.channel.channel_name())) } + + /// Whether the host should enforce generic webhook-secret validation. + pub fn webhook_secret_managed_by_host(&self) -> bool { + self.capabilities_file + .as_ref() + .map(|f| f.webhook_secret_managed_by_host()) + .unwrap_or(true) + } } /// Results from loading multiple channels. diff --git a/src/channels/wasm/schema.rs b/src/channels/wasm/schema.rs index b5081426612..f2ca66745da 100644 --- a/src/channels/wasm/schema.rs +++ b/src/channels/wasm/schema.rs @@ -185,6 +185,19 @@ impl ChannelCapabilitiesFile { .and_then(|w| w.secret_name.clone()) .unwrap_or_else(|| format!("{}_webhook_secret", self.name)) } + + /// Whether the host should enforce generic webhook-secret validation. + /// + /// Defaults to true. Channels can opt out when they validate the shared + /// secret themselves using provider-specific request body fields. + pub fn webhook_secret_managed_by_host(&self) -> bool { + self.capabilities + .channel + .as_ref() + .and_then(|c| c.webhook.as_ref()) + .and_then(|w| w.managed_by_host) + .unwrap_or(true) + } } /// Schema for channel capabilities. @@ -302,6 +315,14 @@ pub struct WebhookSchema { /// Secret name in secrets store for HMAC-SHA256 signing (Slack-style). #[serde(default)] pub hmac_secret_name: Option, + + /// Whether the host/router should enforce generic webhook-secret + /// validation before the channel sees the request. + /// + /// Default: true. Set to false when the provider sends the shared secret + /// in a provider-specific request field rather than the configured header. + #[serde(default)] + pub managed_by_host: Option, } /// Setup configuration schema. @@ -611,6 +632,25 @@ mod tests { Some("X-Telegram-Bot-Api-Secret-Token") ); assert_eq!(file.webhook_secret_name(), "telegram_webhook_secret"); + assert!(file.webhook_secret_managed_by_host()); + } + + #[test] + fn test_webhook_schema_can_disable_host_managed_secret_validation() { + let json = r#"{ + "name": "feishu", + "capabilities": { + "channel": { + "webhook": { + "secret_name": "feishu_verification_token", + "managed_by_host": false + } + } + } + }"#; + + let file = ChannelCapabilitiesFile::from_json(json).unwrap(); + assert!(!file.webhook_secret_managed_by_host()); } #[test] diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index 2883588a0d8..84df615fdf1 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -139,7 +139,11 @@ 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 host_webhook_secret = if loaded.webhook_secret_managed_by_host() { + webhook_secret.clone() + } else { + None + }; let webhook_path = format!("/webhook/{}", channel_name); let endpoints = vec![RegisteredEndpoint { @@ -385,17 +389,6 @@ 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 From c6704b8feb373e7192e70cd22c0496b63ba2f9e9 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Thu, 26 Mar 2026 17:17:22 +0300 Subject: [PATCH 3/3] fix: skip empty verification token write, consistent with app_id/app_secret Address zmanian review nit #4: only write verification_token to workspace when present, matching the if-let pattern used for app_id and app_secret. Functionally identical (the auth check filters empty strings), but consistent. Co-Authored-By: Claude Opus 4.6 (1M context) --- channels-src/feishu/src/lib.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/channels-src/feishu/src/lib.rs b/channels-src/feishu/src/lib.rs index 1666732f3d0..647f5fa5fda 100644 --- a/channels-src/feishu/src/lib.rs +++ b/channels-src/feishu/src/lib.rs @@ -310,8 +310,9 @@ impl Guest for FeishuChannel { if let Some(ref app_secret) = config.app_secret { let _ = channel_host::workspace_write(APP_SECRET_PATH, app_secret); } - let verification_token = config.verification_token.as_deref().unwrap_or(""); - let _ = channel_host::workspace_write(VERIFICATION_TOKEN_PATH, verification_token); + if let Some(ref verification_token) = config.verification_token { + let _ = channel_host::workspace_write(VERIFICATION_TOKEN_PATH, verification_token); + } if let Some(owner_id) = &config.owner_id { let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);