diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c1abbfe --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 10 + labels: + - dependencies + - rust + commit-message: + prefix: "chore(deps)" + reviewers: + - Spagero763 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - dependencies + - github-actions + commit-message: + prefix: "chore(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7897bd..73ffe49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,3 +51,26 @@ jobs: - name: Run integration tests run: cargo test --workspace --test cross_chain_integration + + security-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + + - name: Install cargo-audit + run: cargo install cargo-audit --locked + + - name: Run dependency vulnerability audit + run: cargo audit + + - name: Install cargo-deny + run: cargo install cargo-deny --locked + + - name: Check dependency licenses and advisories + run: cargo deny check diff --git a/contracts/teachlink/src/dos_protection.rs b/contracts/teachlink/src/dos_protection.rs new file mode 100644 index 0000000..2958bfe --- /dev/null +++ b/contracts/teachlink/src/dos_protection.rs @@ -0,0 +1,146 @@ +//! DoS protection: batch-size limits, resource quotas, and per-address rate limiting. +//! +//! All bulk-operation limits live here so they can be reviewed and tuned in +//! one place. + +use soroban_sdk::{symbol_short, Address, Env, Map, Symbol}; + +use crate::errors::BridgeError; + +// ── Resource quotas / batch-size limits ────────────────────────────────────── + +/// Maximum validator signatures accepted in a single `complete_bridge` call. +pub const MAX_VALIDATORS_PER_COMPLETION: u32 = 50; + +/// Maximum chain IDs that may be paused or resumed in a single call. +pub const MAX_CHAIN_BATCH_SIZE: u32 = 50; + +/// Maximum notification preferences a user may submit in a single call. +pub const MAX_PREFERENCE_BATCH_SIZE: u32 = 20; + +/// Maximum packets scanned per `check_timeouts` invocation. +pub const MAX_TIMEOUT_SCAN_BATCH: u32 = 100; + +/// Maximum questions an assessment may contain. +pub const MAX_QUESTIONS_PER_ASSESSMENT: u32 = 200; + +/// Maximum proctor-log entries per assessment submission. +pub const MAX_PROCTOR_LOGS_PER_SUBMISSION: u32 = 50; + +// ── Instruction budget (gas) limits ────────────────────────────────────────── +// +// Soroban measures execution cost in CPU instructions and memory bytes. +// The constants below document the expected instruction budget for each +// bulk-operation category so callers can reason about worst-case costs. +// Exceeding the network's per-transaction limit causes an automatic abort. + +/// Approximate CPU-instruction budget consumed per validator checked in +/// `complete_bridge` (signature verification is the dominant cost). +pub const INSTRUCTIONS_PER_VALIDATOR_CHECK: u64 = 5_000; + +/// Approximate CPU-instruction budget consumed per chain ID processed in +/// `pause_chains` / `resume_chains`. +pub const INSTRUCTIONS_PER_CHAIN_OP: u64 = 2_000; + +/// Approximate CPU-instruction budget consumed per packet evaluated in +/// `check_timeouts`. +pub const INSTRUCTIONS_PER_TIMEOUT_CHECK: u64 = 1_500; + +/// Approximate CPU-instruction budget consumed per notification preference. +pub const INSTRUCTIONS_PER_PREFERENCE: u64 = 1_000; + +/// Absolute upper bound on CPU instructions a single contract invocation may +/// consume before hitting the Soroban network limit (~100 million). +/// Used as a soft guard: if a batch's estimated cost exceeds this, reject early. +pub const MAX_INSTRUCTIONS_PER_INVOCATION: u64 = 50_000_000; + +// ── Rate limiting ───────────────────────────────────────────────────────────── + +/// Minimum gap (seconds) between two `bridge_out` calls from the same address. +pub const BRIDGE_OUT_RATE_LIMIT_SECONDS: u64 = 60; + +/// Minimum gap (seconds) between two admin bulk operations (pause/resume/scan) +/// from the same address. Prevents a rogue admin from looping bulk calls. +pub const ADMIN_OP_RATE_LIMIT_SECONDS: u64 = 10; + +const BRIDGE_RATE_LIMIT_KEY: Symbol = symbol_short!("BR_RLMT"); + +/// Storage key for per-address admin operation rate-limit timestamps. +const ADMIN_RATE_LIMIT_KEY: Symbol = symbol_short!("ADM_RLMT"); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Return `BatchSizeLimitExceeded` if `actual > max`. +pub fn check_batch_size(actual: u32, max: u32) -> Result<(), BridgeError> { + if actual > max { + Err(BridgeError::BatchSizeLimitExceeded) + } else { + Ok(()) + } +} + +/// Estimate instruction cost for `batch_size` items at `cost_per_item` and +/// return `BatchSizeLimitExceeded` if the estimate would exceed +/// `MAX_INSTRUCTIONS_PER_INVOCATION`. +/// +/// Call this alongside `check_batch_size` when the per-item cost is non-trivial +/// (e.g. crypto operations, storage writes) to provide a second, cost-aware guard. +pub fn check_instruction_budget(batch_size: u32, cost_per_item: u64) -> Result<(), BridgeError> { + let estimated = (batch_size as u64).saturating_mul(cost_per_item); + if estimated > MAX_INSTRUCTIONS_PER_INVOCATION { + Err(BridgeError::BatchSizeLimitExceeded) + } else { + Ok(()) + } +} + +/// Enforce a per-sender cooldown for `bridge_out`. +/// +/// Reads and updates a per-address timestamp under `BRIDGE_RATE_LIMIT_KEY`. +/// Returns `BridgeError::RetryBackoffActive` if the caller is within the +/// cooldown window. +pub fn check_bridge_out_rate_limit(env: &Env, sender: &Address) -> Result<(), BridgeError> { + let mut limits: Map = env + .storage() + .instance() + .get(&BRIDGE_RATE_LIMIT_KEY) + .unwrap_or_else(|| Map::new(env)); + + let now = env.ledger().timestamp(); + if let Some(last) = limits.get(sender.clone()) { + if now < last.saturating_add(BRIDGE_OUT_RATE_LIMIT_SECONDS) { + return Err(BridgeError::RetryBackoffActive); + } + } + + limits.set(sender.clone(), now); + env.storage() + .instance() + .set(&BRIDGE_RATE_LIMIT_KEY, &limits); + Ok(()) +} + +/// Enforce a per-sender cooldown for admin bulk operations (pause/resume/timeout +/// scan). Prevents a rogue or compromised admin key from flooding the ledger +/// with back-to-back bulk writes. +/// +/// Returns `BridgeError::RetryBackoffActive` if the caller last performed an +/// admin op within `ADMIN_OP_RATE_LIMIT_SECONDS`. +pub fn check_admin_rate_limit(env: &Env, admin: &Address) -> Result<(), BridgeError> { + let mut limits: Map = env + .storage() + .instance() + .get(&ADMIN_RATE_LIMIT_KEY) + .unwrap_or_else(|| Map::new(env)); + + let now = env.ledger().timestamp(); + if let Some(last) = limits.get(admin.clone()) { + if now < last.saturating_add(ADMIN_OP_RATE_LIMIT_SECONDS) { + return Err(BridgeError::RetryBackoffActive); + } + } + + limits.set(admin.clone(), now); + env.storage().instance().set(&ADMIN_RATE_LIMIT_KEY, &limits); + Ok(()) +} diff --git a/contracts/teachlink/src/emergency.rs b/contracts/teachlink/src/emergency.rs index 94bfbf0..cb20ce1 100644 --- a/contracts/teachlink/src/emergency.rs +++ b/contracts/teachlink/src/emergency.rs @@ -1,4 +1,4 @@ -//! Emergency Pause and Recovery Module +//! Emergency Pause and Recovery Module //! //! This module implements circuit breaker functionality and emergency controls //! to protect the bridge during critical situations. @@ -127,6 +127,19 @@ impl EmergencyManager { crate::types::AccessRole::EmergencyManager, )?; + crate::dos_protection::check_admin_rate_limit(env, &pauser)?; + + #[allow(clippy::cast_possible_truncation)] + let batch_len = chain_ids.len() as u32; + crate::dos_protection::check_batch_size( + batch_len, + crate::dos_protection::MAX_CHAIN_BATCH_SIZE, + )?; + crate::dos_protection::check_instruction_budget( + batch_len, + crate::dos_protection::INSTRUCTIONS_PER_CHAIN_OP, + )?; + let mut paused_chains: Map = env .storage() .instance() @@ -164,6 +177,19 @@ impl EmergencyManager { crate::types::AccessRole::EmergencyManager, )?; + crate::dos_protection::check_admin_rate_limit(env, &resumer)?; + + #[allow(clippy::cast_possible_truncation)] + let batch_len = chain_ids.len() as u32; + crate::dos_protection::check_batch_size( + batch_len, + crate::dos_protection::MAX_CHAIN_BATCH_SIZE, + )?; + crate::dos_protection::check_instruction_budget( + batch_len, + crate::dos_protection::INSTRUCTIONS_PER_CHAIN_OP, + )?; + let mut paused_chains: Map = env .storage() .instance() diff --git a/contracts/teachlink/src/lib.rs b/contracts/teachlink/src/lib.rs index 50a30c7..b713369 100644 --- a/contracts/teachlink/src/lib.rs +++ b/contracts/teachlink/src/lib.rs @@ -101,6 +101,7 @@ mod auto_scaling; mod backup; mod bft_consensus; mod bridge; +mod dos_protection; // TODO: Fix collaboration module compilation errors (pre-existing issue) // mod collaboration; // TODO: Fix content_nft module compilation errors (pre-existing issue) diff --git a/contracts/teachlink/src/validation.rs b/contracts/teachlink/src/validation.rs index 1cf01cd..e62fc80 100644 --- a/contracts/teachlink/src/validation.rs +++ b/contracts/teachlink/src/validation.rs @@ -262,11 +262,11 @@ impl StringValidator { pub fn validate_characters(string: &String) -> ValidationResult<()> { let string_bytes = string.to_bytes(); for byte in string_bytes.iter() { - let char = byte as char; - if !char.is_alphanumeric() - && !char.is_whitespace() + let ch = byte as char; + if !ch.is_alphanumeric() + && !ch.is_whitespace() && !matches!( - char, + ch, '-' | '_' | '.' | ',' @@ -283,19 +283,66 @@ impl StringValidator { | ':' ) { - return Err(ValidationError::InvalidStringLength); + return Err(ValidationError::InvalidCharacters); } } Ok(()) } - /// Comprehensive string validation + /// Comprehensive string validation (length + character set). pub fn validate(string: &String, max_length: u32) -> ValidationResult<()> { Self::validate_length(string, max_length)?; Self::validate_non_whitespace(string)?; Self::validate_characters(string)?; Ok(()) } + + /// Validate after stripping ASCII whitespace from both ends. + /// + /// Returns `InvalidStringLength` if the trimmed result is empty or exceeds + /// `max_length`; returns `InvalidCharacters` if forbidden bytes are present. + pub fn trim_and_validate( + env: &Env, + string: &String, + max_length: u32, + ) -> ValidationResult { + let bytes = string.to_bytes(); + let len = bytes.len(); + + if len == 0 { + return Err(ValidationError::InvalidStringLength); + } + + // Find first non-whitespace index. + let mut start = 0u32; + loop { + if start >= len { + return Err(ValidationError::InvalidStringLength); // entirely whitespace + } + if !(bytes.get(start).unwrap() as char).is_ascii_whitespace() { + break; + } + start += 1; + } + + // Find last non-whitespace index. + let mut end = len - 1; + while end > start && (bytes.get(end).unwrap() as char).is_ascii_whitespace() { + end -= 1; + } + + // Build trimmed Bytes by copying the [start, end] range. + let mut trimmed_bytes = Bytes::new(env); + let mut i = start; + while i <= end { + trimmed_bytes.push_back(bytes.get(i).unwrap()); + i += 1; + } + + let trimmed = String::from_bytes(env, &trimmed_bytes); + Self::validate(&trimmed, max_length)?; + Ok(trimmed) + } } /// Bytes validation utilities @@ -648,6 +695,20 @@ impl InputSanitizer { pub fn sanitize_destination_address(bytes: &Bytes) -> ValidationResult<()> { BytesValidator::validate_cross_chain_address(bytes) } + + /// Trim whitespace from `string`, then validate length and character set. + /// + /// Use this instead of calling `StringValidator::validate` directly when the + /// input originates from an untrusted user (description fields, reason strings, + /// reward-type labels, etc.) so that leading/trailing whitespace is always + /// stripped before the length cap is applied. + pub fn sanitize_string( + env: &Env, + string: &String, + max_length: u32, + ) -> ValidationResult { + StringValidator::trim_and_validate(env, string, max_length) + } } /// Bridge-specific validation utilities @@ -702,6 +763,13 @@ impl BridgeValidator { validator_signatures: &Vec
, min_validators: u32, ) -> Result<(), crate::errors::BridgeError> { + // Enforce maximum validator count to prevent DoS via unbounded loop + #[allow(clippy::cast_possible_truncation)] + crate::dos_protection::check_batch_size( + validator_signatures.len() as u32, + crate::dos_protection::MAX_VALIDATORS_PER_COMPLETION, + )?; + // Validate validator signatures count if validator_signatures.len() < min_validators { return Err(crate::errors::BridgeError::InsufficientValidatorSignatures); diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..9bc3e10 --- /dev/null +++ b/deny.toml @@ -0,0 +1,27 @@ +[advisories] +db-urls = ["https://github.com/rustsec/advisory-db"] +unmaintained = "workspace" +ignore = [] + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-DFS-2016", +] + +[bans] +multiple-versions = "warn" +wildcards = "allow" +highlight = "all" +deny = [] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] \ No newline at end of file