Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ POLL_INTERVAL_SECS=10
PAYMENT_TTL_SECS=3600

# Webhooks — secret signs the X-StellarGate-Signature header (HMAC-SHA256)
WEBHOOK_SECRET=your_webhook_signing_secret
WEBHOOK_SECRET=default-development-webhook-signing-secret-32-chars
WEBHOOK_RETRY_ATTEMPTS=3
WEBHOOK_RETRY_DELAY_MS=5000

Expand Down
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 0 additions & 6 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,6 @@ pub fn router(state: Arc<AppState>) -> axum::Router {
"/:id/webhooks/:delivery_id/redeliver",
post(payments::redeliver_webhook),
)
.layer(middleware::from_fn(
move |ConnectInfo(addr): ConnectInfo<SocketAddr>, req: Request, next: Next| {
rate_limit_middleware(addr, rate_limit_rps, req, next)
},
))
.layer(tower_http::util::ConnectInfoLayer::new())
})
.fallback(not_found)
.layer(PropagateRequestIdLayer::x_request_id())
Expand Down
201 changes: 191 additions & 10 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,23 @@ pub struct Config {

impl Config {
pub fn from_env() -> Result<Self> {
let database_url =
std::env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:stellargate.db".to_string());
let network = std::env::var("STELLAR_NETWORK").unwrap_or_else(|_| "testnet".to_string());
let horizon_url = std::env::var("STELLAR_HORIZON_URL")
.unwrap_or_else(|_| "https://horizon-testnet.stellar.org".to_string());
let gateway_public =
std::env::var("STELLAR_GATEWAY_PUBLIC").unwrap_or_else(|_| "UNCONFIGURED".to_string());
let gateway_secret = std::env::var("STELLAR_GATEWAY_SECRET").unwrap_or_default();
let webhook_secret = Self::validate_webhook_secret(std::env::var("WEBHOOK_SECRET"))?;

let config = Self {
port: parse_env("PORT", 3000),
database_url: env_or("DATABASE_URL", "sqlite:stellargate.db"),
network: env_or("STELLAR_NETWORK", "testnet"),
horizon_url: env_or("STELLAR_HORIZON_URL", "https://horizon-testnet.stellar.org"),
gateway_public: env_or("STELLAR_GATEWAY_PUBLIC", "UNCONFIGURED"),
gateway_secret: env_or("STELLAR_GATEWAY_SECRET", ""),
database_url,
network,
horizon_url,
gateway_public,
gateway_secret,
accepted_assets: {
let raw = std::env::var("ACCEPTED_ASSETS").unwrap_or_default();
if raw.is_empty() {
Expand All @@ -121,7 +131,7 @@ impl Config {
AcceptedAsset::parse_list(&raw)
}
},
webhook_secret: env_or("WEBHOOK_SECRET", "default-secret"),
webhook_secret,
webhook_retry_attempts: parse_env("WEBHOOK_RETRY_ATTEMPTS", 3),
webhook_retry_delay_ms: parse_env("WEBHOOK_RETRY_DELAY_MS", 5000),
poll_interval_secs: parse_env("POLL_INTERVAL_SECS", 10),
Expand Down Expand Up @@ -176,6 +186,39 @@ impl Config {
}
Ok(())
}

fn validate_webhook_secret(raw_secret: Result<String, std::env::VarError>) -> Result<String> {
let secret = match raw_secret {
Ok(s) => s,
Err(_) => {
return Err(anyhow::anyhow!(
"WEBHOOK_SECRET environment variable is missing"
))
}
};

if secret.is_empty() {
return Err(anyhow::anyhow!("WEBHOOK_SECRET cannot be empty"));
}
if secret.trim().is_empty() {
return Err(anyhow::anyhow!(
"WEBHOOK_SECRET cannot contain only whitespace"
));
}
if secret == "default-secret" {
return Err(anyhow::anyhow!(
"WEBHOOK_SECRET cannot equal \"default-secret\""
));
}
if secret.len() < 32 {
return Err(anyhow::anyhow!(
"WEBHOOK_SECRET must be at least 32 characters long (got {})",
secret.len()
));
}

Ok(secret)
}
}

impl std::fmt::Debug for Config {
Expand Down Expand Up @@ -205,10 +248,6 @@ impl std::fmt::Debug for Config {
}
}

fn env_or(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}

/// Parse an env var into `T`, falling back to `default` (and warning) when the
/// variable is set but unparseable, so a typo never silently breaks behaviour.
fn parse_env<T>(key: &str, default: T) -> T
Expand Down Expand Up @@ -345,4 +384,146 @@ mod tests {
let err = cfg.validate_addresses().unwrap_err().to_string();
assert!(err.contains("USDC"), "got: {err}");
}

#[test]
fn validate_webhook_secret_missing() {
let err = Config::validate_webhook_secret(Err(std::env::VarError::NotPresent))
.unwrap_err()
.to_string();
assert!(
err.contains("environment variable is missing"),
"got: {err}"
);
}

#[test]
fn validate_webhook_secret_empty() {
let err = Config::validate_webhook_secret(Ok("".into()))
.unwrap_err()
.to_string();
assert!(err.contains("cannot be empty"), "got: {err}");
}

#[test]
fn validate_webhook_secret_whitespace() {
let err = Config::validate_webhook_secret(Ok(" ".into()))
.unwrap_err()
.to_string();
assert!(err.contains("cannot contain only whitespace"), "got: {err}");
}

#[test]
fn validate_webhook_secret_default() {
let err = Config::validate_webhook_secret(Ok("default-secret".into()))
.unwrap_err()
.to_string();
assert!(
err.contains("cannot equal \"default-secret\""),
"got: {err}"
);
}

#[test]
fn validate_webhook_secret_short() {
let err = Config::validate_webhook_secret(Ok("too-short".into()))
.unwrap_err()
.to_string();
assert!(
err.contains("must be at least 32 characters long"),
"got: {err}"
);
}

#[test]
fn validate_webhook_secret_valid() {
let secret = "a-very-long-and-secure-webhook-signing-secret-32-chars";
let res = Config::validate_webhook_secret(Ok(secret.into())).unwrap();
assert_eq!(res, secret);
}

fn run_with_env<F>(env_vars: &[(&str, Option<&str>)], f: F)
where
F: FnOnce(),
{
use std::sync::OnceLock;
static LOCK: OnceLock<std::sync::Mutex<()>> = OnceLock::new();
let _guard = LOCK
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap();

// Backup current env values
let backups: Vec<(String, Option<String>)> = env_vars
.iter()
.map(|(key, _)| (key.to_string(), std::env::var(key).ok()))
.collect();

// Set new values
for &(key, val) in env_vars {
if let Some(v) = val {
std::env::set_var(key, v);
} else {
std::env::remove_var(key);
}
}

// Run the test logic
let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));

// Restore backups
for (key, val) in backups {
if let Some(v) = val {
std::env::set_var(key, v);
} else {
std::env::remove_var(key);
}
}

if let Err(err) = res {
std::panic::resume_unwind(err);
}
}

#[test]
fn startup_fails_in_production_if_webhook_secret_missing() {
run_with_env(
&[
("STELLAR_NETWORK", Some("public")),
("WEBHOOK_SECRET", None),
],
|| {
let err = Config::from_env().unwrap_err().to_string();
assert!(
err.contains("WEBHOOK_SECRET environment variable is missing"),
"got: {err}"
);
},
);
}

#[test]
fn startup_succeeds_with_valid_configuration() {
run_with_env(
&[
("STELLAR_NETWORK", Some("public")),
(
"WEBHOOK_SECRET",
Some("a-very-long-and-secure-webhook-signing-secret-32-chars"),
),
("DATABASE_URL", Some("sqlite::memory:")),
(
"STELLAR_GATEWAY_PUBLIC",
Some("GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"),
),
],
|| {
let cfg = Config::from_env().unwrap();
assert_eq!(cfg.network, "public");
assert_eq!(
cfg.webhook_secret,
"a-very-long-and-secure-webhook-signing-secret-32-chars"
);
},
);
}
}
Loading