Skip to content

Commit f6ed270

Browse files
committed
fix: close all Chutes TEE model validation bypass vulnerabilities
- Add CHUTES_ALLOWED_MODELS whitelist for defense-in-depth validation - validate_chutes_model() now checks both suffix and whitelist - provider_allows_custom_models() uses case-insensitive comparison - ProviderManager::new() validates model from config for Chutes provider - set_provider() resets model to default TEE model when switching to Chutes - set_model() uses case-insensitive provider check - ensure_client() adds defense-in-depth validation before API calls - Modal handlers now check set_model() result and show error toasts - handle_set_value() validates model changes through provider manager - handle_interactive_selection() checks validation results - Updated unit tests to verify whitelist behavior
1 parent e2b31ec commit f6ed270

5 files changed

Lines changed: 163 additions & 26 deletions

File tree

src/cortex-common/src/model_presets/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pub use types::{ModelAlias, ModelPreset, ModelResolution};
1818
pub use constants::{DEFAULT_MODEL, DEFAULT_MODELS, DEFAULT_PROVIDER};
1919

2020
// Re-export preset data and helpers
21-
pub use presets::{MODEL_PRESETS, get_model_preset, get_models_for_provider, validate_chutes_model, provider_allows_custom_models};
21+
pub use presets::{MODEL_PRESETS, get_model_preset, get_models_for_provider, validate_chutes_model, provider_allows_custom_models, CHUTES_ALLOWED_MODELS};
2222

2323
// Re-export alias data and helpers
2424
pub use aliases::{MODEL_ALIASES, list_model_aliases, resolve_model_alias};

src/cortex-common/src/model_presets/presets.rs

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
33
use super::types::ModelPreset;
44

5+
/// Allowed models for Chutes provider (whitelist approach for security).
6+
/// Only these models can be used with the Chutes provider.
7+
pub const CHUTES_ALLOWED_MODELS: &[&str] = &["moonshotai/Kimi-K2.5-TEE"];
8+
59
/// Available model presets.
610
pub const MODEL_PRESETS: &[ModelPreset] = &[
711
ModelPreset {
@@ -837,21 +841,41 @@ pub fn get_models_for_provider(provider: &str) -> Vec<&'static ModelPreset> {
837841
/// Returns Ok(()) if valid, Err with message if invalid.
838842
pub fn validate_chutes_model(model: &str) -> Result<(), String> {
839843
let model = model.trim();
844+
845+
// Check for empty model
846+
if model.is_empty() {
847+
return Err("Model name cannot be empty for Chutes provider".to_string());
848+
}
849+
850+
// Check suffix (case-insensitive)
840851
if !model.to_uppercase().ends_with("-TEE") {
841852
return Err(format!(
842853
"Chutes provider only allows TEE models (models ending with '-TEE'). \
843-
Model '{}' is not a TEE model. Available TEE models: moonshotai/Kimi-K2.5-TEE",
844-
model
854+
Model '{}' is not a TEE model. Available TEE models: {}",
855+
model,
856+
CHUTES_ALLOWED_MODELS.join(", ")
845857
));
846858
}
859+
860+
// SECURITY: Also verify model is in the allowed whitelist
861+
if !CHUTES_ALLOWED_MODELS.iter().any(|&allowed| allowed.eq_ignore_ascii_case(model)) {
862+
return Err(format!(
863+
"Model '{}' is not in the allowed Chutes models list. \
864+
Available models: {}",
865+
model,
866+
CHUTES_ALLOWED_MODELS.join(", ")
867+
));
868+
}
869+
847870
Ok(())
848871
}
849872

850873
/// Checks if a provider restricts custom models.
851874
/// Chutes only allows predefined TEE models, no custom models.
852875
pub fn provider_allows_custom_models(provider: &str) -> bool {
853876
// Chutes does NOT allow custom models - only predefined TEE models
854-
provider != "chutes"
877+
// Use case-insensitive comparison
878+
!provider.eq_ignore_ascii_case("chutes")
855879
}
856880

857881
#[cfg(test)]
@@ -860,31 +884,52 @@ mod tests {
860884

861885
#[test]
862886
fn test_validate_chutes_model_valid() {
863-
// Valid TEE models
887+
// Valid TEE models in whitelist
864888
assert!(validate_chutes_model("moonshotai/Kimi-K2.5-TEE").is_ok());
865-
assert!(validate_chutes_model("some-model-TEE").is_ok());
866889
// Case insensitive
867-
assert!(validate_chutes_model("model-tee").is_ok());
868-
assert!(validate_chutes_model("model-Tee").is_ok());
890+
assert!(validate_chutes_model("moonshotai/kimi-k2.5-tee").is_ok());
891+
assert!(validate_chutes_model("MOONSHOTAI/KIMI-K2.5-TEE").is_ok());
869892
// Whitespace handling
870-
assert!(validate_chutes_model(" model-TEE ").is_ok());
893+
assert!(validate_chutes_model(" moonshotai/Kimi-K2.5-TEE ").is_ok());
871894
}
872895

873896
#[test]
874897
fn test_validate_chutes_model_invalid() {
898+
// Not a TEE model
875899
assert!(validate_chutes_model("gpt-4").is_err());
876900
assert!(validate_chutes_model("claude-3").is_err());
901+
902+
// Has TEE suffix but NOT in whitelist
903+
assert!(validate_chutes_model("fake-model-TEE").is_err());
904+
assert!(validate_chutes_model("some-model-TEE").is_err());
905+
906+
// TEE in wrong position
877907
assert!(validate_chutes_model("model-TEE-v2").is_err());
878-
assert!(validate_chutes_model("").is_err());
908+
909+
// Empty string
910+
let result = validate_chutes_model("");
911+
assert!(result.is_err());
912+
assert!(result.unwrap_err().contains("cannot be empty"));
879913
}
880914

881915
#[test]
882916
fn test_provider_allows_custom_models() {
883-
// Chutes does NOT allow custom models
917+
// Chutes does NOT allow custom models - case insensitive
884918
assert!(!provider_allows_custom_models("chutes"));
919+
assert!(!provider_allows_custom_models("Chutes"));
920+
assert!(!provider_allows_custom_models("CHUTES"));
921+
885922
// Other providers allow custom models
886923
assert!(provider_allows_custom_models("cortex"));
887924
assert!(provider_allows_custom_models("openai"));
888925
assert!(provider_allows_custom_models("anthropic"));
889926
}
927+
928+
#[test]
929+
fn test_chutes_allowed_models_list() {
930+
// Verify the whitelist contains expected model
931+
assert!(CHUTES_ALLOWED_MODELS.contains(&"moonshotai/Kimi-K2.5-TEE"));
932+
// Whitelist should not be empty
933+
assert!(!CHUTES_ALLOWED_MODELS.is_empty());
934+
}
890935
}

src/cortex-tui/src/providers/manager.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use cortex_engine::client::{
1111

1212
use super::config::CortexConfig;
1313
use super::models::{ModelInfo, get_models_for_provider, get_popular_models};
14-
use cortex_common::model_presets::validate_chutes_model;
14+
use cortex_common::model_presets::{validate_chutes_model, CHUTES_ALLOWED_MODELS};
1515

1616
// ============================================================
1717
// PROVIDER MANAGER
@@ -40,7 +40,22 @@ impl ProviderManager {
4040
pub fn new(config: CortexConfig) -> Self {
4141
let (current_provider, current_model) =
4242
if let Some((provider, model)) = config.get_last_model() {
43-
(provider.to_string(), model.to_string())
43+
// SECURITY: Validate model when loading for Chutes provider
44+
if provider.eq_ignore_ascii_case("chutes") {
45+
if validate_chutes_model(model).is_err() {
46+
// Fall back to first allowed Chutes model if invalid
47+
tracing::warn!(
48+
"Invalid TEE model '{}' loaded from config for Chutes provider, \
49+
resetting to default",
50+
model
51+
);
52+
(provider.to_string(), CHUTES_ALLOWED_MODELS[0].to_string())
53+
} else {
54+
(provider.to_string(), model.to_string())
55+
}
56+
} else {
57+
(provider.to_string(), model.to_string())
58+
}
4459
} else {
4560
(
4661
config.default_provider.clone(),
@@ -212,6 +227,19 @@ impl ProviderManager {
212227
/// Sets the current provider.
213228
pub fn set_provider(&mut self, provider: &str) -> Result<()> {
214229
self.current_provider = provider.to_string();
230+
231+
// SECURITY: Validate current model when switching to Chutes provider
232+
if provider.eq_ignore_ascii_case("chutes") {
233+
if validate_chutes_model(&self.current_model).is_err() {
234+
tracing::warn!(
235+
"Current model '{}' is not a valid TEE model for Chutes provider, \
236+
switching to default",
237+
self.current_model
238+
);
239+
self.current_model = CHUTES_ALLOWED_MODELS[0].to_string();
240+
}
241+
}
242+
215243
self.client = None;
216244
Ok(())
217245
}
@@ -221,7 +249,8 @@ impl ProviderManager {
221249
let resolved = self.config.resolve_alias(model);
222250

223251
// Validate model for Chutes provider (TEE-only security requirement)
224-
if self.current_provider == "chutes" {
252+
// Use case-insensitive comparison for provider check
253+
if self.current_provider.eq_ignore_ascii_case("chutes") {
225254
validate_chutes_model(&resolved)
226255
.map_err(|e| anyhow::anyhow!(e))?;
227256
}
@@ -369,6 +398,13 @@ impl ProviderManager {
369398
if self.client.is_some() {
370399
return Ok(());
371400
}
401+
402+
// SECURITY: Defense-in-depth - validate TEE model before API calls
403+
if self.current_provider.eq_ignore_ascii_case("chutes") {
404+
validate_chutes_model(&self.current_model)
405+
.map_err(|e| anyhow::anyhow!("{}", e))?;
406+
}
407+
372408
let token = self.get_token()?;
373409
self.client = Some(create_client("cortex", &self.current_model, &token, None)?);
374410
Ok(())

src/cortex-tui/src/runner/event_loop/commands.rs

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -548,22 +548,56 @@ impl EventLoop {
548548
pub(super) fn handle_set_value(&mut self, key: &str, value: &str) {
549549
match key {
550550
"model" => {
551-
self.app_state.model = value.to_string();
552-
if let Some(pm) = &self.provider_manager
551+
// Check validation result first, storing any error
552+
let validation_result: Result<(), String> = if let Some(pm) = &self.provider_manager
553553
&& let Ok(mut manager) = pm.try_write()
554554
{
555-
let _ = manager.set_model(value);
555+
manager.set_model(value).map_err(|e| e.to_string())
556+
} else {
557+
Ok(())
558+
};
559+
560+
// Handle validation result after releasing the borrow
561+
if let Err(e) = validation_result {
562+
self.add_system_message(&format!("Cannot set model: {}", e));
563+
return;
556564
}
565+
566+
// Only update state if validation passed
567+
self.app_state.model = value.to_string();
557568
self.update_session_model(value);
558569
self.add_system_message(&format!("Model set to: {}", value));
559570
}
560571
"provider" => {
561-
self.app_state.provider = value.to_string();
562-
if let Some(pm) = &self.provider_manager
572+
// Check validation result first, storing any error and new model
573+
let validation_result: Result<Option<String>, String> = if let Some(pm) = &self.provider_manager
563574
&& let Ok(mut manager) = pm.try_write()
564575
{
565-
let _ = manager.set_provider(value);
576+
match manager.set_provider(value) {
577+
Ok(()) => {
578+
// Get updated model after provider switch
579+
Ok(Some(manager.current_model().to_string()))
580+
}
581+
Err(e) => Err(e.to_string()),
582+
}
583+
} else {
584+
Ok(None)
585+
};
586+
587+
// Handle validation result after releasing the borrow
588+
match validation_result {
589+
Err(e) => {
590+
self.add_system_message(&format!("Cannot set provider: {}", e));
591+
return;
592+
}
593+
Ok(Some(new_model)) => {
594+
// Update model to reflect any changes made during provider switch
595+
self.app_state.model = new_model;
596+
}
597+
Ok(None) => {}
566598
}
599+
600+
self.app_state.provider = value.to_string();
567601
self.add_system_message(&format!("Provider set to: {}", value));
568602
}
569603
"session_name" => {

src/cortex-tui/src/runner/event_loop/modal.rs

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,21 @@ impl EventLoop {
3737
KeyCode::Enter => {
3838
if let Some(model) = self.app_state.model_picker.selected_model() {
3939
let model_id = model.id.clone();
40-
self.app_state.model = model_id.clone();
4140

41+
// Validate through manager instead of direct assignment
4242
if let Some(pm) = &self.provider_manager
4343
&& let Ok(mut manager) = pm.try_write()
4444
{
45-
let _ = manager.set_model(&model_id);
45+
if let Err(e) = manager.set_model(&model_id) {
46+
self.app_state.toasts.error(format!("Cannot use model: {}", e));
47+
self.app_state.close_modal();
48+
return Ok(true);
49+
}
4650
}
4751

52+
// Only update if validation passed
53+
self.app_state.model = model_id.clone();
54+
4855
if let Ok(mut config) = crate::providers::config::CortexConfig::load() {
4956
let _ = config.save_last_model(&self.app_state.provider, &model_id);
5057
}
@@ -216,11 +223,17 @@ impl EventLoop {
216223
pub(super) async fn process_modal_action(&mut self, action: ModalAction) {
217224
match action {
218225
ModalAction::SelectModel(model_id) => {
219-
self.app_state.model = model_id.clone();
226+
// Don't set model directly - let set_model handle validation
220227
if let Some(pm) = &self.provider_manager
221228
&& let Ok(mut manager) = pm.try_write()
222229
{
223-
let _ = manager.set_model(&model_id);
230+
// Check result of set_model instead of ignoring it
231+
if let Err(e) = manager.set_model(&model_id) {
232+
self.app_state.toasts.error(format!("Cannot use model: {}", e));
233+
return;
234+
}
235+
// Only update app state if validation passed
236+
self.app_state.model = model_id.clone();
224237
}
225238
if let Ok(mut config) = crate::providers::config::CortexConfig::load() {
226239
let _ = config.save_last_model(&self.app_state.provider, &model_id);
@@ -498,7 +511,12 @@ impl EventLoop {
498511
if let Some(pm) = &self.provider_manager
499512
&& let Ok(mut manager) = pm.try_write()
500513
{
501-
let _ = manager.set_provider(&item_id);
514+
if let Err(e) = manager.set_provider(&item_id) {
515+
self.app_state.toasts.error(format!("Cannot switch provider: {}", e));
516+
return false;
517+
}
518+
// Update model to reflect any changes made during provider switch
519+
self.app_state.model = manager.current_model().to_string();
502520
self.app_state.provider = item_id.clone();
503521
}
504522
return false;
@@ -507,7 +525,11 @@ impl EventLoop {
507525
if let Some(pm) = &self.provider_manager
508526
&& let Ok(mut manager) = pm.try_write()
509527
{
510-
let _ = manager.set_model(&item_id);
528+
// Check validation result
529+
if let Err(e) = manager.set_model(&item_id) {
530+
self.app_state.toasts.error(format!("Cannot use model: {}", e));
531+
return false;
532+
}
511533
self.app_state.model = item_id.clone();
512534
}
513535
self.update_session_model(&item_id);

0 commit comments

Comments
 (0)