fix: require Feishu webhook authentication

This commit is contained in:
serrrfirat
2026-03-25 10:17:25 +03:00
parent 8e48f36f1b
commit 861fedcb33
3 changed files with 96 additions and 8 deletions
+2 -1
View File
@@ -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",
+75 -2
View File
@@ -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<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")]
@@ -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<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]
@@ -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"
);
}
}
+19 -5
View File
@@ -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<String>,
) -> Option<String> {
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<Arc<dyn SecretsStore + Send + Sync>>,
@@ -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,
};