diff --git a/contract/.env.example b/contract/.env.example index 5f16125..fed69ee 100644 --- a/contract/.env.example +++ b/contract/.env.example @@ -1,4 +1,47 @@ +# ────────────────────────────────────────────────────────────── +# Contract Service Configuration +# Copy this file to .env and fill in the values. +# All variables have sensible defaults where marked [default: ...]. +# ────────────────────────────────────────────────────────────── + +# Port the HTTP server listens on. [default: 8080] +PORT=8080 + +# Stellar Horizon API endpoint. Must use HTTPS unless +# ALLOW_INSECURE_HORIZON=true. [default: https://horizon-testnet.stellar.org] STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org -STELLAR_MAX_RETRIES=3 + +# Stellar secret key for signing transactions (56 chars, starts with 'S'). +# Required – the service will not start without it. +STELLAR_SECRET_KEY= + +# Network auto-derived from Horizon URL but can be overridden. +# Valid values: "testnet", "mainnet" [default: auto-derived] +# STELLAR_NETWORK=testnet + +# Redis connection URL. [default: redis://127.0.0.1:6379] REDIS_URL=redis://127.0.0.1:6379 -RUST_LOG=debug + +# Sustained rate limit (requests per second). [default: 10] +RATE_LIMIT_PER_SECOND=10 + +# Burst rate limit (concurrent requests allowed). [default: same as RATE_LIMIT_PER_SECOND] +RATE_LIMIT_BURST=10 + +# Maximum number of retries for Stellar Horizon requests. [default: 3] +STELLAR_MAX_RETRIES=3 + +# Log verbosity. [default: info] +LOG_LEVEL=info + +# Comma-separated list of webhook callback URLs. [default: empty] +WEBHOOK_URLS= + +# Secret used to validate incoming webhook signatures. [default: none] +WEBHOOK_SECRET= + +# TTL (seconds) for cached verification results. [default: 3600] +CACHE_VERIFICATION_TTL=3600 + +# Set to "true" to allow non-HTTPS Horizon URLs. [default: false] +ALLOW_INSECURE_HORIZON=false diff --git a/contract/Cargo.toml b/contract/Cargo.toml index b134228..9080cd6 100644 --- a/contract/Cargo.toml +++ b/contract/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" axum = "0.7" tokio = { version = "1.35", features = ["full"] } tower = "0.4" -tower-http = { version = "0.5", features = ["trace", "cors"] } +tower-http = { version = "0.5", features = ["trace", "cors", "limit", "timeout"] } url = "2" futures = "0.3" diff --git a/contract/src/config.rs b/contract/src/config.rs index 573b835..3161a53 100644 --- a/contract/src/config.rs +++ b/contract/src/config.rs @@ -1,12 +1,22 @@ use std::env; use thiserror::Error; +use tracing::warn; use url::Url; +/// Recognized Stellar Horizon hosts. Used to warn when a non-standard +/// endpoint is configured. +const ALLOWED_HORIZON_HOSTS: &[&str] = &[ + "horizon-testnet.stellar.org", + "horizon-live.stellar.org", + "horizon.stellar.org", +]; + #[derive(Debug, Clone)] pub struct AppConfig { pub port: u16, pub stellar_horizon_url: String, + pub stellar_network: String, pub stellar_secret_key: Option, pub redis_url: String, pub rate_limit_per_second: u32, @@ -83,13 +93,50 @@ impl AppConfig { }; // Validate horizon URL - if Url::parse(&stellar_horizon_url).is_err() { - errors.push(format!( - "STELLAR_HORIZON_URL must be a valid URL, got '{}'", - stellar_horizon_url - )); + let parsed_url = Url::parse(&stellar_horizon_url); + match &parsed_url { + Err(_) => { + errors.push(format!( + "STELLAR_HORIZON_URL must be a valid URL, got '{}'", + stellar_horizon_url + )); + } + Ok(url) => { + // Enforce HTTPS unless explicitly overridden + let allow_insecure = env::var("ALLOW_INSECURE_HORIZON") + .map(|v| v == "true") + .unwrap_or(false); + + if url.scheme() != "https" && !allow_insecure { + errors.push(format!( + "STELLAR_HORIZON_URL must use HTTPS (got '{}'). \ + Set ALLOW_INSECURE_HORIZON=true to allow insecure connections.", + url.scheme() + )); + } + + // Warn for unrecognized Horizon hosts + if let Some(host) = url.host_str() { + if !ALLOWED_HORIZON_HOSTS.contains(&host) { + warn!( + "STELLAR_HORIZON_URL host '{}' is not in the recognized list: {:?}", + host, ALLOWED_HORIZON_HOSTS + ); + } + } + } } + // Auto-derive stellar_network from Horizon URL + let stellar_network = match parsed_url.as_ref().map(|u| u.host_str()) { + Ok(Some(host)) if host.contains("testnet") => "testnet".to_string(), + Ok(Some(host)) if host.contains("mainnet") || host.contains("live") => { + "mainnet".to_string() + } + Ok(_) => "testnet".to_string(), + Err(_) => "testnet".to_string(), + }; + // Parse numeric values let rate_limit_per_second: u32 = match rate_limit_per_second_raw.parse() { Ok(v) if v > 0 => v, @@ -155,6 +202,7 @@ impl AppConfig { Ok(Self { port, stellar_horizon_url, + stellar_network, stellar_secret_key, redis_url, rate_limit_per_second, @@ -188,6 +236,7 @@ mod tests { "WEBHOOK_URLS", "WEBHOOK_SECRET", "CACHE_VERIFICATION_TTL", + "ALLOW_INSECURE_HORIZON", ]; for key in keys { env::remove_var(key); @@ -230,6 +279,74 @@ mod tests { assert!(msg.contains("RATE_LIMIT_PER_SECOND must be greater than 0")); } + #[test] + fn from_env_rejects_non_https_horizon_url() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_env(); + env::set_var( + "STELLAR_SECRET_KEY", + "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + env::set_var("STELLAR_HORIZON_URL", "http://horizon-testnet.stellar.org"); + + let err = AppConfig::from_env().expect_err("config should fail"); + let msg = err.to_string(); + + assert!(msg.contains("must use HTTPS")); + } + + #[test] + fn from_env_allows_insecure_horizon_when_override_set() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_env(); + env::set_var( + "STELLAR_SECRET_KEY", + "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + env::set_var("STELLAR_HORIZON_URL", "http://horizon-testnet.stellar.org"); + env::set_var("ALLOW_INSECURE_HORIZON", "true"); + + let cfg = AppConfig::from_env().expect("config should load with insecure override"); + assert_eq!( + cfg.stellar_horizon_url, + "http://horizon-testnet.stellar.org" + ); + } + + #[test] + fn from_env_auto_derives_testnet_network() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_env(); + env::set_var( + "STELLAR_SECRET_KEY", + "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + env::set_var( + "STELLAR_HORIZON_URL", + "https://horizon-testnet.stellar.org", + ); + + let cfg = AppConfig::from_env().expect("config should load"); + assert_eq!(cfg.stellar_network, "testnet"); + } + + #[test] + fn from_env_auto_derives_mainnet_network() { + let _guard = ENV_LOCK.lock().unwrap(); + clear_env(); + env::set_var( + "STELLAR_SECRET_KEY", + "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + ); + env::set_var( + "STELLAR_HORIZON_URL", + "https://horizon-live.stellar.org", + ); + + let cfg = AppConfig::from_env().expect("config should load"); + assert_eq!(cfg.stellar_network, "mainnet"); + } + #[test] fn from_env_parses_valid_config() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/contract/src/lib.rs b/contract/src/lib.rs index a555e88..1271609 100644 --- a/contract/src/lib.rs +++ b/contract/src/lib.rs @@ -18,6 +18,9 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; +use tower_http::limit::RequestBodyLimitLayer; +use tower_http::timeout::TimeoutLayer; use tower_http::trace::TraceLayer; use tracing::{info, warn}; @@ -189,6 +192,8 @@ pub fn app(state: AppState) -> Router { .route("/revoke", post(revoke_document)) .route("/transfer", post(record_transfer)) .layer(TraceLayer::new_for_http()) + .layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1 MiB + .layer(TimeoutLayer::new(Duration::from_secs(30))) .with_state(state) } @@ -400,8 +405,14 @@ pub async fn verify_document_by_hash( State(state): State, Path(hash): Path, ) -> Response { + let normalized_hash = HashValidator::normalize(&hash); + if let Err(err) = HashValidator::validate_sha256(&normalized_hash) { + let (status, body) = map_validation_error(err); + return (status, Json(body)).into_response(); + } + let req = VerifyRequest { - document_hash: hash, + document_hash: normalized_hash, transaction_id: None, }; verify_document(State(state), Json(req)).await diff --git a/contract/src/main.rs b/contract/src/main.rs index 380140e..f64e440 100644 --- a/contract/src/main.rs +++ b/contract/src/main.rs @@ -28,10 +28,10 @@ async fn main() -> Result<(), Box> { // Startup configuration summary (redacting secrets) info!( - "Configuration: port={}, stellar_horizon_url={}, redis_url={}, rate_limit_per_second={}, rate_limit_burst={}, stellar_max_retries={}, log_level={}, webhook_urls={:?}, stellar_secret_key=[REDACTED], webhook_secret=[REDACTED], cache_verification_ttl={}", + "Configuration: port={}, stellar_horizon_url={}, stellar_network={}, redis_url=[REDACTED], rate_limit_per_second={}, rate_limit_burst={}, stellar_max_retries={}, log_level={}, webhook_urls={:?}, stellar_secret_key=[REDACTED], webhook_secret=[REDACTED], cache_verification_ttl={}", config.port, config.stellar_horizon_url, - config.redis_url, + config.stellar_network, config.rate_limit_per_second, config.rate_limit_burst, config.stellar_max_retries,