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 \n Injected: 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\n Injected: 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\r name" , "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 \n Injected: 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