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
26 changes: 26 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,32 @@ Caller allocates input buffers, callee allocates result buffers. Caller must fre
3. **Pointer lifetime** — WASM memory is stable within a single function call but may be reallocated between calls
4. **Safety comments required** — All `unsafe` blocks must have `// SAFETY:` comments

## Resource Limits

The evaluator enforces strict resource limits at the WASM boundary and within the Rust API to prevent resource exhaustion from adversarial or accidentally oversized inputs. All limits are defined as constants in `src/limits.rs`.

| Constant | Value | Real-world meaning |
|----------|-------|--------------------|
| `MAX_CONFIG_BYTES` | **100 MB** | ~125,000 complex flags at ~800 bytes each — covers the largest conceivable local-file deployment |
| `MAX_CONTEXT_BYTES` | **1 MB** | ~26,000 context fields at ~40 bytes each — far exceeds any realistic evaluation context |
| `MAX_REF_DEPTH` | **64** | Maximum `$ref` hops during evaluator resolution — more than any real-world chain needs |
| `MAX_JSON_DEPTH` | **128** | Maximum JSON nesting depth checked before parsing — prevents stack overflows |

### Where limits are enforced

- **Config size** (`MAX_CONFIG_BYTES`): Checked in `update_state_internal` (WASM boundary) before reading memory. A host passing an oversized `config_len` gets a JSON error without any memory access.
- **Context size** (`MAX_CONTEXT_BYTES`): Checked in `evaluate_internal` and `evaluate_by_index_internal` before reading context bytes.
- **JSON depth** (`MAX_JSON_DEPTH`): Pre-parse byte scan in `FlagEvaluator::update_state` (see `limits::check_json_depth`). Fires before `serde_json` recursion, preventing stack overflows from adversarial nesting. Also covers YAML configs, since `update_state_from_yaml` converts to JSON first.
- **`$ref` depth** (`MAX_REF_DEPTH`): Depth counter in `resolve_refs` (see `feature_flag.rs`). Counts only `$ref` hops — JSON structural traversal does not consume the budget.

### Behavior when limits are exceeded

All limit violations return deterministic errors — no panics, no crashes:
- Config/context size violations → `{"success": false, "error": "... exceeds maximum ..."}`
- JSON depth violation → `{"success": false, "error": "JSON nesting depth exceeds ..."}`
- `$ref` depth violation → `{"success": false, "error": "$ref resolution depth limit of 64 exceeded ..."}`
- Evaluation with oversized context → `{"reason": "ERROR", "errorCode": "PARSE_ERROR", ...}`

### WASM Build Flags

From `Cargo.toml` release profile:
Expand Down
14 changes: 14 additions & 0 deletions src/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! and validation mode per-instance, allowing multiple independent evaluators
//! in the same process without global state issues.

use crate::limits::check_json_depth;
use crate::model::{FeatureFlag, ParsingResult, UpdateStateResponse};
use crate::operators::create_evaluator;
use crate::types::{ErrorCode, EvaluationResult, ResolutionReason};
Expand Down Expand Up @@ -104,6 +105,19 @@ impl FlagEvaluator {
/// * `Ok(UpdateStateResponse)` - If successful, with changed flag keys
/// * `Err(String)` - If there was an error
pub fn update_state(&mut self, json_config: &str) -> Result<UpdateStateResponse, String> {
// Validate JSON nesting depth before parsing to prevent stack overflows
if let Err(e) = check_json_depth(json_config) {
return Ok(UpdateStateResponse {
success: false,
error: Some(e),
changed_flags: None,
pre_evaluated: None,
required_context_keys: None,
flag_indices: None,
flag_set_metadata: None,
});
}

// Validate the configuration
let validation_result = validate_flags_config(json_config);

Expand Down
229 changes: 173 additions & 56 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ mod wasm_evaluator {
{
let mutex = WASM_EVALUATOR
.get_or_init(|| Mutex::new(evaluator::FlagEvaluator::new(ValidationMode::Strict)));
let mut guard = mutex.lock().unwrap();
// Recover from a poisoned mutex by reclaiming the inner value.
// Mutex poisoning only occurs if a thread panicked while holding the lock;
// since WASM exports use catch_unwind, poisoning should not happen in practice,
// but we handle it gracefully for robustness in native/test contexts.
let mut guard = mutex.lock().unwrap_or_else(|e| e.into_inner());
f(&mut guard)
}
}
Expand Down Expand Up @@ -189,6 +193,9 @@ pub fn get_current_time() -> u64 {

use serde_json::Value;

pub mod limits;
use limits::{MAX_CONFIG_BYTES, MAX_CONTEXT_BYTES};

pub use error::{ErrorType, EvaluatorError};
pub use evaluator::{FlagEvaluator, ValidationMode};
pub use memory::{
Expand Down Expand Up @@ -319,6 +326,19 @@ fn update_state_internal(config_ptr: *const u8, config_len: u32) -> String {
// Initialize panic hook for better error messages
init_panic_hook();

// Reject oversized payloads before touching memory
if config_len as usize > MAX_CONFIG_BYTES {
return serde_json::json!({
"success": false,
"error": format!(
"Config size ({} bytes) exceeds the maximum allowed size of {} bytes (100 MB)",
config_len, MAX_CONFIG_BYTES
),
"changedFlags": null
})
.to_string();
}

// SAFETY: The caller guarantees valid memory regions
let config_str = match unsafe { string_from_memory(config_ptr, config_len) } {
Ok(s) => s,
Expand Down Expand Up @@ -494,6 +514,48 @@ pub extern "C" fn evaluate_by_index(
string_to_memory(&result.to_json_string())
}

/// Parses an evaluation context from a WASM memory pointer.
///
/// Returns `Ok(Value::Null)` when the pointer is null or the length is zero (no context
/// provided). Returns `Err(EvaluationResult)` for any size, UTF-8, or JSON error.
///
/// # Safety
/// `context_ptr` must point to valid memory of at least `context_len` bytes, or be null.
#[allow(clippy::result_large_err)]
unsafe fn parse_context_from_memory(
context_ptr: *const u8,
context_len: u32,
) -> Result<Value, EvaluationResult> {
if context_ptr.is_null() || context_len == 0 {
return Ok(Value::Null);
}

if context_len as usize > MAX_CONTEXT_BYTES {
return Err(EvaluationResult::error(
ErrorCode::ParseError,
format!(
"Context size ({} bytes) exceeds the maximum allowed size of {} bytes (1 MB)",
context_len, MAX_CONTEXT_BYTES
),
));
}

// SAFETY: The caller guarantees valid memory regions
let context_str = unsafe { string_from_memory(context_ptr, context_len) }.map_err(|e| {
EvaluationResult::error(
ErrorCode::ParseError,
format!("Failed to read context: {}", e),
)
})?;

serde_json::from_str(&context_str).map_err(|e| {
EvaluationResult::error(
ErrorCode::ParseError,
format!("Failed to parse context JSON: {}", e),
)
})
}

/// Internal implementation of evaluate_by_index.
fn evaluate_by_index_internal(
flag_index: u32,
Expand All @@ -511,30 +573,10 @@ fn evaluate_by_index_internal(
);
}

// Parse context (pre-enriched by host)
let context: Value = if context_ptr.is_null() || context_len == 0 {
Value::Null
} else {
// SAFETY: The caller guarantees valid memory regions
let context_str = match unsafe { string_from_memory(context_ptr, context_len) } {
Ok(s) => s,
Err(e) => {
return EvaluationResult::error(
ErrorCode::ParseError,
format!("Failed to read context: {}", e),
)
}
};

match serde_json::from_str(&context_str) {
Ok(v) => v,
Err(e) => {
return EvaluationResult::error(
ErrorCode::ParseError,
format!("Failed to parse context JSON: {}", e),
)
}
}
// SAFETY: The caller guarantees valid memory regions
let context = match unsafe { parse_context_from_memory(context_ptr, context_len) } {
Ok(v) => v,
Err(e) => return e,
};

eval.evaluate_flag_by_index(flag_index, context)
Expand Down Expand Up @@ -566,14 +608,6 @@ fn evaluate_internal(
// Catch any panics and convert them to error responses
let result = std::panic::catch_unwind(|| {
wasm_evaluator::with_evaluator(|eval| {
// Check if state is initialized
if eval.get_state().is_none() {
return EvaluationResult::error(
ErrorCode::FlagNotFound,
"Flag state not initialized. Call update_state first.",
);
}

// SAFETY: The caller guarantees valid memory regions
let flag_key = match unsafe { string_from_memory(flag_key_ptr, flag_key_len) } {
Ok(s) => s,
Expand All @@ -585,33 +619,26 @@ fn evaluate_internal(
}
};

let flag = eval.get_state().unwrap().flags.get(&flag_key);
let state = match eval.get_state() {
Some(s) => s,
None => {
return EvaluationResult::error(
ErrorCode::FlagNotFound,
"Flag state not initialized. Call update_state first.",
)
}
};

let flag = state.flags.get(&flag_key);

// Parse protobuf context
let context: Value = if context_ptr.is_null()
|| context_len == 0
|| flag.is_some_and(|f| f.targeting.is_none())
{
// Skip parsing when the flag has no targeting rules — context is unused.
// SAFETY: The caller guarantees valid memory regions
let context = if flag.is_some_and(|f| f.targeting.is_none()) {
Value::Null
} else {
let context_str = match unsafe { string_from_memory(context_ptr, context_len) } {
Ok(s) => s,
Err(e) => {
return EvaluationResult::error(
ErrorCode::ParseError,
format!("Failed to read context: {}", e),
)
}
};

match serde_json::from_str(&context_str) {
match unsafe { parse_context_from_memory(context_ptr, context_len) } {
Ok(v) => v,
Err(e) => {
return EvaluationResult::error(
ErrorCode::ParseError,
format!("Failed to parse context JSON: {}", e),
)
}
Err(e) => return e,
}
};

Expand Down Expand Up @@ -1822,3 +1849,93 @@ mod optimization_tests {
assert!(flag_keys.contains(&"targetingKey".to_string()));
}
}

// ============================================================================
// Adversarial WASM-boundary tests
//
// These tests call update_state_internal / evaluate_internal directly to avoid
// the packed-u64 pointer mechanism, which truncates 64-bit native heap addresses
// to 32 bits and is not suitable for native integration tests.
// ============================================================================

#[cfg(test)]
mod adversarial_wasm_tests {
use super::*;
use std::sync::Mutex;

// Serialize adversarial tests because they share the process-global WASM evaluator.
static TEST_LOCK: Mutex<()> = Mutex::new(());

/// Verifies that a config payload length exceeding `MAX_CONFIG_BYTES` (100 MB)
/// is rejected before any memory is read, returning a deterministic JSON error.
///
/// Passes only the raw pointer to the first byte plus a claimed length above the
/// limit. The size check fires before `string_from_memory` is ever called.
#[test]
fn test_oversized_config_rejected() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tiny_config = b"{";
let over_limit = (limits::MAX_CONFIG_BYTES + 1) as u32;
let response_str = update_state_internal(tiny_config.as_ptr(), over_limit);
let response: serde_json::Value = serde_json::from_str(&response_str)
.expect("update_state_internal must return valid JSON");

assert_eq!(
response["success"].as_bool(),
Some(false),
"Expected success=false for oversized config, got: {response}"
);
let error = response["error"].as_str().expect("Expected error message");
assert!(
error.contains("exceeds") || error.contains("size"),
"Error should describe size limit: {error}"
);
}

/// Verifies that a context payload length exceeding `MAX_CONTEXT_BYTES` (1 MB)
/// is rejected during flag evaluation with a deterministic PARSE_ERROR.
///
/// Passes a valid flag key and a tiny context allocation with a claimed length
/// above the limit. The size check fires before the context bytes are read.
#[test]
fn test_oversized_context_rejected() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());

// Load a valid flag with targeting so the context path is taken
let config = r#"{
"flags": {
"testFlag": {
"state": "ENABLED",
"defaultVariant": "on",
"variants": { "on": true, "off": false },
"targeting": { "var": ["email"] }
}
}
}"#;
update_state_internal(config.as_bytes().as_ptr(), config.len() as u32);

let key = b"testFlag";
let tiny_ctx = b"{";
let over_limit = (limits::MAX_CONTEXT_BYTES + 1) as u32;

let result = evaluate_internal(
key.as_ptr(),
key.len() as u32,
tiny_ctx.as_ptr(),
over_limit,
);

assert_eq!(
result.reason,
types::ResolutionReason::Error,
"Expected ERROR reason for oversized context, got: {:?}",
result
);
assert_eq!(
result.error_code,
Some(types::ErrorCode::ParseError),
"Expected PARSE_ERROR for oversized context, got: {:?}",
result
);
}
}
Loading
Loading