mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* refactor: add explicit owner scope across channels * fix: tighten routine owner target routing * fix: address owner scope review feedback * Fix owner-scope onboarding and event trigger isolation * Tighten routing fallback and wizard owner validation * fix: address owner-scope follow-up review * fix: tighten owner-scope follow-up details * fix: import Channel trait in telegram test * fix: normalize http webhook sender ids * fix: address remaining owner-scope review issues * fix: reconcile config rebase fallout * fix: reconcile extension manager rebase drift * fix: address current copilot review regressions * fix: restore clippy matrix after rebase
1504 lines
51 KiB
Rust
1504 lines
51 KiB
Rust
//! HTTP router for WASM channel webhooks.
|
|
//!
|
|
//! Routes incoming HTTP requests to the appropriate WASM channel based on
|
|
//! registered paths. Handles secret validation at the host level.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use axum::{
|
|
Json, Router,
|
|
body::Bytes,
|
|
extract::{Path, Query, State},
|
|
http::{HeaderMap, Method, StatusCode},
|
|
response::IntoResponse,
|
|
routing::{get, post},
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::RwLock;
|
|
|
|
use crate::channels::wasm::wrapper::WasmChannel;
|
|
|
|
/// A registered HTTP endpoint for a WASM channel.
|
|
#[derive(Debug, Clone)]
|
|
pub struct RegisteredEndpoint {
|
|
/// Channel name that owns this endpoint.
|
|
pub channel_name: String,
|
|
/// HTTP path (e.g., "/webhook/slack").
|
|
pub path: String,
|
|
/// Allowed HTTP methods.
|
|
pub methods: Vec<String>,
|
|
/// Whether secret validation is required.
|
|
pub require_secret: bool,
|
|
}
|
|
|
|
/// Router for WASM channel HTTP endpoints.
|
|
pub struct WasmChannelRouter {
|
|
/// Registered channels by name.
|
|
channels: RwLock<HashMap<String, Arc<WasmChannel>>>,
|
|
/// Path to channel mapping for fast lookup.
|
|
path_to_channel: RwLock<HashMap<String, String>>,
|
|
/// Expected webhook secrets by channel name.
|
|
secrets: RwLock<HashMap<String, String>>,
|
|
/// Webhook secret header names by channel name (e.g., "X-Telegram-Bot-Api-Secret-Token").
|
|
secret_headers: RwLock<HashMap<String, String>>,
|
|
/// Ed25519 public keys for signature verification by channel name (hex-encoded).
|
|
signature_keys: RwLock<HashMap<String, String>>,
|
|
/// HMAC-SHA256 signing secrets for signature verification by channel name (Slack-style).
|
|
hmac_secrets: RwLock<HashMap<String, String>>,
|
|
}
|
|
|
|
impl WasmChannelRouter {
|
|
/// Create a new router.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
channels: RwLock::new(HashMap::new()),
|
|
path_to_channel: RwLock::new(HashMap::new()),
|
|
secrets: RwLock::new(HashMap::new()),
|
|
secret_headers: RwLock::new(HashMap::new()),
|
|
signature_keys: RwLock::new(HashMap::new()),
|
|
hmac_secrets: RwLock::new(HashMap::new()),
|
|
}
|
|
}
|
|
|
|
/// Register a channel with its endpoints.
|
|
///
|
|
/// # Arguments
|
|
/// * `channel` - The WASM channel to register
|
|
/// * `endpoints` - HTTP endpoints to register for this channel
|
|
/// * `secret` - Optional webhook secret for validation
|
|
/// * `secret_header` - Optional HTTP header name for secret validation
|
|
/// (e.g., "X-Telegram-Bot-Api-Secret-Token"). Defaults to "X-Webhook-Secret".
|
|
pub async fn register(
|
|
&self,
|
|
channel: Arc<WasmChannel>,
|
|
endpoints: Vec<RegisteredEndpoint>,
|
|
secret: Option<String>,
|
|
secret_header: Option<String>,
|
|
) {
|
|
let name = channel.channel_name().to_string();
|
|
|
|
// Store the channel
|
|
self.channels.write().await.insert(name.clone(), channel);
|
|
|
|
// Register path mappings
|
|
let mut path_map = self.path_to_channel.write().await;
|
|
for endpoint in endpoints {
|
|
path_map.insert(endpoint.path.clone(), name.clone());
|
|
tracing::info!(
|
|
channel = %name,
|
|
path = %endpoint.path,
|
|
methods = ?endpoint.methods,
|
|
"Registered WASM channel HTTP endpoint"
|
|
);
|
|
}
|
|
|
|
// Store secret if provided
|
|
if let Some(s) = secret {
|
|
self.secrets.write().await.insert(name.clone(), s);
|
|
}
|
|
|
|
// Store secret header if provided
|
|
if let Some(h) = secret_header {
|
|
self.secret_headers.write().await.insert(name, h);
|
|
}
|
|
}
|
|
|
|
/// Get the secret header name for a channel.
|
|
///
|
|
/// Returns the configured header or "X-Webhook-Secret" as default.
|
|
pub async fn get_secret_header(&self, channel_name: &str) -> String {
|
|
self.secret_headers
|
|
.read()
|
|
.await
|
|
.get(channel_name)
|
|
.cloned()
|
|
.unwrap_or_else(|| "X-Webhook-Secret".to_string())
|
|
}
|
|
|
|
/// Update the webhook secret for an already-registered channel.
|
|
///
|
|
/// This is used when credentials are saved after a channel was registered
|
|
/// without a secret (e.g., loaded at startup before the user configured it).
|
|
pub async fn update_secret(&self, channel_name: &str, secret: String) {
|
|
self.secrets
|
|
.write()
|
|
.await
|
|
.insert(channel_name.to_string(), secret);
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
"Updated webhook secret for channel"
|
|
);
|
|
}
|
|
|
|
/// Unregister a channel and its endpoints.
|
|
pub async fn unregister(&self, channel_name: &str) {
|
|
self.channels.write().await.remove(channel_name);
|
|
self.secrets.write().await.remove(channel_name);
|
|
self.secret_headers.write().await.remove(channel_name);
|
|
self.signature_keys.write().await.remove(channel_name);
|
|
self.hmac_secrets.write().await.remove(channel_name);
|
|
|
|
// Remove all paths for this channel
|
|
self.path_to_channel
|
|
.write()
|
|
.await
|
|
.retain(|_, name| name != channel_name);
|
|
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
"Unregistered WASM channel"
|
|
);
|
|
}
|
|
|
|
/// Get the channel for a given path.
|
|
pub async fn get_channel_for_path(&self, path: &str) -> Option<Arc<WasmChannel>> {
|
|
let path_map = self.path_to_channel.read().await;
|
|
let channel_name = path_map.get(path)?;
|
|
|
|
self.channels.read().await.get(channel_name).cloned()
|
|
}
|
|
|
|
/// Validate a secret for a channel.
|
|
pub async fn validate_secret(&self, channel_name: &str, provided: &str) -> bool {
|
|
let secrets = self.secrets.read().await;
|
|
match secrets.get(channel_name) {
|
|
Some(expected) => expected == provided,
|
|
None => true, // No secret required
|
|
}
|
|
}
|
|
|
|
/// Check if a channel requires a secret.
|
|
pub async fn requires_secret(&self, channel_name: &str) -> bool {
|
|
self.secrets.read().await.contains_key(channel_name)
|
|
}
|
|
|
|
/// List all registered channels.
|
|
pub async fn list_channels(&self) -> Vec<String> {
|
|
self.channels.read().await.keys().cloned().collect()
|
|
}
|
|
|
|
/// List all registered paths.
|
|
pub async fn list_paths(&self) -> Vec<String> {
|
|
self.path_to_channel.read().await.keys().cloned().collect()
|
|
}
|
|
|
|
/// Register an Ed25519 public key for signature verification.
|
|
///
|
|
/// Validates that the key is valid hex encoding of a 32-byte Ed25519 public key.
|
|
/// Channels with a registered key will have Discord-style Ed25519
|
|
/// signature validation performed before forwarding to WASM.
|
|
pub async fn register_signature_key(
|
|
&self,
|
|
channel_name: &str,
|
|
public_key_hex: &str,
|
|
) -> Result<(), String> {
|
|
use ed25519_dalek::VerifyingKey;
|
|
|
|
let key_bytes = hex::decode(public_key_hex).map_err(|e| format!("invalid hex: {e}"))?;
|
|
VerifyingKey::try_from(key_bytes.as_slice())
|
|
.map_err(|e| format!("invalid Ed25519 public key: {e}"))?;
|
|
|
|
self.signature_keys
|
|
.write()
|
|
.await
|
|
.insert(channel_name.to_string(), public_key_hex.to_string());
|
|
Ok(())
|
|
}
|
|
|
|
/// Get the signature verification key for a channel.
|
|
///
|
|
/// Returns `None` if no key is registered (no signature check needed).
|
|
pub async fn get_signature_key(&self, channel_name: &str) -> Option<String> {
|
|
self.signature_keys.read().await.get(channel_name).cloned()
|
|
}
|
|
|
|
/// Register an HMAC-SHA256 signing secret for signature verification.
|
|
///
|
|
/// Channels with a registered secret will have Slack-style HMAC-SHA256
|
|
/// signature validation performed before forwarding to WASM.
|
|
pub async fn register_hmac_secret(&self, channel_name: &str, secret: &str) {
|
|
self.hmac_secrets
|
|
.write()
|
|
.await
|
|
.insert(channel_name.to_string(), secret.to_string());
|
|
}
|
|
|
|
/// Get the HMAC signing secret for a channel.
|
|
///
|
|
/// Returns `None` if no secret is registered (no HMAC check needed).
|
|
pub async fn get_hmac_secret(&self, channel_name: &str) -> Option<String> {
|
|
self.hmac_secrets.read().await.get(channel_name).cloned()
|
|
}
|
|
}
|
|
|
|
impl Default for WasmChannelRouter {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Shared state for the HTTP server.
|
|
#[allow(dead_code)]
|
|
#[derive(Clone)]
|
|
pub struct RouterState {
|
|
router: Arc<WasmChannelRouter>,
|
|
extension_manager: Option<Arc<crate::extensions::ExtensionManager>>,
|
|
}
|
|
|
|
impl RouterState {
|
|
pub fn new(router: Arc<WasmChannelRouter>) -> Self {
|
|
Self {
|
|
router,
|
|
extension_manager: None,
|
|
}
|
|
}
|
|
|
|
pub fn with_extension_manager(
|
|
mut self,
|
|
manager: Arc<crate::extensions::ExtensionManager>,
|
|
) -> Self {
|
|
self.extension_manager = Some(manager);
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Webhook request body for WASM channels.
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct WasmWebhookRequest {
|
|
/// Optional secret for authentication.
|
|
#[serde(default)]
|
|
pub secret: Option<String>,
|
|
}
|
|
|
|
/// Health response.
|
|
#[allow(dead_code)]
|
|
#[derive(Debug, Serialize)]
|
|
struct HealthResponse {
|
|
status: String,
|
|
channels: Vec<String>,
|
|
}
|
|
|
|
/// Handler for health check endpoint.
|
|
#[allow(dead_code)]
|
|
async fn health_handler(State(state): State<RouterState>) -> impl IntoResponse {
|
|
let channels = state.router.list_channels().await;
|
|
Json(HealthResponse {
|
|
status: "healthy".to_string(),
|
|
channels,
|
|
})
|
|
}
|
|
|
|
/// Generic webhook handler that routes to the appropriate WASM channel.
|
|
async fn webhook_handler(
|
|
State(state): State<RouterState>,
|
|
method: Method,
|
|
Path(path): Path<String>,
|
|
Query(query): Query<HashMap<String, String>>,
|
|
headers: HeaderMap,
|
|
body: Bytes,
|
|
) -> impl IntoResponse {
|
|
let full_path = format!("/webhook/{}", path);
|
|
|
|
tracing::info!(
|
|
method = %method,
|
|
path = %full_path,
|
|
body_len = body.len(),
|
|
"Webhook request received"
|
|
);
|
|
|
|
// Find the channel for this path
|
|
let channel = match state.router.get_channel_for_path(&full_path).await {
|
|
Some(c) => c,
|
|
None => {
|
|
tracing::warn!(
|
|
path = %full_path,
|
|
"No channel registered for webhook path"
|
|
);
|
|
return (
|
|
StatusCode::NOT_FOUND,
|
|
Json(serde_json::json!({
|
|
"error": "Channel not found for path",
|
|
"path": full_path
|
|
})),
|
|
);
|
|
}
|
|
};
|
|
|
|
tracing::info!(
|
|
channel = %channel.channel_name(),
|
|
"Found channel for webhook"
|
|
);
|
|
|
|
let channel_name = channel.channel_name();
|
|
|
|
// Check if secret is required
|
|
if state.router.requires_secret(channel_name).await {
|
|
// Get the secret header name for this channel (from capabilities or default)
|
|
let secret_header_name = state.router.get_secret_header(channel_name).await;
|
|
|
|
// Try to get secret from query param or the channel's configured header
|
|
let provided_secret = query
|
|
.get("secret")
|
|
.cloned()
|
|
.or_else(|| {
|
|
headers
|
|
.get(&secret_header_name)
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string())
|
|
})
|
|
.or_else(|| {
|
|
// Fallback to generic header if different from configured
|
|
if secret_header_name != "X-Webhook-Secret" {
|
|
headers
|
|
.get("X-Webhook-Secret")
|
|
.and_then(|v| v.to_str().ok())
|
|
.map(|s| s.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
});
|
|
|
|
tracing::debug!(
|
|
channel = %channel_name,
|
|
has_provided_secret = provided_secret.is_some(),
|
|
provided_secret_len = provided_secret.as_ref().map(|s| s.len()),
|
|
"Checking webhook secret"
|
|
);
|
|
|
|
match provided_secret {
|
|
Some(secret) => {
|
|
if !state.router.validate_secret(channel_name, &secret).await {
|
|
tracing::warn!(
|
|
channel = %channel_name,
|
|
"Webhook secret validation failed"
|
|
);
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(serde_json::json!({
|
|
"error": "Invalid webhook secret"
|
|
})),
|
|
);
|
|
}
|
|
tracing::debug!(channel = %channel_name, "Webhook secret validated");
|
|
}
|
|
None => {
|
|
tracing::warn!(
|
|
channel = %channel_name,
|
|
"Webhook secret required but not provided"
|
|
);
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(serde_json::json!({
|
|
"error": "Webhook secret required"
|
|
})),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ed25519 signature verification (Discord-style)
|
|
if let Some(pub_key_hex) = state.router.get_signature_key(channel_name).await {
|
|
let sig_hex = headers
|
|
.get("x-signature-ed25519")
|
|
.and_then(|v| v.to_str().ok());
|
|
let timestamp = headers
|
|
.get("x-signature-timestamp")
|
|
.and_then(|v| v.to_str().ok());
|
|
|
|
match (sig_hex, timestamp) {
|
|
(Some(sig), Some(ts)) => {
|
|
let now_secs = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs() as i64;
|
|
|
|
if !crate::channels::wasm::signature::verify_discord_signature(
|
|
&pub_key_hex,
|
|
sig,
|
|
ts,
|
|
&body,
|
|
now_secs,
|
|
) {
|
|
tracing::warn!(
|
|
channel = %channel_name,
|
|
"Ed25519 signature verification failed"
|
|
);
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(serde_json::json!({
|
|
"error": "Invalid signature"
|
|
})),
|
|
);
|
|
}
|
|
tracing::debug!(channel = %channel_name, "Ed25519 signature verified");
|
|
}
|
|
_ => {
|
|
tracing::warn!(
|
|
channel = %channel_name,
|
|
"Signature headers missing but key is registered"
|
|
);
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(serde_json::json!({
|
|
"error": "Missing signature headers"
|
|
})),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// HMAC-SHA256 signature verification (Slack-style)
|
|
if let Some(hmac_secret) = state.router.get_hmac_secret(channel_name).await {
|
|
let timestamp = headers
|
|
.get("x-slack-request-timestamp")
|
|
.and_then(|v| v.to_str().ok());
|
|
let sig_header = headers
|
|
.get("x-slack-signature")
|
|
.and_then(|v| v.to_str().ok());
|
|
|
|
match (timestamp, sig_header) {
|
|
(Some(ts), Some(sig)) => {
|
|
let now_secs = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs() as i64;
|
|
|
|
if !crate::channels::wasm::signature::verify_slack_signature(
|
|
&hmac_secret,
|
|
ts,
|
|
&body,
|
|
sig,
|
|
now_secs,
|
|
) {
|
|
tracing::warn!(
|
|
channel = %channel_name,
|
|
"HMAC-SHA256 signature verification failed"
|
|
);
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(serde_json::json!({
|
|
"error": "Invalid Slack signature"
|
|
})),
|
|
);
|
|
}
|
|
tracing::debug!(channel = %channel_name, "HMAC-SHA256 signature verified");
|
|
}
|
|
_ => {
|
|
tracing::warn!(
|
|
channel = %channel_name,
|
|
"Slack signature headers missing but secret is registered"
|
|
);
|
|
return (
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(serde_json::json!({
|
|
"error": "Missing Slack signature headers"
|
|
})),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert headers to HashMap
|
|
let headers_map: HashMap<String, String> = headers
|
|
.iter()
|
|
.filter_map(|(k, v)| {
|
|
v.to_str()
|
|
.ok()
|
|
.map(|v| (k.as_str().to_string(), v.to_string()))
|
|
})
|
|
.collect();
|
|
|
|
// Call the WASM channel
|
|
let secret_validated = state.router.requires_secret(channel_name).await;
|
|
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
secret_validated = secret_validated,
|
|
"Calling WASM channel on_http_request"
|
|
);
|
|
|
|
match channel
|
|
.call_on_http_request(
|
|
method.as_str(),
|
|
&full_path,
|
|
&headers_map,
|
|
&query,
|
|
&body,
|
|
secret_validated,
|
|
)
|
|
.await
|
|
{
|
|
Ok(response) => {
|
|
let status =
|
|
StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
|
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
status = %status,
|
|
body_len = response.body.len(),
|
|
"WASM channel on_http_request completed successfully"
|
|
);
|
|
|
|
// Build response with headers
|
|
let body_json: serde_json::Value = serde_json::from_slice(&response.body)
|
|
.unwrap_or_else(|_| {
|
|
serde_json::json!({
|
|
"raw": String::from_utf8_lossy(&response.body).to_string()
|
|
})
|
|
});
|
|
|
|
(status, Json(body_json))
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
channel = %channel_name,
|
|
error = %e,
|
|
"WASM channel callback failed"
|
|
);
|
|
(
|
|
StatusCode::INTERNAL_SERVER_ERROR,
|
|
Json(serde_json::json!({
|
|
"error": "Channel callback failed",
|
|
"details": e.to_string()
|
|
})),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// OAuth callback handler for extension authentication.
|
|
///
|
|
/// Handles OAuth redirect callbacks at /oauth/callback?code=xxx&state=yyy.
|
|
/// This is used when authenticating MCP servers or WASM tool OAuth flows
|
|
/// via a tunnel URL (remote callback).
|
|
#[allow(dead_code)]
|
|
async fn oauth_callback_handler(
|
|
State(_state): State<RouterState>,
|
|
Query(params): Query<HashMap<String, String>>,
|
|
) -> impl IntoResponse {
|
|
let code = params.get("code").cloned().unwrap_or_default();
|
|
let _state = params.get("state").cloned().unwrap_or_default();
|
|
|
|
if code.is_empty() {
|
|
let error = params
|
|
.get("error")
|
|
.cloned()
|
|
.unwrap_or_else(|| "unknown".to_string());
|
|
return (
|
|
StatusCode::BAD_REQUEST,
|
|
axum::response::Html(format!(
|
|
"<!DOCTYPE html><html><body style=\"font-family: sans-serif; \
|
|
display: flex; justify-content: center; align-items: center; \
|
|
height: 100vh; margin: 0; background: #191919; color: white;\">\
|
|
<div style=\"text-align: center;\">\
|
|
<h1>Authorization Failed</h1>\
|
|
<p>Error: {}</p>\
|
|
</div></body></html>",
|
|
error
|
|
)),
|
|
);
|
|
}
|
|
|
|
// TODO: In a future iteration, use the state nonce to look up the pending auth
|
|
// and complete the token exchange. For now, the OAuth flow uses local callbacks
|
|
// via authorize_mcp_server() which handles the full flow synchronously.
|
|
|
|
(
|
|
StatusCode::OK,
|
|
axum::response::Html(
|
|
"<!DOCTYPE html><html><body style=\"font-family: sans-serif; \
|
|
display: flex; justify-content: center; align-items: center; \
|
|
height: 100vh; margin: 0; background: #191919; color: white;\">\
|
|
<div style=\"text-align: center;\">\
|
|
<h1>Connected!</h1>\
|
|
<p>You can close this window and return to IronClaw.</p>\
|
|
</div></body></html>"
|
|
.to_string(),
|
|
),
|
|
)
|
|
}
|
|
|
|
/// Create an Axum router for WASM channel webhooks.
|
|
///
|
|
/// This router can be merged with the existing HTTP channel router.
|
|
pub fn create_wasm_channel_router(
|
|
router: Arc<WasmChannelRouter>,
|
|
extension_manager: Option<Arc<crate::extensions::ExtensionManager>>,
|
|
) -> Router {
|
|
let mut state = RouterState::new(router);
|
|
if let Some(manager) = extension_manager {
|
|
state = state.with_extension_manager(manager);
|
|
}
|
|
|
|
Router::new()
|
|
.route("/wasm-channels/health", get(health_handler))
|
|
.route("/oauth/callback", get(oauth_callback_handler))
|
|
// Catch-all for webhook paths
|
|
.route("/webhook/{*path}", get(webhook_handler))
|
|
.route("/webhook/{*path}", post(webhook_handler))
|
|
.with_state(state)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
|
use crate::channels::wasm::router::{RegisteredEndpoint, WasmChannelRouter};
|
|
use crate::channels::wasm::runtime::{
|
|
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
|
};
|
|
use crate::channels::wasm::wrapper::WasmChannel;
|
|
use crate::pairing::PairingStore;
|
|
use crate::tools::wasm::ResourceLimits;
|
|
|
|
fn create_test_channel(name: &str) -> Arc<WasmChannel> {
|
|
let config = WasmChannelRuntimeConfig::for_testing();
|
|
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
|
|
|
|
let prepared = Arc::new(PreparedChannelModule {
|
|
name: name.to_string(),
|
|
description: format!("Test channel: {}", name),
|
|
component: None,
|
|
limits: ResourceLimits::default(),
|
|
});
|
|
|
|
let capabilities =
|
|
ChannelCapabilities::for_channel(name).with_path(format!("/webhook/{}", name));
|
|
|
|
Arc::new(WasmChannel::new(
|
|
runtime,
|
|
prepared,
|
|
capabilities,
|
|
"default",
|
|
"{}".to_string(),
|
|
Arc::new(PairingStore::new()),
|
|
None,
|
|
))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_router_register_and_lookup() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("slack");
|
|
|
|
let endpoints = vec![RegisteredEndpoint {
|
|
channel_name: "slack".to_string(),
|
|
path: "/webhook/slack".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: true,
|
|
}];
|
|
|
|
router
|
|
.register(channel, endpoints, Some("secret123".to_string()), None)
|
|
.await;
|
|
|
|
// Should find channel by path
|
|
let found = router.get_channel_for_path("/webhook/slack").await;
|
|
assert!(found.is_some());
|
|
assert_eq!(found.unwrap().channel_name(), "slack");
|
|
|
|
// Should not find non-existent path
|
|
let not_found = router.get_channel_for_path("/webhook/telegram").await;
|
|
assert!(not_found.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_router_secret_validation() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("slack");
|
|
|
|
router
|
|
.register(channel, vec![], Some("secret123".to_string()), None)
|
|
.await;
|
|
|
|
// Correct secret
|
|
assert!(router.validate_secret("slack", "secret123").await);
|
|
|
|
// Wrong secret
|
|
assert!(!router.validate_secret("slack", "wrong").await);
|
|
|
|
// Channel without secret always validates
|
|
let channel2 = create_test_channel("telegram");
|
|
router.register(channel2, vec![], None, None).await;
|
|
assert!(router.validate_secret("telegram", "anything").await);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_router_unregister() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("slack");
|
|
|
|
let endpoints = vec![RegisteredEndpoint {
|
|
channel_name: "slack".to_string(),
|
|
path: "/webhook/slack".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: false,
|
|
}];
|
|
|
|
router.register(channel, endpoints, None, None).await;
|
|
|
|
// Should exist
|
|
assert!(
|
|
router
|
|
.get_channel_for_path("/webhook/slack")
|
|
.await
|
|
.is_some()
|
|
);
|
|
|
|
// Unregister
|
|
router.unregister("slack").await;
|
|
|
|
// Should no longer exist
|
|
assert!(
|
|
router
|
|
.get_channel_for_path("/webhook/slack")
|
|
.await
|
|
.is_none()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_router_list_channels() {
|
|
let router = WasmChannelRouter::new();
|
|
|
|
let channel1 = create_test_channel("slack");
|
|
let channel2 = create_test_channel("telegram");
|
|
|
|
router.register(channel1, vec![], None, None).await;
|
|
router.register(channel2, vec![], None, None).await;
|
|
|
|
let channels = router.list_channels().await;
|
|
assert_eq!(channels.len(), 2);
|
|
assert!(channels.contains(&"slack".to_string()));
|
|
assert!(channels.contains(&"telegram".to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_router_secret_header() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("telegram");
|
|
|
|
// Register with custom secret header
|
|
router
|
|
.register(
|
|
channel,
|
|
vec![],
|
|
Some("secret123".to_string()),
|
|
Some("X-Telegram-Bot-Api-Secret-Token".to_string()),
|
|
)
|
|
.await;
|
|
|
|
// Should return the custom header
|
|
assert_eq!(
|
|
router.get_secret_header("telegram").await,
|
|
"X-Telegram-Bot-Api-Secret-Token"
|
|
);
|
|
|
|
// Channel without custom header should use default
|
|
let channel2 = create_test_channel("slack");
|
|
router
|
|
.register(channel2, vec![], Some("secret456".to_string()), None)
|
|
.await;
|
|
assert_eq!(router.get_secret_header("slack").await, "X-Webhook-Secret");
|
|
}
|
|
|
|
// ── Category 3: Router HMAC Secret Management ───────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_register_and_get_hmac_secret() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("slack");
|
|
|
|
router.register(channel, vec![], None, None).await;
|
|
|
|
let hmac_secret = "my-slack-signing-secret";
|
|
router.register_hmac_secret("slack", hmac_secret).await;
|
|
|
|
let retrieved = router.get_hmac_secret("slack").await;
|
|
assert_eq!(retrieved, Some(hmac_secret.to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_no_hmac_secret_returns_none() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("slack");
|
|
router.register(channel, vec![], None, None).await;
|
|
|
|
// Slack has no HMAC secret registered
|
|
let secret = router.get_hmac_secret("slack").await;
|
|
assert!(secret.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_unregister_removes_hmac_secret() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("slack");
|
|
|
|
let endpoints = vec![RegisteredEndpoint {
|
|
channel_name: "slack".to_string(),
|
|
path: "/webhook/slack".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: false,
|
|
}];
|
|
|
|
router.register(channel, endpoints, None, None).await;
|
|
router.register_hmac_secret("slack", "signing-secret").await;
|
|
|
|
// Secret should exist
|
|
assert!(router.get_hmac_secret("slack").await.is_some());
|
|
|
|
// Unregister
|
|
router.unregister("slack").await;
|
|
|
|
// Secret should be gone
|
|
assert!(router.get_hmac_secret("slack").await.is_none());
|
|
}
|
|
|
|
// ── Category 4: Router Signature Key Management ─────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_register_and_get_signature_key() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("discord");
|
|
|
|
router.register(channel, vec![], None, None).await;
|
|
|
|
let fake_pub_key = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2";
|
|
router
|
|
.register_signature_key("discord", fake_pub_key)
|
|
.await
|
|
.unwrap();
|
|
|
|
let key = router.get_signature_key("discord").await;
|
|
assert_eq!(key, Some(fake_pub_key.to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_no_signature_key_returns_none() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("slack");
|
|
router.register(channel, vec![], None, None).await;
|
|
|
|
// Slack has no signature key registered
|
|
let key = router.get_signature_key("slack").await;
|
|
assert!(key.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_unregister_removes_signature_key() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("discord");
|
|
|
|
let endpoints = vec![RegisteredEndpoint {
|
|
channel_name: "discord".to_string(),
|
|
path: "/webhook/discord".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: false,
|
|
}];
|
|
|
|
router.register(channel, endpoints, None, None).await;
|
|
// Use a valid 32-byte Ed25519 key for this test
|
|
let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602";
|
|
router
|
|
.register_signature_key("discord", valid_key)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Key should exist
|
|
assert!(router.get_signature_key("discord").await.is_some());
|
|
|
|
// Unregister
|
|
router.unregister("discord").await;
|
|
|
|
// Key should be gone
|
|
assert!(router.get_signature_key("discord").await.is_none());
|
|
}
|
|
|
|
// ── Key Validation Tests ──────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_register_valid_signature_key_succeeds() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("discord");
|
|
router.register(channel, vec![], None, None).await;
|
|
|
|
// Valid 32-byte Ed25519 public key (from test keypair)
|
|
let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602";
|
|
let result = router.register_signature_key("discord", valid_key).await;
|
|
assert!(result.is_ok(), "Valid Ed25519 key should be accepted");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_register_invalid_hex_key_fails() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("discord");
|
|
router.register(channel, vec![], None, None).await;
|
|
|
|
let result = router
|
|
.register_signature_key("discord", "not-valid-hex-zzz")
|
|
.await;
|
|
assert!(result.is_err(), "Invalid hex should be rejected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_register_wrong_length_key_fails() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("discord");
|
|
router.register(channel, vec![], None, None).await;
|
|
|
|
// 16 bytes instead of 32
|
|
let short_key = hex::encode([0u8; 16]);
|
|
let result = router.register_signature_key("discord", &short_key).await;
|
|
assert!(result.is_err(), "Wrong-length key should be rejected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_register_empty_key_fails() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("discord");
|
|
router.register(channel, vec![], None, None).await;
|
|
|
|
let result = router.register_signature_key("discord", "").await;
|
|
assert!(result.is_err(), "Empty key should be rejected");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_valid_key_is_retrievable() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("discord");
|
|
router.register(channel, vec![], None, None).await;
|
|
|
|
let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602";
|
|
router
|
|
.register_signature_key("discord", valid_key)
|
|
.await
|
|
.unwrap();
|
|
|
|
let stored = router.get_signature_key("discord").await;
|
|
assert_eq!(stored, Some(valid_key.to_string()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_invalid_key_does_not_store() {
|
|
let router = WasmChannelRouter::new();
|
|
let channel = create_test_channel("discord");
|
|
router.register(channel, vec![], None, None).await;
|
|
|
|
// Attempt to register invalid key
|
|
let _ = router
|
|
.register_signature_key("discord", "not-valid-hex")
|
|
.await;
|
|
|
|
// Should not have stored anything
|
|
let stored = router.get_signature_key("discord").await;
|
|
assert!(stored.is_none(), "Invalid key should not be stored");
|
|
}
|
|
|
|
// ── Webhook Handler Integration Tests ─────────────────────────────
|
|
|
|
use axum::Router as AxumRouter;
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use tower::ServiceExt;
|
|
|
|
use crate::channels::wasm::router::create_wasm_channel_router;
|
|
use ed25519_dalek::{Signer, SigningKey};
|
|
|
|
/// Helper to create a router with a registered channel at /webhook/discord.
|
|
async fn setup_discord_router() -> (Arc<WasmChannelRouter>, AxumRouter) {
|
|
let wasm_router = Arc::new(WasmChannelRouter::new());
|
|
let channel = create_test_channel("discord");
|
|
|
|
let endpoints = vec![RegisteredEndpoint {
|
|
channel_name: "discord".to_string(),
|
|
path: "/webhook/discord".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: false,
|
|
}];
|
|
|
|
wasm_router.register(channel, endpoints, None, None).await;
|
|
|
|
let app = create_wasm_channel_router(wasm_router.clone(), None);
|
|
(wasm_router, app)
|
|
}
|
|
|
|
/// Helper: generate a test keypair.
|
|
fn test_signing_key() -> SigningKey {
|
|
SigningKey::from_bytes(&[
|
|
0x9d, 0x61, 0xb1, 0x9d, 0xef, 0xfd, 0x5a, 0x60, 0xba, 0x84, 0x4a, 0xf4, 0x92, 0xec,
|
|
0x2c, 0xc4, 0x44, 0x49, 0xc5, 0x69, 0x7b, 0x32, 0x69, 0x19, 0x70, 0x3b, 0xac, 0x03,
|
|
0x1c, 0xae, 0x7f, 0x60,
|
|
])
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_rejects_missing_sig_headers() {
|
|
let (wasm_router, app) = setup_discord_router().await;
|
|
|
|
// Register a signature key
|
|
let signing_key = test_signing_key();
|
|
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
|
wasm_router
|
|
.register_signature_key("discord", &pub_key_hex)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Send request without signature headers
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/discord")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"type":1}"#))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Missing signature headers should return 401"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_rejects_invalid_signature() {
|
|
let (wasm_router, app) = setup_discord_router().await;
|
|
|
|
let signing_key = test_signing_key();
|
|
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
|
wasm_router
|
|
.register_signature_key("discord", &pub_key_hex)
|
|
.await
|
|
.unwrap();
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/discord")
|
|
.header("content-type", "application/json")
|
|
.header("x-signature-ed25519", "deadbeefdeadbeef")
|
|
.header("x-signature-timestamp", "1234567890")
|
|
.body(Body::from(r#"{"type":1}"#))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Invalid signature should return 401"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_accepts_valid_signature() {
|
|
let (wasm_router, app) = setup_discord_router().await;
|
|
|
|
let signing_key = test_signing_key();
|
|
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
|
wasm_router
|
|
.register_signature_key("discord", &pub_key_hex)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Use current timestamp so staleness check passes
|
|
let now_secs = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
let timestamp = now_secs.to_string();
|
|
let body_bytes = br#"{"type":1}"#;
|
|
|
|
let mut message = Vec::new();
|
|
message.extend_from_slice(timestamp.as_bytes());
|
|
message.extend_from_slice(body_bytes);
|
|
let signature = signing_key.sign(&message);
|
|
let sig_hex = hex::encode(signature.to_bytes());
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/discord")
|
|
.header("content-type", "application/json")
|
|
.header("x-signature-ed25519", &sig_hex)
|
|
.header("x-signature-timestamp", ×tamp)
|
|
.body(Body::from(&body_bytes[..]))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
// Should NOT be 401 — signature is valid (may be 500 since no WASM module)
|
|
assert_ne!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Valid signature should not return 401"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_skips_sig_for_no_key() {
|
|
let (_wasm_router, app) = setup_discord_router().await;
|
|
|
|
// No signature key registered — should not require signature
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/discord")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"type":1}"#))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
// Should NOT be 401 (may be 500 since no WASM module, but not auth failure)
|
|
assert_ne!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"No signature key registered — should skip sig check"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_sig_check_uses_body() {
|
|
let (wasm_router, app) = setup_discord_router().await;
|
|
|
|
let signing_key = test_signing_key();
|
|
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
|
wasm_router
|
|
.register_signature_key("discord", &pub_key_hex)
|
|
.await
|
|
.unwrap();
|
|
|
|
let timestamp = "1234567890";
|
|
// Sign body A
|
|
let body_a = br#"{"type":1}"#;
|
|
let mut message = Vec::new();
|
|
message.extend_from_slice(timestamp.as_bytes());
|
|
message.extend_from_slice(body_a);
|
|
let signature = signing_key.sign(&message);
|
|
let sig_hex = hex::encode(signature.to_bytes());
|
|
|
|
// But send body B
|
|
let body_b = br#"{"type":2}"#;
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/discord")
|
|
.header("content-type", "application/json")
|
|
.header("x-signature-ed25519", &sig_hex)
|
|
.header("x-signature-timestamp", timestamp)
|
|
.body(Body::from(&body_b[..]))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Signature for different body should return 401"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_sig_check_uses_timestamp() {
|
|
let (wasm_router, app) = setup_discord_router().await;
|
|
|
|
let signing_key = test_signing_key();
|
|
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
|
wasm_router
|
|
.register_signature_key("discord", &pub_key_hex)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Sign with timestamp A
|
|
let timestamp_a = "1234567890";
|
|
let body = br#"{"type":1}"#;
|
|
let mut message = Vec::new();
|
|
message.extend_from_slice(timestamp_a.as_bytes());
|
|
message.extend_from_slice(body);
|
|
let signature = signing_key.sign(&message);
|
|
let sig_hex = hex::encode(signature.to_bytes());
|
|
|
|
// But send timestamp B in the header
|
|
let timestamp_b = "9999999999";
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/discord")
|
|
.header("content-type", "application/json")
|
|
.header("x-signature-ed25519", &sig_hex)
|
|
.header("x-signature-timestamp", timestamp_b)
|
|
.body(Body::from(&body[..]))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Signature with mismatched timestamp should return 401"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_sig_plus_secret() {
|
|
let wasm_router = Arc::new(WasmChannelRouter::new());
|
|
let channel = create_test_channel("discord");
|
|
|
|
let endpoints = vec![RegisteredEndpoint {
|
|
channel_name: "discord".to_string(),
|
|
path: "/webhook/discord".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: true,
|
|
}];
|
|
|
|
// Register with BOTH secret and signature key
|
|
wasm_router
|
|
.register(channel, endpoints, Some("my-secret".to_string()), None)
|
|
.await;
|
|
|
|
let signing_key = test_signing_key();
|
|
let pub_key_hex = hex::encode(signing_key.verifying_key().to_bytes());
|
|
wasm_router
|
|
.register_signature_key("discord", &pub_key_hex)
|
|
.await
|
|
.unwrap();
|
|
|
|
let app = create_wasm_channel_router(wasm_router.clone(), None);
|
|
|
|
// Use current timestamp so staleness check passes
|
|
let now_secs = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
let timestamp = now_secs.to_string();
|
|
let body = br#"{"type":1}"#;
|
|
let mut message = Vec::new();
|
|
message.extend_from_slice(timestamp.as_bytes());
|
|
message.extend_from_slice(body);
|
|
let signature = signing_key.sign(&message);
|
|
let sig_hex = hex::encode(signature.to_bytes());
|
|
|
|
// Provide valid signature AND valid secret
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/discord?secret=my-secret")
|
|
.header("content-type", "application/json")
|
|
.header("x-signature-ed25519", &sig_hex)
|
|
.header("x-signature-timestamp", ×tamp)
|
|
.body(Body::from(&body[..]))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
// Should pass both checks (may be 500 due to no WASM module, but not 401)
|
|
assert_ne!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Valid secret + valid signature should not return 401"
|
|
);
|
|
}
|
|
|
|
// ── HMAC-SHA256 Webhook Signature Tests ────────────────────────────
|
|
|
|
/// Helper to create a router with a registered channel at /webhook/slack.
|
|
async fn setup_slack_router() -> (Arc<WasmChannelRouter>, AxumRouter) {
|
|
let wasm_router = Arc::new(WasmChannelRouter::new());
|
|
let channel = create_test_channel("slack");
|
|
|
|
let endpoints = vec![RegisteredEndpoint {
|
|
channel_name: "slack".to_string(),
|
|
path: "/webhook/slack".to_string(),
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: false,
|
|
}];
|
|
|
|
wasm_router.register(channel, endpoints, None, None).await;
|
|
|
|
let app = create_wasm_channel_router(wasm_router.clone(), None);
|
|
(wasm_router, app)
|
|
}
|
|
|
|
/// Helper: compute expected Slack signature for testing.
|
|
fn slack_signature(signing_secret: &str, timestamp: &str, body: &[u8]) -> String {
|
|
use hmac::{Hmac, Mac};
|
|
use sha2::Sha256;
|
|
|
|
let mut basestring = Vec::new();
|
|
basestring.extend_from_slice(b"v0:");
|
|
basestring.extend_from_slice(timestamp.as_bytes());
|
|
basestring.push(b':');
|
|
basestring.extend_from_slice(body);
|
|
|
|
let mut mac = Hmac::<Sha256>::new_from_slice(signing_secret.as_bytes()).unwrap();
|
|
mac.update(&basestring);
|
|
let computed = mac.finalize().into_bytes();
|
|
format!("v0={}", hex::encode(computed))
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_hmac_rejects_missing_sig_headers() {
|
|
let (wasm_router, app) = setup_slack_router().await;
|
|
|
|
wasm_router
|
|
.register_hmac_secret("slack", "my-signing-secret")
|
|
.await;
|
|
|
|
// Send request without HMAC signature headers
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/slack")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("token=xyzz0WbapA4vBCDEFasx0q6G"))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Missing HMAC signature headers should return 401"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_hmac_rejects_invalid_signature() {
|
|
let (wasm_router, app) = setup_slack_router().await;
|
|
|
|
wasm_router
|
|
.register_hmac_secret("slack", "my-signing-secret")
|
|
.await;
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/slack")
|
|
.header("content-type", "application/json")
|
|
.header("x-slack-request-timestamp", "1234567890")
|
|
.header("x-slack-signature", "v0=deadbeefdeadbeef")
|
|
.body(Body::from("token=xyzz0WbapA4vBCDEFasx0q6G"))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Invalid HMAC signature should return 401"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_hmac_accepts_valid_signature() {
|
|
let (wasm_router, app) = setup_slack_router().await;
|
|
|
|
let signing_secret = "my-signing-secret";
|
|
wasm_router
|
|
.register_hmac_secret("slack", signing_secret)
|
|
.await;
|
|
|
|
let now_secs = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
let timestamp = now_secs.to_string();
|
|
let body = b"token=xyzz0WbapA4vBCDEFasx0q6G";
|
|
|
|
let signature = slack_signature(signing_secret, ×tamp, body);
|
|
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/slack")
|
|
.header("content-type", "application/json")
|
|
.header("x-slack-request-timestamp", ×tamp)
|
|
.header("x-slack-signature", &signature)
|
|
.body(Body::from(&body[..]))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
// Should NOT be 401 — signature is valid (may be 500 since no WASM module)
|
|
assert_ne!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Valid HMAC signature should not return 401"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_hmac_skips_check_for_no_secret() {
|
|
let (_wasm_router, app) = setup_slack_router().await;
|
|
|
|
// No HMAC secret registered — should not require signature
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/slack")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from("token=xyzz0WbapA4vBCDEFasx0q6G"))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
// Should NOT be 401 (may be 500 since no WASM module, but not auth failure)
|
|
assert_ne!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"No HMAC secret registered — should skip check"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_hmac_uses_correct_body() {
|
|
let (wasm_router, app) = setup_slack_router().await;
|
|
|
|
let signing_secret = "my-signing-secret";
|
|
wasm_router
|
|
.register_hmac_secret("slack", signing_secret)
|
|
.await;
|
|
|
|
let timestamp = "1234567890";
|
|
let body_a = b"token=xyzz0WbapA4vBCDEFasx0q6G";
|
|
let body_b = b"token=MODIFIED";
|
|
|
|
// Sign body A
|
|
let signature = slack_signature(signing_secret, timestamp, body_a);
|
|
|
|
// But send body B
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/slack")
|
|
.header("content-type", "application/json")
|
|
.header("x-slack-request-timestamp", timestamp)
|
|
.header("x-slack-signature", &signature)
|
|
.body(Body::from(&body_b[..]))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Signature for different body should return 401"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_webhook_hmac_uses_correct_timestamp() {
|
|
let (wasm_router, app) = setup_slack_router().await;
|
|
|
|
let signing_secret = "my-signing-secret";
|
|
wasm_router
|
|
.register_hmac_secret("slack", signing_secret)
|
|
.await;
|
|
|
|
let timestamp_a = "1234567890";
|
|
let timestamp_b = "9999999999";
|
|
let body = b"token=xyzz0WbapA4vBCDEFasx0q6G";
|
|
|
|
// Sign with timestamp A
|
|
let signature = slack_signature(signing_secret, timestamp_a, body);
|
|
|
|
// But send timestamp B in the header
|
|
let req = Request::builder()
|
|
.method("POST")
|
|
.uri("/webhook/slack")
|
|
.header("content-type", "application/json")
|
|
.header("x-slack-request-timestamp", timestamp_b)
|
|
.header("x-slack-signature", &signature)
|
|
.body(Body::from(&body[..]))
|
|
.unwrap();
|
|
|
|
let resp = app.oneshot(req).await.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Signature with mismatched timestamp should return 401"
|
|
);
|
|
}
|
|
}
|