diff --git a/.env.example b/.env.example index 4b63f89..4453fa5 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ ESCROW_CONTRACT_ID= GATEWAY_SECRET_KEY= # GitHub webhook verification +WAVEFLOW_ENV=development GITHUB_WEBHOOK_SECRET=change-me-to-github-webhook-secret # API authentication (comma-separated admin keys) diff --git a/crates/shared/src/config.rs b/crates/shared/src/config.rs index 2e4d988..d6b0575 100644 --- a/crates/shared/src/config.rs +++ b/crates/shared/src/config.rs @@ -3,6 +3,9 @@ use std::env; use crate::error::{WaveFlowError, WaveFlowResult}; +const DEFAULT_GITHUB_WEBHOOK_SECRET: &str = "change-me-to-github-webhook-secret"; +const MIN_PRODUCTION_WEBHOOK_SECRET_LEN: usize = 32; + #[derive(Debug, Clone)] pub struct AppConfig { pub database_url: String, @@ -20,8 +23,10 @@ impl AppConfig { pub fn from_env() -> WaveFlowResult { let database_url = env::var("DATABASE_URL") .map_err(|_| WaveFlowError::Config("DATABASE_URL is required".into()))?; + let waveflow_env = env::var("WAVEFLOW_ENV").unwrap_or_else(|_| "development".into()); let github_webhook_secret = env::var("GITHUB_WEBHOOK_SECRET") .map_err(|_| WaveFlowError::Config("GITHUB_WEBHOOK_SECRET is required".into()))?; + validate_webhook_secret(&waveflow_env, &github_webhook_secret)?; let soroban_rpc_url = env::var("SOROBAN_RPC_URL") .unwrap_or_else(|_| "https://soroban-testnet.stellar.org".into()); let network_passphrase = env::var("NETWORK_PASSPHRASE") @@ -59,6 +64,28 @@ impl AppConfig { } } +fn validate_webhook_secret(waveflow_env: &str, secret: &str) -> WaveFlowResult<()> { + if !waveflow_env.eq_ignore_ascii_case("production") { + return Ok(()); + } + + let trimmed_secret = secret.trim(); + + if trimmed_secret == DEFAULT_GITHUB_WEBHOOK_SECRET || trimmed_secret.is_empty() { + return Err(WaveFlowError::Config( + "GITHUB_WEBHOOK_SECRET must be replaced with a random production secret".into(), + )); + } + + if trimmed_secret.len() < MIN_PRODUCTION_WEBHOOK_SECRET_LEN { + return Err(WaveFlowError::Config(format!( + "GITHUB_WEBHOOK_SECRET must be at least {MIN_PRODUCTION_WEBHOOK_SECRET_LEN} characters in production" + ))); + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -67,6 +94,7 @@ mod tests { fn parses_admin_keys_from_comma_list() { std::env::set_var("DATABASE_URL", "postgres://localhost/waveflow"); std::env::set_var("GITHUB_WEBHOOK_SECRET", "secret"); + std::env::set_var("WAVEFLOW_ENV", "development"); std::env::set_var("API_ADMIN_KEYS", "key-a, key-b"); let cfg = AppConfig::from_env().expect("config"); @@ -74,6 +102,38 @@ mod tests { std::env::remove_var("DATABASE_URL"); std::env::remove_var("GITHUB_WEBHOOK_SECRET"); + std::env::remove_var("WAVEFLOW_ENV"); std::env::remove_var("API_ADMIN_KEYS"); } + + #[test] + fn development_allows_example_webhook_secret() { + validate_webhook_secret("development", DEFAULT_GITHUB_WEBHOOK_SECRET) + .expect("example secret is allowed outside production"); + } + + #[test] + fn production_rejects_example_webhook_secret() { + let err = validate_webhook_secret("production", DEFAULT_GITHUB_WEBHOOK_SECRET) + .expect_err("production must reject the example secret"); + + assert!(err.to_string().contains("random production secret")); + } + + #[test] + fn production_rejects_short_webhook_secret() { + let err = validate_webhook_secret("production", "short") + .expect_err("production must reject short secrets"); + + assert!(err.to_string().contains("at least 32 characters")); + } + + #[test] + fn production_accepts_strong_webhook_secret() { + validate_webhook_secret( + "production", + "a-random-production-webhook-secret-with-entropy", + ) + .expect("strong production secret"); + } } diff --git a/docs/configuration/env-vars.md b/docs/configuration/env-vars.md index e7dab11..3dd34b3 100644 --- a/docs/configuration/env-vars.md +++ b/docs/configuration/env-vars.md @@ -9,6 +9,12 @@ Loaded by `crates/shared/src/config.rs` via `AppConfig::from_env()`. | `DATABASE_URL` | Postgres connection string for audit trail | | `GITHUB_WEBHOOK_SECRET` | Shared secret for HMAC verification | +## Runtime mode + +| Variable | Default | Description | +|----------|---------|-------------| +| `WAVEFLOW_ENV` | `development` | Use `production` in production to enforce webhook secret safety checks. | + ## Soroban / Stellar | Variable | Default | Description | diff --git a/docs/security-checklist.md b/docs/security-checklist.md index 70b3f4f..8a0744a 100644 --- a/docs/security-checklist.md +++ b/docs/security-checklist.md @@ -2,6 +2,9 @@ ## Webhook ingestion +- [ ] Set `WAVEFLOW_ENV=production` on deployed gateway services +- [ ] Use a random `GITHUB_WEBHOOK_SECRET` with at least 32 characters +- [ ] Confirm `GITHUB_WEBHOOK_SECRET` is not `change-me-to-github-webhook-secret` - [ ] Rotate `GITHUB_WEBHOOK_SECRET` on a defined schedule - [ ] Reject requests missing `X-Hub-Signature-256` - [ ] Persist raw webhook payloads for forensic replay diff --git a/render.yaml b/render.yaml index 6e56aa3..f249f60 100644 --- a/render.yaml +++ b/render.yaml @@ -12,6 +12,8 @@ services: property: connectionString - key: GITHUB_WEBHOOK_SECRET sync: false + - key: WAVEFLOW_ENV + value: production - key: SOROBAN_RPC_URL value: https://soroban-testnet.stellar.org - key: ESCROW_CONTRACT_ID