use serde::{Deserialize, Serialize}; use std::net::IpAddr; use url::Url; use uuid::Uuid; use crate::error::AppError; use crate::models::common::{EventType, Role}; use crate::models::workspaces::WorkspaceWebhook; use crate::service::WorkspaceService; use crate::session::Session; use super::util::{clamp_limit_offset, ensure_affected, required_text}; /// Validate webhook URL for SSRF protection fn validate_webhook_url(url_str: &str) -> Result<(), AppError> { let url = Url::parse(url_str).map_err(|_| AppError::BadRequest("Invalid URL format".into()))?; // Only allow HTTPS if url.scheme() != "https" { return Err(AppError::BadRequest( "Webhook URL must use HTTPS protocol".into(), )); } let host = url .host_str() .ok_or_else(|| AppError::BadRequest("URL must have a host".into()))?; // Reject IP addresses directly (require domain names) if host.parse::().is_ok() { return Err(AppError::BadRequest( "Webhook URL must use a domain name, not an IP address".into(), )); } // Reject localhost and common local domains let host_lower = host.to_lowercase(); if host_lower == "localhost" || host_lower.ends_with(".localhost") || host_lower == "127.0.0.1" || host_lower == "::1" || host_lower == "0.0.0.0" || host_lower.ends_with(".local") || host_lower.ends_with(".internal") { return Err(AppError::BadRequest( "Webhook URL cannot point to localhost or internal domains".into(), )); } // Reject metadata endpoints (AWS, GCP, Azure) if host == "169.254.169.254" || host == "metadata.google.internal" { return Err(AppError::BadRequest( "Webhook URL cannot point to cloud metadata endpoints".into(), )); } Ok(()) } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateWebhookParams { pub url: String, pub secret_ciphertext: Option, pub events: Vec, pub active: Option, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateWebhookParams { pub url: Option, pub secret_ciphertext: Option, pub events: Option>, pub active: Option, } impl WorkspaceService { pub async fn workspace_webhooks( &self, ctx: &Session, workspace_id: Uuid, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let ws = self.find_workspace_by_id(workspace_id).await?; self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) .await?; let (limit, offset) = clamp_limit_offset(limit, offset); sqlx::query_as::<_, WorkspaceWebhook>( "SELECT id, workspace_id, url, secret_ciphertext, events, active, \ last_delivery_status, last_delivery_at, created_by, created_at, updated_at \ FROM workspace_webhook WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) .bind(workspace_id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn workspace_create_webhook( &self, ctx: &Session, workspace_id: Uuid, params: CreateWebhookParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let ws = self.find_workspace_by_id(workspace_id).await?; self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) .await?; let url = required_text(params.url, "url")?; validate_webhook_url(&url)?; if params.events.is_empty() { return Err(AppError::BadRequest( "at least one event is required".into(), )); } let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query("SET LOCAL app.current_user_id = $1") .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; let result = sqlx::query_as::<_, WorkspaceWebhook>( "INSERT INTO workspace_webhook (id, workspace_id, url, secret_ciphertext, events, active, \ created_by, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \ RETURNING id, workspace_id, url, secret_ciphertext, events, active, \ last_delivery_status, last_delivery_at, created_by, created_at, updated_at", ) .bind(Uuid::now_v7()) .bind(workspace_id) .bind(&url) .bind(¶ms.secret_ciphertext) .bind(¶ms.events) .bind(params.active.unwrap_or(true)) .bind(user_uid) .bind(now) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(result) } pub async fn workspace_update_webhook( &self, ctx: &Session, workspace_id: Uuid, webhook_id: Uuid, params: UpdateWebhookParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let ws = self.find_workspace_by_id(workspace_id).await?; self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) .await?; let current = sqlx::query_as::<_, WorkspaceWebhook>( "SELECT id, workspace_id, url, secret_ciphertext, events, active, \ last_delivery_status, last_delivery_at, created_by, created_at, updated_at \ FROM workspace_webhook WHERE id = $1 AND workspace_id = $2", ) .bind(webhook_id) .bind(workspace_id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? .ok_or(AppError::NotFound("webhook not found".into()))?; let url = params .url .as_ref() .map(|u| u.trim().to_string()) .unwrap_or(current.url); // Validate URL if it was updated if params.url.is_some() { validate_webhook_url(&url)?; } let active = params.active.unwrap_or(current.active); let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query("SET LOCAL app.current_user_id = $1") .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; let result = sqlx::query_as::<_, WorkspaceWebhook>( "UPDATE workspace_webhook SET url = $1, secret_ciphertext = $2, events = $3, \ active = $4, updated_at = $5 WHERE id = $6 AND workspace_id = $7 \ RETURNING id, workspace_id, url, secret_ciphertext, events, active, \ last_delivery_status, last_delivery_at, created_by, created_at, updated_at", ) .bind(&url) .bind(params.secret_ciphertext.or(current.secret_ciphertext)) .bind(params.events.unwrap_or(current.events)) .bind(active) .bind(now) .bind(webhook_id) .bind(workspace_id) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(result) } pub async fn workspace_delete_webhook( &self, ctx: &Session, workspace_id: Uuid, webhook_id: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let ws = self.find_workspace_by_id(workspace_id).await?; self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) .await?; let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query("SET LOCAL app.current_user_id = $1") .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; let result = sqlx::query("DELETE FROM workspace_webhook WHERE id = $1 AND workspace_id = $2") .bind(webhook_id) .bind(workspace_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "webhook not found")?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(()) } }