diff --git a/channels-src/feishu/Cargo.lock b/channels-src/feishu/Cargo.lock index 60f68fcc..4e95f3fe 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 53b9357d..95762410 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 a228cc4e..cf344d74 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" @@ -63,13 +63,15 @@ }, "webhook": { "secret_header": "X-Feishu-Verification-Token", - "secret_name": "feishu_verification_token" + "secret_name": "feishu_verification_token", + "managed_by_host": false } } }, "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..647f5fa5 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!({ @@ -32,6 +33,7 @@ wit_bindgen::generate!({ }); use serde::{Deserialize, Serialize}; +use subtle::ConstantTimeEq; // Re-export generated types use exports::near::agent::channel::{ @@ -50,6 +52,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"; @@ -102,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). @@ -251,6 +258,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 +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); } + 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); @@ -376,6 +389,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(), + request_verification_token(&event), + ) { + 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 +869,31 @@ 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)) => { + 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::*; @@ -862,7 +917,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 +952,64 @@ 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" + ); + } + + #[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 6329428f..41ecfac1 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 b5081426..f2ca6674 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 7f0bb8fb..84df615f 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -139,13 +139,18 @@ async fn register_channel( }; let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); + 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 { 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 +210,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 +219,7 @@ async fn register_channel( .register( Arc::clone(&channel_arc), endpoints, - webhook_secret.clone(), + host_webhook_secret.clone(), secret_header, ) .await; @@ -392,8 +397,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 +410,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, };