From c9f9e3d19f488f5c4869179cdaaf1124425b4122 Mon Sep 17 00:00:00 2001 From: userAdityaa Date: Fri, 17 Apr 2026 19:13:16 +0545 Subject: [PATCH] fix: make stripe webhooks explicitly toggleable with mandatory secret validation --- backend/.env.template | 4 +- backend/README.md | 46 ++++++------- backend/spec/api.md | 1 + backend/spec/billing.md | 4 +- backend/src/api.rs | 7 +- backend/src/billing.rs | 111 ++++++++++++++++++++++++++++++-- backend/src/command.rs | 2 +- backend/src/infra.rs | 2 +- backend/src/main.rs | 2 +- backend/src/pool.rs | 3 +- backend/tests/btc_quote_stub.rs | 4 +- 11 files changed, 145 insertions(+), 41 deletions(-) diff --git a/backend/.env.template b/backend/.env.template index 812fef6..d1ba7d4 100644 --- a/backend/.env.template +++ b/backend/.env.template @@ -28,5 +28,5 @@ LIVEKIT_API_SECRET= # Billing NWC_URL= # Nostr Wallet Connect URL for generating Lightning invoices -STRIPE_SECRET_KEY= # Stripe API secret key (sk_...) -STRIPE_WEBHOOK_SECRET= # Secret for verifying Stripe webhook signatures (whsec_...) +STRIPE_SECRET_KEY= # Required Stripe API secret key (sk_...) +STRIPE_WEBHOOK_SECRET=whsec_test_00000000000000000000000000 # Webhook signing secret (use real value in production) diff --git a/backend/README.md b/backend/README.md index f6bb697..1070de9 100644 --- a/backend/README.md +++ b/backend/README.md @@ -30,27 +30,29 @@ backend/ Environment variables: -| Variable | Description | Default | -|---|---|---| -| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite:///data/caravel.db` | -| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` | -| `PORT` | API bind port | `2892` | -| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ | -| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ | -| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ | -| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ | -| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty | -| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ | -| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ | -| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ | -| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ | -| `ROBOT_SECRET` | Robot Nostr secret key | _required_ | -| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ | -| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ | -| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ | -| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ | -| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ | -| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ | +| Variable | Description | Default | +| ------------------------ | ----------------------------------------------------------------------- | ------------------------------------ | +| `DATABASE_URL` | SQLite URL. Relative paths are resolved under `backend/`. | `sqlite:///data/caravel.db` | +| `HOST` | API bind host (also used for NIP-98 `u` host check) | `127.0.0.1` | +| `PORT` | API bind port | `2892` | +| `ADMINS` | Comma-separated admin pubkeys (hex) | _optional_ | +| `ALLOW_ORIGINS` | Comma-separated CORS origins. If empty, CORS is permissive. | _optional_ | +| `ZOOID_API_URL` | Zooid API base URL used by infra worker | _required for infra sync_ | +| `ZOOID_API_SECRET` | Nostr secret key used for authentication of requests to the zooid API | _required_ | +| `RELAY_DOMAIN` | Base domain appended to relay subdomains | empty | +| `LIVEKIT_URL` | LiveKit URL sent to zooid when relay livekit is enabled | _optional_ | +| `LIVEKIT_API_KEY` | LiveKit API key sent to zooid | _optional_ | +| `LIVEKIT_API_SECRET` | LiveKit API secret sent to zooid | _optional_ | +| `NWC_URL` | Platform NWC URL used to generate BOLT11 invoices | _required for invoice generation_ | +| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | _required_ | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | _required_ | +| `ROBOT_SECRET` | Robot Nostr secret key | _required_ | +| `ROBOT_NAME` | Robot display name (kind `0`) | _optional_ | +| `ROBOT_DESCRIPTION` | Robot description (kind `0`) | _optional_ | +| `ROBOT_PICTURE` | Robot picture URL (kind `0`) | _optional_ | +| `ROBOT_OUTBOX_RELAYS` | Comma-separated relays published as kind `10002` | _required_ | +| `ROBOT_INDEXER_RELAYS` | Comma-separated relays used for recipient relay discovery | _required_ | +| `ROBOT_MESSAGING_RELAYS` | Comma-separated relays published as kind `10050` | _required_ | Relay list env vars are comma-separated and trimmed. If a relay has no `ws://` or `wss://` scheme, `wss://` is prepended. @@ -66,7 +68,7 @@ Public exceptions: - `GET /plans` - `GET /plans/:id` -- `POST /stripe/webhook` (validated with Stripe signatures instead) +- `POST /stripe/webhook` (validated with Stripe signatures) - `GET /identity` — get auth identity (`pubkey`, `is_admin`) - `GET /tenants` — list tenants (admin) diff --git a/backend/spec/api.md b/backend/spec/api.md index 35d09dc..e9dc9a4 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -178,6 +178,7 @@ Notes: - Reads raw request body and `Stripe-Signature` header - Calls `billing.handle_webhook(payload, signature)` - Returns `200` on success, `400` on signature verification failure +- Startup requires non-empty `STRIPE_WEBHOOK_SECRET` --- Utilities diff --git a/backend/spec/billing.md b/backend/spec/billing.md index 270d988..e5ec40d 100644 --- a/backend/spec/billing.md +++ b/backend/spec/billing.md @@ -5,6 +5,7 @@ Billing encapsulates logic related to synchronizing state with Stripe, processin Members: - `nwc_url: String` - a nostr wallet connect URL used to **create** bolt11 invoices (i.e. receive payments), from `NWC_URL` +- `stripe_secret_key: String` - Stripe API key used for billing API operations, from `STRIPE_SECRET_KEY` - `stripe_webhook_secret: String` - secret for verifying Stripe webhook signatures, from `STRIPE_WEBHOOK_SECRET` - `query: Query` - `command: Command` @@ -13,6 +14,8 @@ Members: ## `pub fn new(query: Query, command: Command, robot: Robot) -> Self` - Reads environment and populates members +- Panics if `STRIPE_SECRET_KEY` is missing/empty +- Panics if `STRIPE_WEBHOOK_SECRET` is missing/empty ## `pub fn start(&self)` @@ -109,4 +112,3 @@ Skip invoices with `amount_due` of 0. - Look up tenant by `stripe_customer_id` - Clear `stripe_subscription_id` via `command.clear_tenant_subscription` - diff --git a/backend/src/api.rs b/backend/src/api.rs index 34ae5fd..d39a040 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -139,7 +139,7 @@ impl Api { api: Arc::new(self), }; - Router::new() + let router = Router::new() .route("/identity", get(get_identity)) .route("/plans", get(list_plans)) .route("/plans/:id", get(get_plan)) @@ -158,8 +158,9 @@ impl Api { "/tenants/:pubkey/stripe/session", get(create_stripe_session), ) - .route("/stripe/webhook", post(stripe_webhook)) - .with_state(state) + .route("/stripe/webhook", post(stripe_webhook)); + + router.with_state(state) } fn extract_auth_pubkey(&self, headers: &HeaderMap) -> std::result::Result { diff --git a/backend/src/billing.rs b/backend/src/billing.rs index 997f0f0..75be841 100644 --- a/backend/src/billing.rs +++ b/backend/src/billing.rs @@ -95,6 +95,9 @@ impl Billing { panic!("missing STRIPE_SECRET_KEY environment variable"); } let stripe_webhook_secret = std::env::var("STRIPE_WEBHOOK_SECRET").unwrap_or_default(); + if stripe_webhook_secret.trim().is_empty() { + panic!("missing STRIPE_WEBHOOK_SECRET environment variable"); + } let btc_quote_api_base = std::env::var("BTC_PRICE_API_BASE").unwrap_or_else(|_| COINBASE_SPOT_API.to_string()); Self { @@ -949,7 +952,8 @@ mod tests { use sqlx::SqlitePool; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use std::str::FromStr; - use std::sync::{Mutex, OnceLock}; + use std::sync::OnceLock; + use tokio::sync::Mutex; fn env_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); @@ -964,6 +968,14 @@ mod tests { } } + #[allow(unused_unsafe)] + fn set_stripe_webhook_secret(value: Option<&str>) { + match value { + Some(v) => unsafe { std::env::set_var("STRIPE_WEBHOOK_SECRET", v) }, + None => unsafe { std::env::remove_var("STRIPE_WEBHOOK_SECRET") }, + } + } + struct StripeSecretKeyGuard { previous: Option, } @@ -982,6 +994,24 @@ mod tests { } } + struct StripeWebhookSecretGuard { + previous: Option, + } + + impl StripeWebhookSecretGuard { + fn set(value: Option<&str>) -> Self { + let previous = std::env::var("STRIPE_WEBHOOK_SECRET").ok(); + set_stripe_webhook_secret(value); + Self { previous } + } + } + + impl Drop for StripeWebhookSecretGuard { + fn drop(&mut self) { + set_stripe_webhook_secret(self.previous.as_deref()); + } + } + async fn test_pool() -> SqlitePool { let connect_options = SqliteConnectOptions::from_str("sqlite::memory:") .expect("valid sqlite memory url") @@ -1003,8 +1033,9 @@ mod tests { #[tokio::test] async fn billing_new_panics_without_stripe_secret_key() { - let _lock = env_lock().lock().expect("acquire env lock"); - let _env = StripeSecretKeyGuard::set(None); + let _lock = env_lock().lock().await; + let _secret_env = StripeSecretKeyGuard::set(None); + let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy")); let pool = test_pool().await; let query = Query::new(pool.clone()); @@ -1034,9 +1065,76 @@ mod tests { } #[tokio::test] - async fn billing_new_accepts_non_empty_stripe_secret_key() { - let _lock = env_lock().lock().expect("acquire env lock"); - let _env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); + async fn billing_new_panics_without_stripe_webhook_secret() { + let _lock = env_lock().lock().await; + let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); + let _webhook_env = StripeWebhookSecretGuard::set(None); + + let pool = test_pool().await; + let query = Query::new(pool.clone()); + let command = Command::new(pool); + let robot = Robot::test_stub(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + Billing::new(query, command, robot) + })); + + let panic_payload = match result { + Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is missing"), + Err(payload) => payload, + }; + let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() { + (*msg).to_string() + } else if let Some(msg) = panic_payload.downcast_ref::() { + msg.clone() + } else { + String::new() + }; + + assert!( + panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"), + "unexpected panic: {panic_msg}" + ); + } + + #[tokio::test] + async fn billing_new_panics_with_blank_stripe_webhook_secret() { + let _lock = env_lock().lock().await; + let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); + let _webhook_env = StripeWebhookSecretGuard::set(Some(" ")); + + let pool = test_pool().await; + let query = Query::new(pool.clone()); + let command = Command::new(pool); + let robot = Robot::test_stub(); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + Billing::new(query, command, robot) + })); + + let panic_payload = match result { + Ok(_) => panic!("constructor should panic when STRIPE_WEBHOOK_SECRET is blank"), + Err(payload) => payload, + }; + let panic_msg = if let Some(msg) = panic_payload.downcast_ref::<&str>() { + (*msg).to_string() + } else if let Some(msg) = panic_payload.downcast_ref::() { + msg.clone() + } else { + String::new() + }; + + assert!( + panic_msg.contains("missing STRIPE_WEBHOOK_SECRET environment variable"), + "unexpected panic: {panic_msg}" + ); + } + + #[tokio::test] + async fn billing_new_accepts_non_empty_stripe_secrets() { + let _lock = env_lock().lock().await; + let _secret_env = StripeSecretKeyGuard::set(Some("sk_test_dummy")); + let _webhook_env = StripeWebhookSecretGuard::set(Some("whsec_test_dummy")); let pool = test_pool().await; let billing = Billing::new( @@ -1046,5 +1144,6 @@ mod tests { ); assert_eq!(billing.stripe_secret_key, "sk_test_dummy"); + assert_eq!(billing.stripe_webhook_secret, "whsec_test_dummy"); } } diff --git a/backend/src/command.rs b/backend/src/command.rs index e75100a..85062b2 100644 --- a/backend/src/command.rs +++ b/backend/src/command.rs @@ -32,7 +32,7 @@ impl Command { .fetch_one(&mut **tx) .await? } - _ => anyhow::bail!("unknown resource_type: {}", resource_type), + _ => anyhow::bail!("unknown resource_type: {resource_type}"), }; let id = uuid::Uuid::new_v4().to_string(); diff --git a/backend/src/infra.rs b/backend/src/infra.rs index 1f7232f..fea69c0 100644 --- a/backend/src/infra.rs +++ b/backend/src/infra.rs @@ -169,7 +169,7 @@ impl Infra { if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - anyhow::bail!("zooid sync returned {}: {}", status, body) + anyhow::bail!("zooid sync returned {status}: {body}") } Ok(()) } diff --git a/backend/src/main.rs b/backend/src/main.rs index 402b80d..7fecdc7 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -3,8 +3,8 @@ mod billing; mod command; mod infra; mod models; -mod query; mod pool; +mod query; mod robot; use anyhow::Result; diff --git a/backend/src/pool.rs b/backend/src/pool.rs index 1fbe829..165bc9e 100644 --- a/backend/src/pool.rs +++ b/backend/src/pool.rs @@ -21,8 +21,7 @@ pub async fn create_pool() -> Result { std::fs::create_dir_all(parent)?; } - let connect_options = - SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true); + let connect_options = SqliteConnectOptions::from_str(&database_url)?.create_if_missing(true); let pool = SqlitePoolOptions::new() .max_connections(5) diff --git a/backend/tests/btc_quote_stub.rs b/backend/tests/btc_quote_stub.rs index bb23464..12a2dae 100644 --- a/backend/tests/btc_quote_stub.rs +++ b/backend/tests/btc_quote_stub.rs @@ -25,7 +25,7 @@ async fn quote_endpoint_can_be_stubbed_deterministically() { assert_eq!(btc_price, 50_000.0); - let msats = fiat_minor_to_msats_from_quote(100, "USD", btc_price) - .expect("convert quoted fiat amount"); + let msats = + fiat_minor_to_msats_from_quote(100, "USD", btc_price).expect("convert quoted fiat amount"); assert_eq!(msats, 2_000_000); } -- 2.52.0