Skip to content
Open
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions crates/shared/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -20,8 +23,10 @@ impl AppConfig {
pub fn from_env() -> WaveFlowResult<Self> {
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")
Expand Down Expand Up @@ -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::*;
Expand All @@ -67,13 +94,46 @@ 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");
assert_eq!(cfg.api_admin_keys, vec!["key-a", "key-b"]);

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");
}
}
6 changes: 6 additions & 0 deletions docs/configuration/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 3 additions & 0 deletions docs/security-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions render.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down