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
27 changes: 27 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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)"
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
146 changes: 146 additions & 0 deletions contracts/teachlink/src/dos_protection.rs
Original file line number Diff line number Diff line change
@@ -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<Address, u64> = 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<Address, u64> = 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(())
}
28 changes: 27 additions & 1 deletion contracts/teachlink/src/emergency.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<u32, bool> = env
.storage()
.instance()
Expand Down Expand Up @@ -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<u32, bool> = env
.storage()
.instance()
Expand Down
1 change: 1 addition & 0 deletions contracts/teachlink/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
80 changes: 74 additions & 6 deletions contracts/teachlink/src/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
'-' | '_'
| '.'
| ','
Expand All @@ -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<String> {
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
Expand Down Expand Up @@ -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<String> {
StringValidator::trim_and_validate(env, string, max_length)
}
}

/// Bridge-specific validation utilities
Expand Down Expand Up @@ -702,6 +763,13 @@ impl BridgeValidator {
validator_signatures: &Vec<Address>,
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);
Expand Down
Loading
Loading