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
47 changes: 45 additions & 2 deletions contract/.env.example
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion contract/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
127 changes: 122 additions & 5 deletions contract/src/config.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub redis_url: String,
pub rate_limit_per_second: u32,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -155,6 +202,7 @@ impl AppConfig {
Ok(Self {
port,
stellar_horizon_url,
stellar_network,
stellar_secret_key,
redis_url,
rate_limit_per_second,
Expand Down Expand Up @@ -188,6 +236,7 @@ mod tests {
"WEBHOOK_URLS",
"WEBHOOK_SECRET",
"CACHE_VERIFICATION_TTL",
"ALLOW_INSECURE_HORIZON",
];
for key in keys {
env::remove_var(key);
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 12 additions & 1 deletion contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -400,8 +405,14 @@ pub async fn verify_document_by_hash(
State(state): State<AppState>,
Path(hash): Path<String>,
) -> 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
Expand Down
4 changes: 2 additions & 2 deletions contract/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {

// 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,
Expand Down