Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions channels-src/feishu/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions channels-src/feishu/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 4 additions & 2 deletions channels-src/feishu/feishu.capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
122 changes: 120 additions & 2 deletions channels-src/feishu/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!({
Expand All @@ -32,6 +33,7 @@ wit_bindgen::generate!({
});

use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;

// Re-export generated types
use exports::near::agent::channel::{
Expand All @@ -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";

Expand Down Expand Up @@ -102,6 +105,10 @@ struct FeishuEventHeader {
/// Tenant key.
#[serde(default)]
tenant_key: Option<String>,

/// Verification token for v2 event payloads.
#[serde(default)]
token: Option<String>,
}

/// Message receive event payload (im.message.receive_v1).
Expand Down Expand Up @@ -251,6 +258,9 @@ struct FeishuConfig {
/// Feishu App Secret (for token exchange).
app_secret: Option<String>,

/// Feishu Event Subscription verification token.
verification_token: Option<String>,

/// API base URL. Defaults to "https://open.feishu.cn" (use
/// "https://open.larksuite.com" for Lark international).
#[serde(default = "default_api_base")]
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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::*;
Expand All @@ -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<TenantAccessTokenResponse, _> = 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]
Expand Down Expand Up @@ -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"));
}
}
8 changes: 8 additions & 0 deletions src/channels/wasm/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions src/channels/wasm/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String>,

/// 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<bool>,
}

/// Setup configuration schema.
Expand Down Expand Up @@ -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]
Expand Down
17 changes: 12 additions & 5 deletions src/channels/wasm/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down Expand Up @@ -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"
);
Expand All @@ -214,7 +219,7 @@ async fn register_channel(
.register(
Arc::clone(&channel_arc),
endpoints,
webhook_secret.clone(),
host_webhook_secret.clone(),
secret_header,
)
.await;
Expand Down Expand Up @@ -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<Arc<dyn SecretsStore + Send + Sync>>,
Expand All @@ -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,
};
Expand Down
Loading