Skip to content

Commit b8b3101

Browse files
committed
fix(security): tighten fs path extraction and honor forwarder test timeout
1 parent 854c3a4 commit b8b3101

File tree

2 files changed

+109
-11
lines changed

2 files changed

+109
-11
lines changed

crates/libs/clawdstrike/src/irm/fs.rs

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -135,19 +135,24 @@ impl FilesystemIrm {
135135

136136
/// Extract path from host call arguments
137137
fn extract_path(&self, call: &HostCall) -> Option<String> {
138+
// Prefer explicit object path fields over free-form string args.
138139
for arg in &call.args {
139-
if let Some(s) = arg.as_str() {
140-
if self.looks_like_path(s) {
141-
return Some(s.to_string());
140+
if let Some(obj) = arg.as_object() {
141+
for key in ["path", "file_path", "target_path"] {
142+
if let Some(path) = obj.get(key).and_then(|value| value.as_str()) {
143+
let trimmed = path.trim();
144+
if !trimmed.is_empty() {
145+
return Some(trimmed.to_string());
146+
}
147+
}
142148
}
143149
}
144150
}
145151

146-
// Check for named path argument
147-
if let Some(first) = call.args.first() {
148-
if let Some(obj) = first.as_object() {
149-
if let Some(path) = obj.get("path") {
150-
return path.as_str().map(|s| s.to_string());
152+
for arg in &call.args {
153+
if let Some(s) = arg.as_str() {
154+
if self.looks_like_path(s) {
155+
return Some(s.to_string());
151156
}
152157
}
153158
}
@@ -172,7 +177,36 @@ impl FilesystemIrm {
172177
return true;
173178
}
174179

175-
value.contains('/') && !value.contains("://")
180+
value.contains('/') && !value.contains("://") && !self.looks_like_mime_type(value)
181+
}
182+
183+
fn looks_like_mime_type(&self, value: &str) -> bool {
184+
let mut parts = value.split('/');
185+
let Some(kind) = parts.next() else {
186+
return false;
187+
};
188+
let Some(subtype) = parts.next() else {
189+
return false;
190+
};
191+
if parts.next().is_some() {
192+
return false;
193+
}
194+
if kind.is_empty() || subtype.is_empty() {
195+
return false;
196+
}
197+
198+
matches!(
199+
kind.to_ascii_lowercase().as_str(),
200+
"application"
201+
| "audio"
202+
| "font"
203+
| "image"
204+
| "message"
205+
| "model"
206+
| "multipart"
207+
| "text"
208+
| "video"
209+
)
176210
}
177211

178212
fn has_parent_traversal(&self, path: &str) -> bool {
@@ -341,6 +375,21 @@ mod tests {
341375
Some("../../etc/passwd".to_string())
342376
);
343377

378+
let call = HostCall::new(
379+
"fd_read",
380+
vec![
381+
serde_json::json!("text/plain"),
382+
serde_json::json!({"path": "../../etc/passwd"}),
383+
],
384+
);
385+
assert_eq!(
386+
irm.extract_path(&call),
387+
Some("../../etc/passwd".to_string())
388+
);
389+
390+
assert!(!irm.looks_like_path("text/plain"));
391+
assert!(irm.looks_like_path("src/main.rs"));
392+
344393
let call = HostCall::new("fd_read", vec![serde_json::json!(123)]);
345394
assert_eq!(irm.extract_path(&call), None);
346395
}
@@ -366,6 +415,19 @@ mod tests {
366415
!decision.is_allowed(),
367416
"object traversal path should be denied"
368417
);
418+
419+
let call = HostCall::new(
420+
"fd_read",
421+
vec![
422+
serde_json::json!("text/plain"),
423+
serde_json::json!({"path": "../../etc/passwd"}),
424+
],
425+
);
426+
let decision = irm.evaluate(&call, &policy).await;
427+
assert!(
428+
!decision.is_allowed(),
429+
"object traversal path must not be bypassed by slash-containing non-path tokens"
430+
);
369431
}
370432

371433
#[test]

crates/services/hush-cli/src/hush_run.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,7 @@ impl HushdForwarder {
129129
let mut req = self
130130
.client
131131
.post(format!("{}/api/v1/eval", self.base_url))
132-
.json(event)
133-
.timeout(HUSHD_FORWARD_TIMEOUT);
132+
.json(event);
134133

135134
if let Some(token) = self.token.as_ref() {
136135
req = req.bearer_auth(token);
@@ -1468,4 +1467,41 @@ name: "slowloris-cap"
14681467
let _ = tokio::time::timeout(Duration::from_secs(2), writer).await;
14691468
stalled_handle.abort();
14701469
}
1470+
1471+
#[tokio::test]
1472+
async fn forwarder_test_timeout_is_respected() {
1473+
let stalled_listener = TcpListener::bind(("127.0.0.1", 0))
1474+
.await
1475+
.expect("bind stalled target");
1476+
let stalled_addr = stalled_listener.local_addr().expect("stalled addr");
1477+
let stalled_handle = tokio::spawn(async move {
1478+
while let Ok((mut stream, _)) = stalled_listener.accept().await {
1479+
tokio::spawn(async move {
1480+
let mut buf = [0u8; 1024];
1481+
let _ =
1482+
tokio::time::timeout(Duration::from_secs(1), stream.read(&mut buf)).await;
1483+
tokio::time::sleep(Duration::from_secs(5)).await;
1484+
});
1485+
}
1486+
});
1487+
1488+
let forwarder = HushdForwarder::new_with_timeout(
1489+
format!("http://{}", stalled_addr),
1490+
None,
1491+
Duration::from_millis(50),
1492+
);
1493+
let event = test_custom_event(0);
1494+
1495+
let started = tokio::time::Instant::now();
1496+
tokio::time::timeout(Duration::from_millis(300), forwarder.forward_event(&event))
1497+
.await
1498+
.expect("forward_event should honor test timeout");
1499+
let elapsed = started.elapsed();
1500+
assert!(
1501+
elapsed < Duration::from_millis(300),
1502+
"forward_event exceeded expected test timeout; elapsed: {elapsed:?}"
1503+
);
1504+
1505+
stalled_handle.abort();
1506+
}
14711507
}

0 commit comments

Comments
 (0)