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