Skip to content

Commit 810cb18

Browse files
committed
fix: handle Feishu v2 webhook token auth
1 parent 1663cd3 commit 810cb18

7 files changed

Lines changed: 114 additions & 20 deletions

File tree

channels-src/feishu/Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

channels-src/feishu/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ wit-bindgen = "0.36"
1515
# Serialization
1616
serde = { version = "1.0", features = ["derive"] }
1717
serde_json = "1.0"
18+
subtle = "2.6"
1819

1920
# Exclude from parent workspace (this is a standalone WASM component)
2021

channels-src/feishu/feishu.capabilities.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363
},
6464
"webhook": {
6565
"secret_header": "X-Feishu-Verification-Token",
66-
"secret_name": "feishu_verification_token"
66+
"secret_name": "feishu_verification_token",
67+
"managed_by_host": false
6768
}
6869
}
6970
},

channels-src/feishu/src/lib.rs

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ wit_bindgen::generate!({
3333
});
3434

3535
use serde::{Deserialize, Serialize};
36+
use subtle::ConstantTimeEq;
3637

3738
// Re-export generated types
3839
use exports::near::agent::channel::{
@@ -104,6 +105,10 @@ struct FeishuEventHeader {
104105
/// Tenant key.
105106
#[serde(default)]
106107
tenant_key: Option<String>,
108+
109+
/// Verification token for v2 event payloads.
110+
#[serde(default)]
111+
token: Option<String>,
107112
}
108113

109114
/// Message receive event payload (im.message.receive_v1).
@@ -305,11 +310,8 @@ impl Guest for FeishuChannel {
305310
if let Some(ref app_secret) = config.app_secret {
306311
let _ = channel_host::workspace_write(APP_SECRET_PATH, app_secret);
307312
}
308-
if let Some(ref verification_token) = config.verification_token {
309-
let _ = channel_host::workspace_write(VERIFICATION_TOKEN_PATH, verification_token);
310-
} else {
311-
let _ = channel_host::workspace_write(VERIFICATION_TOKEN_PATH, "");
312-
}
313+
let verification_token = config.verification_token.as_deref().unwrap_or("");
314+
let _ = channel_host::workspace_write(VERIFICATION_TOKEN_PATH, verification_token);
313315

314316
if let Some(owner_id) = &config.owner_id {
315317
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
@@ -391,7 +393,7 @@ impl Guest for FeishuChannel {
391393
if !is_authenticated_webhook(
392394
req.secret_validated,
393395
configured_token.as_deref(),
394-
event.token.as_deref(),
396+
request_verification_token(&event),
395397
) {
396398
channel_host::log(
397399
channel_host::LogLevel::Warn,
@@ -876,11 +878,21 @@ fn is_authenticated_webhook(
876878
}
877879

878880
match (configured_token, request_token) {
879-
(Some(expected), Some(provided)) => expected == provided,
881+
(Some(expected), Some(provided)) => {
882+
bool::from(expected.as_bytes().ct_eq(provided.as_bytes()))
883+
}
880884
_ => false,
881885
}
882886
}
883887

888+
fn request_verification_token(event: &FeishuEvent) -> Option<&str> {
889+
event
890+
.header
891+
.as_ref()
892+
.and_then(|header| header.token.as_deref())
893+
.or(event.token.as_deref())
894+
}
895+
884896
#[cfg(test)]
885897
mod tests {
886898
use super::*;
@@ -967,4 +979,36 @@ mod tests {
967979
"host authentication should take precedence over body token checks"
968980
);
969981
}
982+
983+
#[test]
984+
fn request_verification_token_prefers_v2_header_token() {
985+
let event: FeishuEvent = serde_json::from_str(
986+
r#"{
987+
"schema": "2.0",
988+
"header": {
989+
"event_id": "evt_123",
990+
"event_type": "im.message.receive_v1",
991+
"token": "header-token"
992+
},
993+
"event": {}
994+
}"#,
995+
)
996+
.unwrap();
997+
998+
assert_eq!(request_verification_token(&event), Some("header-token"));
999+
}
1000+
1001+
#[test]
1002+
fn request_verification_token_falls_back_to_top_level_token() {
1003+
let event: FeishuEvent = serde_json::from_str(
1004+
r#"{
1005+
"type": "url_verification",
1006+
"challenge": "abc",
1007+
"token": "top-level-token"
1008+
}"#,
1009+
)
1010+
.unwrap();
1011+
1012+
assert_eq!(request_verification_token(&event), Some("top-level-token"));
1013+
}
9701014
}

src/channels/wasm/loader.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,14 @@ impl LoadedChannel {
317317
.map(|f| f.webhook_secret_name())
318318
.unwrap_or_else(|| format!("{}_webhook_secret", self.channel.channel_name()))
319319
}
320+
321+
/// Whether the host should enforce generic webhook-secret validation.
322+
pub fn webhook_secret_managed_by_host(&self) -> bool {
323+
self.capabilities_file
324+
.as_ref()
325+
.map(|f| f.webhook_secret_managed_by_host())
326+
.unwrap_or(true)
327+
}
320328
}
321329

322330
/// Results from loading multiple channels.

src/channels/wasm/schema.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,19 @@ impl ChannelCapabilitiesFile {
185185
.and_then(|w| w.secret_name.clone())
186186
.unwrap_or_else(|| format!("{}_webhook_secret", self.name))
187187
}
188+
189+
/// Whether the host should enforce generic webhook-secret validation.
190+
///
191+
/// Defaults to true. Channels can opt out when they validate the shared
192+
/// secret themselves using provider-specific request body fields.
193+
pub fn webhook_secret_managed_by_host(&self) -> bool {
194+
self.capabilities
195+
.channel
196+
.as_ref()
197+
.and_then(|c| c.webhook.as_ref())
198+
.and_then(|w| w.managed_by_host)
199+
.unwrap_or(true)
200+
}
188201
}
189202

190203
/// Schema for channel capabilities.
@@ -302,6 +315,14 @@ pub struct WebhookSchema {
302315
/// Secret name in secrets store for HMAC-SHA256 signing (Slack-style).
303316
#[serde(default)]
304317
pub hmac_secret_name: Option<String>,
318+
319+
/// Whether the host/router should enforce generic webhook-secret
320+
/// validation before the channel sees the request.
321+
///
322+
/// Default: true. Set to false when the provider sends the shared secret
323+
/// in a provider-specific request field rather than the configured header.
324+
#[serde(default)]
325+
pub managed_by_host: Option<bool>,
305326
}
306327

307328
/// Setup configuration schema.
@@ -611,6 +632,25 @@ mod tests {
611632
Some("X-Telegram-Bot-Api-Secret-Token")
612633
);
613634
assert_eq!(file.webhook_secret_name(), "telegram_webhook_secret");
635+
assert!(file.webhook_secret_managed_by_host());
636+
}
637+
638+
#[test]
639+
fn test_webhook_schema_can_disable_host_managed_secret_validation() {
640+
let json = r#"{
641+
"name": "feishu",
642+
"capabilities": {
643+
"channel": {
644+
"webhook": {
645+
"secret_name": "feishu_verification_token",
646+
"managed_by_host": false
647+
}
648+
}
649+
}
650+
}"#;
651+
652+
let file = ChannelCapabilitiesFile::from_json(json).unwrap();
653+
assert!(!file.webhook_secret_managed_by_host());
614654
}
615655

616656
#[test]

src/channels/wasm/setup.rs

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,11 @@ async fn register_channel(
139139
};
140140

141141
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
142-
let host_webhook_secret = host_managed_webhook_secret(&channel_name, webhook_secret.clone());
142+
let host_webhook_secret = if loaded.webhook_secret_managed_by_host() {
143+
webhook_secret.clone()
144+
} else {
145+
None
146+
};
143147

144148
let webhook_path = format!("/webhook/{}", channel_name);
145149
let endpoints = vec![RegisteredEndpoint {
@@ -385,17 +389,6 @@ pub async fn inject_channel_credentials(
385389
Ok(count)
386390
}
387391

388-
fn host_managed_webhook_secret(
389-
channel_name: &str,
390-
webhook_secret: Option<String>,
391-
) -> Option<String> {
392-
if channel_name == "feishu" {
393-
None
394-
} else {
395-
webhook_secret
396-
}
397-
}
398-
399392
/// Inject channel-specific secrets into the config JSON.
400393
///
401394
/// Some channels (e.g., Feishu) need raw credential values in their config

0 commit comments

Comments
 (0)