Skip to content

Add URL validation to operational server webhooks #1887

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 21, 2025
Merged
Show file tree
Hide file tree
Changes from 7 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
35 changes: 34 additions & 1 deletion server/svix-server/src/cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ use figment::{
use ipnet::IpNet;
use serde::{Deserialize, Deserializer};
use tracing::Level;
use validator::{Validate, ValidationError};
use url::Url;
use validator::{Validate, ValidationError, ValidationErrors};

use crate::{
core::{cryptography::Encryption, security::JwtSigningConfig},
error::Result,
v1::utils::validation_error,
};

fn deserialize_main_secret<'de, D>(deserializer: D) -> Result<Encryption, D::Error>
Expand Down Expand Up @@ -74,6 +76,36 @@ fn default_redis_pending_duration_secs() -> u64 {
45
}

fn validate_operational_webhook_url(url: &str) -> Result<(), ValidationError> {
match Url::parse(url) {
Ok(url) => {
// Verify scheme is http or https
if url.scheme() != "http" && url.scheme() != "https" {
return Err(validation_error(
Some("operational_webhook_address"),
Some("URL scheme must be http or https"),
));
}

// Verify there's a host
if url.host().is_none() {
return Err(validation_error(
Some("operational_webhook_address"),
Some("URL must include a valid host"),
));
}
}
Err(_) => {
return Err(validation_error(
Some("operational_webhook_address"),
Some("Invalid URL format"),
));
}
}

Ok(())
}

#[derive(Clone, Debug, Deserialize, Validate)]
#[validate(
schema(function = "validate_config_complete"),
Expand All @@ -85,6 +117,7 @@ pub struct ConfigurationInner {

/// The address to send operational webhooks to. When None, operational webhooks will not be
/// sent. When Some, the API server with the given URL will be used to send operational webhooks.
#[validate(custom = "validate_operational_webhook_url")]
pub operational_webhook_address: Option<String>,

/// The main secret used by Svix. Used for client-side encryption of sensitive data, etc.
Expand Down
12 changes: 10 additions & 2 deletions server/svix-server/src/core/operational_webhooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,15 @@ pub struct OperationalWebhookSenderInner {
}

impl OperationalWebhookSenderInner {
pub fn new(keys: Arc<JwtSigningConfig>, url: Option<String>) -> Arc<Self> {
pub fn new(keys: Arc<JwtSigningConfig>, mut url: Option<String>) -> Arc<Self> {
// Sanitize the URL if present
if let Some(url) = &mut url {
// Remove trailing slashes
while curl.ends_with('/') {
url.pop();
}
}

Arc::new(Self {
signing_config: keys,
url,
Expand Down Expand Up @@ -177,7 +185,7 @@ impl OperationalWebhookSenderInner {
..
})) => {
tracing::warn!(
"Operational webhooks are enabled but no listener set for {}",
"Operational webhooks are enabled, but no listener found for organization {}",
recipient_org_id,
);
}
Expand Down
Loading