use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tracing::{info, warn};
use crate::daemon::AppState;
#[derive(Deserialize, Debug, Clone)]
pub struct TestWebhookRequest {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub format: Option<crate::chatops::WebhookFormat>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct GenericCallbackPayload {
pub action: String,
pub ticket_id: String,
#[serde(default)]
pub operator: Option<String>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub modified_args: Option<Value>,
}
pub async fn handle_webhook_callback(
State(state): State<AppState>,
headers: HeaderMap,
body_bytes: Bytes,
) -> impl IntoResponse {
let body_str = match std::str::from_utf8(&body_bytes) {
Ok(s) => s,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "ok": false, "error": "Invalid UTF-8 payload" })),
)
.into_response()
}
};
let config = crate::config::load_or_default_config(&state.config_path).unwrap_or_default();
if let Some(ref policy) = config.policy {
if let Some(ref webhook_cfg) = policy.webhook {
if let Some(secret) = webhook_cfg.resolve_secret() {
let sig_header = headers
.get("x-warmplane-signature")
.or_else(|| headers.get("x-slack-signature"))
.and_then(|v| v.to_str().ok());
let ts_header = headers
.get("x-warmplane-timestamp")
.or_else(|| headers.get("x-slack-request-timestamp"))
.and_then(|v| v.to_str().ok());
if let Some(sig) = sig_header {
if !crate::chatops::verify_signature(&secret, body_str, sig, ts_header) {
warn!("Rejecting incoming webhook callback: HMAC signature mismatch");
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "ok": false, "error": "Signature mismatch" })),
)
.into_response();
}
}
}
}
}
let (action, ticket_id, operator, reason, modified_args) =
if let Some(raw_payload) = body_str.strip_prefix("payload=") {
let decoded = url_decode_str(raw_payload);
if let Ok(slack_val) = serde_json::from_str::<Value>(&decoded) {
parse_slack_interaction(&slack_val)
} else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "ok": false, "error": "Invalid Slack payload JSON" })),
)
.into_response();
}
} else if let Ok(generic_val) = serde_json::from_str::<GenericCallbackPayload>(body_str) {
(
generic_val.action,
generic_val.ticket_id,
generic_val
.operator
.unwrap_or_else(|| "chatops-operator".to_string()),
generic_val.reason,
generic_val.modified_args,
)
} else {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "ok": false, "error": "Unrecognized callback payload structure" })),
)
.into_response();
};
if action == "approve" {
match state
.approval_registry
.approve(&ticket_id, operator.clone(), modified_args, None)
.await
{
Ok(true) => {
info!(ticket_id = %ticket_id, operator = %operator, "approved ticket via incoming webhook callback");
(
StatusCode::OK,
Json(json!({
"ok": true,
"status": "approved",
"ticket_id": ticket_id,
"operator": operator,
"text": format!("✅ Approval ticket `{}` was successfully approved by `{}`.", ticket_id, operator)
})),
)
.into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({ "ok": false, "error": "Ticket not found or already resolved" })),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "ok": false, "error": e.to_string() })),
)
.into_response(),
}
} else if action == "reject" {
match state
.approval_registry
.reject(&ticket_id, operator.clone(), reason.clone(), None)
.await
{
Ok(true) => {
info!(ticket_id = %ticket_id, operator = %operator, "rejected ticket via incoming webhook callback");
(
StatusCode::OK,
Json(json!({
"ok": true,
"status": "rejected",
"ticket_id": ticket_id,
"operator": operator,
"reason": reason,
"text": format!("❌ Approval ticket `{}` was rejected by `{}`.", ticket_id, operator)
})),
)
.into_response()
}
Ok(false) => (
StatusCode::NOT_FOUND,
Json(json!({ "ok": false, "error": "Ticket not found or already resolved" })),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "ok": false, "error": e.to_string() })),
)
.into_response(),
}
} else {
(
StatusCode::BAD_REQUEST,
Json(json!({ "ok": false, "error": format!("Unsupported action '{}'", action) })),
)
.into_response()
}
}
fn parse_slack_interaction(val: &Value) -> (String, String, String, Option<String>, Option<Value>) {
let operator = val
.get("user")
.and_then(|u| u.get("username").or_else(|| u.get("name")))
.and_then(Value::as_str)
.unwrap_or("slack-user")
.to_string();
let mut action = "unknown".to_string();
let mut ticket_id = "unknown".to_string();
if let Some(actions) = val.get("actions").and_then(Value::as_array) {
if let Some(first) = actions.first() {
let action_id = first.get("action_id").and_then(Value::as_str).unwrap_or("");
if action_id.contains("approve") {
action = "approve".to_string();
} else if action_id.contains("reject") {
action = "reject".to_string();
}
if let Some(val_str) = first.get("value").and_then(Value::as_str) {
if let Ok(parsed_btn_val) = serde_json::from_str::<Value>(val_str) {
if let Some(t_id) = parsed_btn_val.get("ticket_id").and_then(Value::as_str) {
ticket_id = t_id.to_string();
}
if let Some(act) = parsed_btn_val.get("action").and_then(Value::as_str) {
action = act.to_string();
}
} else {
ticket_id = val_str.to_string();
}
}
}
}
(action, ticket_id, operator, None, None)
}
fn url_decode_str(input: &str) -> String {
let mut result = Vec::new();
let mut bytes = input.bytes();
while let Some(b) = bytes.next() {
match b {
b'+' => result.push(b' '),
b'%' => {
let h1 = bytes.next();
let h2 = bytes.next();
if let (Some(h1), Some(h2)) = (h1, h2) {
if let Ok(hex_byte) =
u8::from_str_radix(&format!("{}{}", h1 as char, h2 as char), 16)
{
result.push(hex_byte);
continue;
}
}
result.push(b'%');
if let Some(h1) = h1 {
result.push(h1);
}
if let Some(h2) = h2 {
result.push(h2);
}
}
other => result.push(other),
}
}
String::from_utf8_lossy(&result).into_owned()
}
pub async fn handle_test_webhook(
State(state): State<AppState>,
Json(payload): Json<TestWebhookRequest>,
) -> impl IntoResponse {
let config = crate::config::load_or_default_config(&state.config_path).unwrap_or_default();
let webhook_cfg = if let Some(ref pol) = config.policy {
pol.webhook.clone()
} else {
None
};
let target_url = payload
.url
.or_else(|| webhook_cfg.as_ref().map(|w| w.url.clone()));
let target_format = payload
.format
.or_else(|| webhook_cfg.as_ref().and_then(|w| w.format))
.unwrap_or_default();
let target_url = match target_url {
Some(u) if !u.trim().is_empty() => u,
_ => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "ok": false, "error": "No webhook URL configured or provided" })),
)
.into_response();
}
};
let _ = match reqwest::Url::parse(&target_url) {
Ok(u) => {
if u.scheme() != "http" && u.scheme() != "https" {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "ok": false, "error": "Webhook URL must use http or https scheme" })),
)
.into_response();
}
u
}
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "ok": false, "error": format!("Invalid webhook URL: {}", e) })),
)
.into_response();
}
};
let trusted_target_url = match webhook_cfg.as_ref() {
Some(cfg) if cfg.url == target_url => cfg.url.as_str(),
Some(cfg) => {
if let Some(matched) = cfg
.allowed_urls
.iter()
.find(|allowed| *allowed == &target_url)
{
matched.as_str()
} else {
return (
StatusCode::FORBIDDEN,
Json(json!({
"ok": false,
"error": "Webhook URL is not permitted. URL must match policy.webhook.url or be present in policy.webhook.allowed_urls."
})),
)
.into_response();
}
}
None => {
return (
StatusCode::FORBIDDEN,
Json(json!({
"ok": false,
"error": "No policy webhook configuration present."
})),
)
.into_response();
}
};
let parsed_trusted_url = match reqwest::Url::parse(trusted_target_url) {
Ok(u) => u,
Err(e) => {
return (
StatusCode::BAD_REQUEST,
Json(json!({ "ok": false, "error": format!("Invalid webhook URL in configuration: {}", e) })),
)
.into_response();
}
};
let test_data = json!({
"id": "appr-test-101",
"capability_id": "db.drop_database",
"server_id": "postgres_production",
"sanitized_args": {
"database": "customer_data",
"cascade": true
},
"request_id": "req-test-sim",
"created_at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(),
"expires_at": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + 300,
"status": "pending"
});
let formatted = crate::chatops::format_webhook_payload(
target_format,
"approval.requested",
&test_data,
Some("http://127.0.0.1:9090"),
);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_default();
match client.post(parsed_trusted_url).json(&formatted).send().await {
Ok(resp) if resp.status().is_success() => (
StatusCode::OK,
Json(json!({
"ok": true,
"message": format!("Successfully sent test webhook ({:?}) to {}", target_format, target_url),
"status_code": resp.status().as_u16(),
})),
)
.into_response(),
Ok(resp) => (
StatusCode::BAD_GATEWAY,
Json(json!({
"ok": false,
"error": format!("Webhook target responded with HTTP {}", resp.status()),
"status_code": resp.status().as_u16(),
})),
)
.into_response(),
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(json!({
"ok": false,
"error": format!("Failed to send test webhook: {}", e),
})),
)
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{McpConfig, PolicyConfig, WebhookConfig};
use axum::body::to_bytes;
use tempfile::NamedTempFile;
#[tokio::test]
async fn test_webhook_rejects_unpermitted_url() {
let temp = NamedTempFile::new().unwrap();
let config = McpConfig {
policy: Some(PolicyConfig {
webhook: Some(WebhookConfig {
url: "https://hooks.slack.com/services/T00/B00/X00".to_string(),
allowed_urls: vec!["https://discord.com/api/webhooks/1/2".to_string()],
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
std::fs::write(temp.path(), serde_json::to_string(&config).unwrap()).unwrap();
let state = AppState::builder()
.config_path(temp.path().to_str().unwrap().to_string())
.catalog_version("test")
.build();
let req = TestWebhookRequest {
url: Some("https://evil.internal.attacker.com/webhook".to_string()),
format: None,
};
let resp = handle_test_webhook(State(state.clone()), Json(req))
.await
.into_response();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let body_bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let body_json: Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body_json["ok"], false);
let req_invalid = TestWebhookRequest {
url: Some("file:///etc/passwd".to_string()),
format: None,
};
let resp_invalid = handle_test_webhook(State(state), Json(req_invalid))
.await
.into_response();
assert_eq!(resp_invalid.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_webhook_rejects_when_no_webhook_configured() {
let temp = NamedTempFile::new().unwrap();
let config = McpConfig::default();
std::fs::write(temp.path(), serde_json::to_string(&config).unwrap()).unwrap();
let state = AppState::builder()
.config_path(temp.path().to_str().unwrap().to_string())
.catalog_version("test")
.build();
let req = TestWebhookRequest {
url: None,
format: None,
};
let resp = handle_test_webhook(State(state), Json(req))
.await
.into_response();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
}