Skip to content

Commit 45e76cc

Browse files
authored
fix(builtins): sanitize curl multipart field names to prevent header injection (#1053)
## Summary - Add `sanitize_multipart_name()` that rejects `\r`/`\n` and escapes `"` in field names/filenames - Early validation before network check (defense in depth) - Sanitization applied when building `Content-Disposition` headers in multipart body ## Test plan - [ ] Field name with `"` is properly escaped - [ ] Field name with `\r\n` is rejected with error (exit 2) - [ ] Normal multipart fields still work - [ ] 5 unit tests + 3 integration tests pass - [ ] All existing curl tests pass Closes #985
1 parent 0a1daef commit 45e76cc

File tree

1 file changed

+115
-3
lines changed

1 file changed

+115
-3
lines changed

crates/bashkit/src/builtins/curl.rs

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
//! - URLs must match the configured allowlist
99
//! - Response size is limited (default: 10MB) to prevent memory exhaustion
1010
//! - Timeouts prevent hanging on unresponsive servers
11+
//! - Multipart field names/filenames are sanitized to prevent header injection (issue #985)
1112
//! - Redirects are not followed automatically (to prevent allowlist bypass)
1213
//! - Compression decompression is size-limited to prevent zip bombs
1314
@@ -205,6 +206,17 @@ impl Builtin for Curl {
205206
}
206207
};
207208

209+
// Validate multipart field names early to reject injection attempts
210+
// even when network is not configured (defense in depth).
211+
for field in &form_fields {
212+
if let Some(eq_pos) = field.find('=') {
213+
let name = &field[..eq_pos];
214+
if let Err(e) = sanitize_multipart_name(name, "field name") {
215+
return Ok(ExecResult::err(e, 2));
216+
}
217+
}
218+
}
219+
208220
// Check if network is configured
209221
#[cfg(feature = "http_client")]
210222
{
@@ -268,6 +280,18 @@ impl Builtin for Curl {
268280
}
269281
}
270282

283+
/// Sanitize a multipart field name or filename to prevent header injection.
284+
/// Rejects CR/LF characters (which could inject headers) and escapes double quotes.
285+
fn sanitize_multipart_name(value: &str, label: &str) -> std::result::Result<String, String> {
286+
if value.contains('\r') || value.contains('\n') {
287+
return Err(format!(
288+
"curl: multipart {} contains illegal newline characters\n",
289+
label
290+
));
291+
}
292+
Ok(value.replace('"', "\\\""))
293+
}
294+
271295
/// Execute the actual curl request when http_client feature is enabled.
272296
#[cfg(feature = "http_client")]
273297
#[allow(clippy::too_many_arguments)]
@@ -364,6 +388,13 @@ async fn execute_curl_request(
364388
if let Some(eq_pos) = field.find('=') {
365389
let name = &field[..eq_pos];
366390
let value = &field[eq_pos + 1..];
391+
392+
// Sanitize field name to prevent header injection
393+
let safe_name = match sanitize_multipart_name(name, "field name") {
394+
Ok(n) => n,
395+
Err(e) => return Ok(ExecResult::err(e, 2)),
396+
};
397+
367398
body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
368399
if let Some(file_path) = value.strip_prefix('@') {
369400
// File upload: key=@filepath[;type=mime]
@@ -383,10 +414,17 @@ async fn execute_curl_request(
383414
.file_name()
384415
.map(|n| n.to_string_lossy().to_string())
385416
.unwrap_or_else(|| "file".to_string());
417+
418+
// Sanitize filename to prevent header injection
419+
let safe_filename = match sanitize_multipart_name(&filename, "filename") {
420+
Ok(n) => n,
421+
Err(e) => return Ok(ExecResult::err(e, 2)),
422+
};
423+
386424
body.extend_from_slice(
387425
format!(
388426
"Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\n",
389-
name, filename
427+
safe_name, safe_filename
390428
)
391429
.as_bytes(),
392430
);
@@ -395,8 +433,11 @@ async fn execute_curl_request(
395433
} else {
396434
// Text field: key=value
397435
body.extend_from_slice(
398-
format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n", name)
399-
.as_bytes(),
436+
format!(
437+
"Content-Disposition: form-data; name=\"{}\"\r\n\r\n",
438+
safe_name
439+
)
440+
.as_bytes(),
400441
);
401442
body.extend_from_slice(value.as_bytes());
402443
}
@@ -1358,5 +1399,76 @@ mod tests {
13581399
assert_eq!(filtered.len(), 1);
13591400
assert_eq!(filtered[0].0, "Content-Type");
13601401
}
1402+
1403+
#[test]
1404+
fn test_sanitize_multipart_name_normal() {
1405+
let result = sanitize_multipart_name("field1", "field name").unwrap();
1406+
assert_eq!(result, "field1");
1407+
}
1408+
1409+
#[test]
1410+
fn test_sanitize_multipart_name_escapes_quotes() {
1411+
let result = sanitize_multipart_name("fie\"ld", "field name").unwrap();
1412+
assert_eq!(result, "fie\\\"ld");
1413+
}
1414+
1415+
#[test]
1416+
fn test_sanitize_multipart_name_rejects_cr() {
1417+
let result = sanitize_multipart_name("field\r\nInjected: header", "field name");
1418+
assert!(result.is_err());
1419+
assert!(result.unwrap_err().contains("illegal newline"));
1420+
}
1421+
1422+
#[test]
1423+
fn test_sanitize_multipart_name_rejects_lf() {
1424+
let result = sanitize_multipart_name("field\nInjected: header", "field name");
1425+
assert!(result.is_err());
1426+
assert!(result.unwrap_err().contains("illegal newline"));
1427+
}
1428+
1429+
#[test]
1430+
fn test_sanitize_multipart_name_rejects_bare_cr() {
1431+
let result = sanitize_multipart_name("field\rname", "filename");
1432+
assert!(result.is_err());
1433+
}
1434+
1435+
#[tokio::test]
1436+
async fn test_curl_multipart_field_name_with_quotes() {
1437+
// Field name with quotes should be escaped, not cause injection
1438+
let result = run_curl_with_stdin_and_fs(
1439+
&["-F", "fie\"ld=value", "https://example.com"],
1440+
None,
1441+
&[],
1442+
)
1443+
.await;
1444+
// Should reach network error (field name accepted after escaping)
1445+
assert!(result.stderr.contains("network access not configured"));
1446+
}
1447+
1448+
#[tokio::test]
1449+
async fn test_curl_multipart_field_name_with_newline_rejected() {
1450+
// Field name with newline must be rejected
1451+
let result = run_curl_with_stdin_and_fs(
1452+
&["-F", "field\r\nInjected: evil=value", "https://example.com"],
1453+
None,
1454+
&[],
1455+
)
1456+
.await;
1457+
assert_ne!(result.exit_code, 0);
1458+
assert!(result.stderr.contains("illegal newline"));
1459+
}
1460+
1461+
#[tokio::test]
1462+
async fn test_curl_multipart_normal_field_works() {
1463+
// Normal field names should work fine
1464+
let result = run_curl_with_stdin_and_fs(
1465+
&["-F", "username=alice", "https://example.com"],
1466+
None,
1467+
&[],
1468+
)
1469+
.await;
1470+
// Should reach network error (multipart built successfully)
1471+
assert!(result.stderr.contains("network access not configured"));
1472+
}
13611473
}
13621474
}

0 commit comments

Comments
 (0)