Skip to content

Commit fdbef19

Browse files
committed
fix(plugins): address Greptile security and deadlock concerns
Security fixes: - Add SSRF protection in registry.rs: validate download URLs to block private IPs (10.x, 172.16-31.x, 192.168.x, 169.254.x, 127.x), localhost, dangerous ports, and non-HTTPS protocols before downloading plugins - Add directory traversal protection: validate plugin IDs to prevent '../' and path separator attacks that could write outside the target directory - Add ValidationError type for security validation failures Concurrency fixes: - Replace tokio::sync::RwLock with std::sync::Mutex in PluginHostState for widgets, keybindings, events, and toasts collections - Remove block_on() calls in WASM host functions (register_widget, register_keybinding, show_toast, emit_event) to prevent potential deadlocks when tokio runtime is already blocked on the WASM call - Add proper error handling for poisoned mutex locks Code clarity: - Add clarifying comments for JSON validation in emit_event to explain why empty data strings are intentionally allowed (represents 'no data' case) - Add comprehensive documentation for security measures Tests: - Add tests for SSRF protection (private IPs, dangerous ports, non-HTTPS) - Add tests for directory traversal prevention - All 108 tests pass
1 parent b099518 commit fdbef19

File tree

3 files changed

+454
-35
lines changed

3 files changed

+454
-35
lines changed

src/cortex-plugins/src/error.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ pub enum PluginError {
104104
/// Registry error.
105105
#[error("Registry error: {0}")]
106106
RegistryError(String),
107+
108+
/// Validation error (SSRF protection, path traversal, etc.).
109+
#[error("Validation error for '{field}': {message}")]
110+
ValidationError { field: String, message: String },
107111
}
108112

109113
impl PluginError {
@@ -175,6 +179,14 @@ impl PluginError {
175179
actual: actual.into(),
176180
}
177181
}
182+
183+
/// Create a validation error (for SSRF protection, path traversal, etc.).
184+
pub fn validation_error(field: impl Into<String>, message: impl Into<String>) -> Self {
185+
Self::ValidationError {
186+
field: field.into(),
187+
message: message.into(),
188+
}
189+
}
178190
}
179191

180192
impl From<toml::de::Error> for PluginError {

src/cortex-plugins/src/host.rs

Lines changed: 76 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,16 @@
22
//!
33
//! This module provides the host-side implementation of functions that WASM plugins
44
//! can call. These functions are exposed to plugins through the wasmtime Linker.
5+
//!
6+
//! # Synchronous Context
7+
//!
8+
//! WASM host functions run in a synchronous context (called from wasmtime's sync API).
9+
//! We avoid using `tokio::runtime::Handle::block_on()` to prevent potential deadlocks
10+
//! when the tokio runtime is already blocked on the WASM call. Instead, we use
11+
//! `std::sync::Mutex` for state that needs synchronous access from host functions.
512
613
use std::collections::HashMap;
7-
use std::sync::Arc;
8-
use tokio::sync::RwLock;
14+
use std::sync::{Arc, Mutex};
915
use wasmtime::{Caller, Engine, Linker};
1016

1117
use crate::Result;
@@ -77,25 +83,34 @@ impl ToastLevel {
7783
}
7884

7985
/// State shared between the host and WASM plugins.
86+
///
87+
/// Uses `std::sync::Mutex` instead of `tokio::sync::RwLock` to allow synchronous
88+
/// access from WASM host functions without risking deadlocks. WASM host functions
89+
/// are called synchronously by wasmtime, and using `block_on()` to access async
90+
/// locks could deadlock if the tokio runtime is already blocked on the WASM call.
8091
#[derive(Debug, Clone)]
8192
pub struct PluginHostState {
8293
pub plugin_id: String,
8394
pub context: PluginContext,
84-
pub widgets: Arc<RwLock<HashMap<UiRegion, Vec<String>>>>,
85-
pub keybindings: Arc<RwLock<HashMap<String, String>>>,
86-
pub events: Arc<RwLock<Vec<PluginEvent>>>,
87-
pub toasts: Arc<RwLock<Vec<ToastNotification>>>,
95+
/// Registered widgets by UI region. Uses sync Mutex for safe access from WASM host functions.
96+
pub widgets: Arc<Mutex<HashMap<UiRegion, Vec<String>>>>,
97+
/// Registered keybindings (key -> action). Uses sync Mutex for safe access from WASM host functions.
98+
pub keybindings: Arc<Mutex<HashMap<String, String>>>,
99+
/// Emitted events queue. Uses sync Mutex for safe access from WASM host functions.
100+
pub events: Arc<Mutex<Vec<PluginEvent>>>,
101+
/// Toast notifications queue. Uses sync Mutex for safe access from WASM host functions.
102+
pub toasts: Arc<Mutex<Vec<ToastNotification>>>,
88103
}
89104

90105
impl PluginHostState {
91106
pub fn new(plugin_id: impl Into<String>, context: PluginContext) -> Self {
92107
Self {
93108
plugin_id: plugin_id.into(),
94109
context,
95-
widgets: Arc::new(RwLock::new(HashMap::new())),
96-
keybindings: Arc::new(RwLock::new(HashMap::new())),
97-
events: Arc::new(RwLock::new(Vec::new())),
98-
toasts: Arc::new(RwLock::new(Vec::new())),
110+
widgets: Arc::new(Mutex::new(HashMap::new())),
111+
keybindings: Arc::new(Mutex::new(HashMap::new())),
112+
events: Arc::new(Mutex::new(Vec::new())),
113+
toasts: Arc::new(Mutex::new(Vec::new())),
99114
}
100115
}
101116
}
@@ -333,11 +348,17 @@ fn register_widget_impl<T: HasHostState>(
333348
}
334349
};
335350

336-
if let Ok(handle) = tokio::runtime::Handle::try_current() {
337-
handle.block_on(async {
338-
let mut w = widgets.write().await;
351+
// Use sync Mutex instead of async RwLock to avoid deadlock risk.
352+
// WASM host functions run synchronously, and using block_on() on an async lock
353+
// could deadlock if the tokio runtime is already blocked on this WASM call.
354+
match widgets.lock() {
355+
Ok(mut w) => {
339356
w.entry(ui_region).or_default().push(widget_type.clone());
340-
});
357+
}
358+
Err(e) => {
359+
tracing::error!(plugin = %plugin_id, error = %e, "Failed to acquire widget lock (poisoned)");
360+
return HostError::InternalError.into();
361+
}
341362
}
342363
tracing::debug!(plugin = %plugin_id, widget_type = %widget_type, region = ?ui_region, "Widget registered");
343364
HostError::Success.into()
@@ -369,11 +390,17 @@ fn register_keybinding_impl<T: HasHostState>(
369390
return HostError::InvalidArgument.into();
370391
}
371392

372-
if let Ok(handle) = tokio::runtime::Handle::try_current() {
373-
handle.block_on(async {
374-
let mut kb = keybindings.write().await;
393+
// Use sync Mutex instead of async RwLock to avoid deadlock risk.
394+
// WASM host functions run synchronously, and using block_on() on an async lock
395+
// could deadlock if the tokio runtime is already blocked on this WASM call.
396+
match keybindings.lock() {
397+
Ok(mut kb) => {
375398
kb.insert(key.clone(), action.clone());
376-
});
399+
}
400+
Err(e) => {
401+
tracing::error!(plugin = %plugin_id, error = %e, "Failed to acquire keybinding lock (poisoned)");
402+
return HostError::InternalError.into();
403+
}
377404
}
378405
tracing::debug!(plugin = %plugin_id, key = %key, action = %action, "Keybinding registered");
379406
HostError::Success.into()
@@ -406,11 +433,17 @@ fn show_toast_impl<T: HasHostState>(
406433
plugin_id: plugin_id.clone(),
407434
};
408435

409-
if let Ok(handle) = tokio::runtime::Handle::try_current() {
410-
handle.block_on(async {
411-
let mut t = toasts.write().await;
436+
// Use sync Mutex instead of async RwLock to avoid deadlock risk.
437+
// WASM host functions run synchronously, and using block_on() on an async lock
438+
// could deadlock if the tokio runtime is already blocked on this WASM call.
439+
match toasts.lock() {
440+
Ok(mut t) => {
412441
t.push(toast);
413-
});
442+
}
443+
Err(e) => {
444+
tracing::error!(plugin = %plugin_id, error = %e, "Failed to acquire toast lock (poisoned)");
445+
return HostError::InternalError.into();
446+
}
414447
}
415448
tracing::debug!(plugin = %plugin_id, message = %message, "Toast queued");
416449
HostError::Success.into()
@@ -442,10 +475,13 @@ fn emit_event_impl<T: HasHostState>(
442475
return HostError::InvalidArgument.into();
443476
}
444477

445-
if !data.is_empty()
446-
&& serde_json::from_str::<serde_json::Value>(&data).is_err() {
447-
return HostError::InvalidArgument.into();
448-
}
478+
// Validate that data is valid JSON if non-empty.
479+
// Empty data is allowed and represents "no data" (null/empty event payload).
480+
// This avoids confusing behavior where `serde_json::from_str("")` would fail,
481+
// which we explicitly want to allow as a valid "no data" case.
482+
if !data.is_empty() && serde_json::from_str::<serde_json::Value>(&data).is_err() {
483+
return HostError::InvalidArgument.into();
484+
}
449485

450486
let event = PluginEvent {
451487
name: name.clone(),
@@ -454,11 +490,17 @@ fn emit_event_impl<T: HasHostState>(
454490
timestamp: chrono::Utc::now(),
455491
};
456492

457-
if let Ok(handle) = tokio::runtime::Handle::try_current() {
458-
handle.block_on(async {
459-
let mut e = events.write().await;
493+
// Use sync Mutex instead of async RwLock to avoid deadlock risk.
494+
// WASM host functions run synchronously, and using block_on() on an async lock
495+
// could deadlock if the tokio runtime is already blocked on this WASM call.
496+
match events.lock() {
497+
Ok(mut e) => {
460498
e.push(event);
461-
});
499+
}
500+
Err(e) => {
501+
tracing::error!(plugin = %plugin_id, error = %e, "Failed to acquire event lock (poisoned)");
502+
return HostError::InternalError.into();
503+
}
462504
}
463505
tracing::debug!(plugin = %plugin_id, event_name = %name, "Event emitted");
464506
HostError::Success.into()
@@ -508,19 +550,19 @@ mod tests {
508550
assert!(result.is_ok());
509551
}
510552

511-
#[tokio::test]
512-
async fn test_plugin_host_state_widgets() {
553+
#[test]
554+
fn test_plugin_host_state_widgets() {
513555
let context = PluginContext::new("/tmp");
514556
let state = PluginHostState::new("test-plugin", context);
515557
{
516-
let mut widgets = state.widgets.write().await;
558+
let mut widgets = state.widgets.lock().expect("lock should not be poisoned");
517559
widgets
518560
.entry(UiRegion::StatusBar)
519561
.or_default()
520562
.push("test_widget".to_string());
521563
}
522564
{
523-
let widgets = state.widgets.read().await;
565+
let widgets = state.widgets.lock().expect("lock should not be poisoned");
524566
assert!(widgets.get(&UiRegion::StatusBar).is_some());
525567
assert_eq!(widgets.get(&UiRegion::StatusBar).unwrap()[0], "test_widget");
526568
}

0 commit comments

Comments
 (0)