curb 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/ext/curb_easy.c CHANGED
@@ -650,6 +650,93 @@ static int curb_host_allowlist_prereq(void *clientp,
650
650
  }
651
651
  #endif
652
652
 
653
+ #if defined(CURB_HAVE_OPENSOCKET_NETWORK_POLICY) && defined(HAVE_CURLOPT_DOH_URL)
654
+ /* libcurl's DoH probe transfers do not inherit the socket policy callback,
655
+ * so under :public the DoH endpoint must be vetted before the transfer.
656
+ * Only public IP literals are accepted: a hostname would be resolved outside
657
+ * the policy, leaving a DNS-rebinding window. The host is taken from
658
+ * libcurl's own URL parser so it matches what libcurl will connect to. */
659
+ static void curb_check_public_doh_url(ruby_curl_easy *rbce, const char *doh_url) {
660
+ CURLU *parsed_url;
661
+ char *host = NULL;
662
+ char literal[INET6_ADDRSTRLEN + 3];
663
+ char *address = literal;
664
+ size_t len;
665
+ unsigned char ip[16];
666
+ int parsed = 0;
667
+
668
+ parsed_url = curl_url();
669
+ if (!parsed_url) {
670
+ rb_raise(rb_eNoMemError, "Failed to allocate URL parser for DoH URL check");
671
+ }
672
+ if (curl_url_set(parsed_url, CURLUPART_URL, doh_url, 0) == CURLUE_OK &&
673
+ curl_url_get(parsed_url, CURLUPART_HOST, &host, 0) == CURLUE_OK && host) {
674
+ len = strlen(host);
675
+ if (len < sizeof(literal)) {
676
+ memcpy(literal, host, len + 1);
677
+ parsed = 1;
678
+ }
679
+ }
680
+ if (host) curl_free(host);
681
+ curl_url_cleanup(parsed_url);
682
+
683
+ if (!parsed) {
684
+ rb_raise(eCurlErrUnsafeDestination,
685
+ "DoH URL must use a public IP address under public network policy");
686
+ }
687
+
688
+ len = strlen(address);
689
+ if (len >= 2 && address[0] == '[' && address[len - 1] == ']') {
690
+ address[len - 1] = '\0';
691
+ address++;
692
+ }
693
+
694
+ if (inet_pton(AF_INET, address, ip) == 1) {
695
+ if (curb_ipv4_is_unsafe_destination(ip) || !curb_cidr_rules_match(rbce, CURB_CIDR_FAMILY_IPV4, ip)) {
696
+ rb_raise(eCurlErrUnsafeDestination,
697
+ "DoH URL address %s is not allowed by public network policy", address);
698
+ }
699
+ } else if (inet_pton(AF_INET6, address, ip) == 1) {
700
+ if (curb_ipv6_is_unsafe_destination(ip) || !curb_cidr_rules_match(rbce, CURB_CIDR_FAMILY_IPV6, ip)) {
701
+ rb_raise(eCurlErrUnsafeDestination,
702
+ "DoH URL address %s is not allowed by public network policy", address);
703
+ }
704
+ } else {
705
+ rb_raise(eCurlErrUnsafeDestination,
706
+ "DoH URL must use a public IP address under public network policy (got host %s)", address);
707
+ }
708
+ }
709
+ #endif
710
+
711
+ #ifdef CURB_HAVE_OPENSOCKET_NETWORK_POLICY
712
+ /* file:// opens no socket, so the public policy's socket check never sees
713
+ * it. Without a protocol allowlist, reject it up front; libcurl's default
714
+ * redirect protocols already exclude file. The scheme comes from libcurl's
715
+ * parser when available. */
716
+ static void curb_check_public_url_scheme(const char *url) {
717
+ int is_file = 0;
718
+ #if LIBCURL_VERSION_NUM >= 0x073e00
719
+ CURLU *parsed_url = curl_url();
720
+ char *scheme = NULL;
721
+ if (!parsed_url) {
722
+ rb_raise(rb_eNoMemError, "Failed to allocate URL parser for scheme check");
723
+ }
724
+ if (curl_url_set(parsed_url, CURLUPART_URL, url, CURLU_GUESS_SCHEME | CURLU_NON_SUPPORT_SCHEME) == CURLUE_OK &&
725
+ curl_url_get(parsed_url, CURLUPART_SCHEME, &scheme, 0) == CURLUE_OK && scheme) {
726
+ is_file = STRCASECMP(scheme, "file") == 0;
727
+ }
728
+ if (scheme) curl_free(scheme);
729
+ curl_url_cleanup(parsed_url);
730
+ #else
731
+ while (*url == ' ' || *url == '\t') url++;
732
+ is_file = STRNCASECMP(url, "file:", 5) == 0;
733
+ #endif
734
+ if (is_file) {
735
+ rb_raise(eCurlErrUnsafeDestination, "file:// URLs are not allowed by public network policy");
736
+ }
737
+ }
738
+ #endif
739
+
653
740
  #ifdef CURB_HAVE_OPENSOCKET_NETWORK_POLICY
654
741
  static curl_socket_t curb_public_network_opensocket(void *clientp, curlsocktype purpose, struct curl_sockaddr *address) {
655
742
  ruby_curl_easy *rbce = (ruby_curl_easy *)clientp;
@@ -704,6 +791,14 @@ static curl_socket_t curb_public_network_opensocket(void *clientp, curlsocktype
704
791
  }
705
792
  #endif
706
793
 
794
+ /* Anything other than an IP socket (e.g. a Unix domain socket configured
795
+ * after the policy was applied) has no address to vet. Refuse it unless
796
+ * Unix sockets were explicitly allowed. */
797
+ if (!checked_destination_address && rbce && !rbce->allow_unix_socket) {
798
+ curb_store_destination_error(rbce, "(non-IP socket)", "is not allowed");
799
+ return CURL_SOCKET_BAD;
800
+ }
801
+
707
802
  if (!checked_destination_address && rbce && rbce->network_allowed_cidr_rule_count > 0) {
708
803
  curb_store_destination_error(rbce, "unknown", "outside allowed CIDR ranges");
709
804
  return CURL_SOCKET_BAD;
@@ -879,6 +974,13 @@ static int ruby_curl_easy_body_limit_exceeded(ruby_curl_easy *rbce, size_t total
879
974
  return 0;
880
975
  }
881
976
 
977
+ /* Initial capacity for the default body/header buffers. rb_str_buf_cat grows
978
+ * them geometrically, so starting small costs a few reallocations at most,
979
+ * while a large fixed reservation stays attached to every returned body_str
980
+ * (a 2 KB response used to keep 32 KB alive). */
981
+ #define CURB_DEFAULT_BODY_BUFFER_CAPA 0
982
+ #define CURB_DEFAULT_HEADER_BUFFER_CAPA 0
983
+
882
984
  /* Default body handler appends to easy.body_data buffer */
883
985
  static size_t default_body_handler(char *stream,
884
986
  size_t size,
@@ -893,7 +995,7 @@ static size_t default_body_handler(char *stream,
893
995
  }
894
996
 
895
997
  if (NIL_P(out)) {
896
- out = rb_easy_set("body_data", rb_str_buf_new(32768));
998
+ out = rb_easy_set("body_data", rb_str_buf_new(CURB_DEFAULT_BODY_BUFFER_CAPA));
897
999
  }
898
1000
  rb_str_buf_cat(out, stream, total);
899
1001
  return total;
@@ -908,7 +1010,7 @@ static size_t default_header_handler(char *stream,
908
1010
  size_t total = size * nmemb;
909
1011
  VALUE out = rb_easy_get("header_data");
910
1012
  if (NIL_P(out)) {
911
- out = rb_easy_set("header_data", rb_str_buf_new(16384));
1013
+ out = rb_easy_set("header_data", rb_str_buf_new(CURB_DEFAULT_HEADER_BUFFER_CAPA));
912
1014
  }
913
1015
  rb_str_buf_cat(out, stream, total);
914
1016
  return total;
@@ -1316,6 +1418,36 @@ static void ruby_curl_easy_detach(VALUE self, ruby_curl_easy *rbce) {
1316
1418
  rbce->multi = Qnil;
1317
1419
  }
1318
1420
 
1421
+ /* Release the curb-owned CURLOPT_STDERR stream. Callers must first stop
1422
+ * libcurl from using it (cleanup, reset, or setopt of a replacement). */
1423
+ static void ruby_curl_easy_close_stderr_file(ruby_curl_easy *rbce) {
1424
+ FILE *fp = rbce->stderr_file;
1425
+ if (fp) {
1426
+ rbce->stderr_file = NULL;
1427
+ fclose(fp);
1428
+ }
1429
+ }
1430
+
1431
+ /* Open a stream curb owns on a duplicate of fd, so its lifetime is
1432
+ * independent of the Ruby IO it came from. */
1433
+ static FILE *ruby_curl_easy_dup_stderr_file(int fd) {
1434
+ FILE *fp;
1435
+ int dup_fd = rb_cloexec_dup(fd);
1436
+ if (dup_fd < 0) {
1437
+ rb_sys_fail("dup(CURLOPT_STDERR)");
1438
+ }
1439
+ rb_update_max_fd(dup_fd);
1440
+ /* "w" on an existing descriptor neither truncates nor changes O_APPEND. */
1441
+ fp = fdopen(dup_fd, "w");
1442
+ if (!fp) {
1443
+ int saved_errno = errno;
1444
+ close(dup_fd);
1445
+ errno = saved_errno;
1446
+ rb_sys_fail("fdopen(CURLOPT_STDERR)");
1447
+ }
1448
+ return fp;
1449
+ }
1450
+
1319
1451
  static void ruby_curl_easy_free(ruby_curl_easy *rbce) {
1320
1452
  if (!rbce) {
1321
1453
  return;
@@ -1362,6 +1494,8 @@ static void ruby_curl_easy_free(ruby_curl_easy *rbce) {
1362
1494
  curl_easy_cleanup(rbce->curl);
1363
1495
  rbce->curl = NULL;
1364
1496
  }
1497
+ /* After cleanup: libcurl may log while closing connections. */
1498
+ ruby_curl_easy_close_stderr_file(rbce);
1365
1499
 
1366
1500
  rbce->self = Qnil;
1367
1501
  }
@@ -1431,6 +1565,15 @@ ruby_curl_easy *ruby_curl_easy_get_handle(VALUE self) {
1431
1565
  return rbce;
1432
1566
  }
1433
1567
 
1568
+ /* Entry points that hand libcurl new request state (bodies, verbs, setup)
1569
+ * must not run while libcurl is still using the old state: replacing it
1570
+ * mid-transfer frees buffers libcurl is reading (use-after-free). */
1571
+ static ruby_curl_easy *ruby_curl_easy_get_idle_handle(VALUE self) {
1572
+ ruby_curl_easy *rbce = ruby_curl_easy_get_handle(self);
1573
+ ruby_curl_easy_check_idle(rbce);
1574
+ return rbce;
1575
+ }
1576
+
1434
1577
  /* Keep libcurl's typed getinfo arguments while checking every result. */
1435
1578
  #define CURB_GETINFO(handle, info, output) do { \
1436
1579
  CURLcode info_result = curl_easy_getinfo(handle, info, output); \
@@ -1457,6 +1600,8 @@ static void ruby_curl_easy_zero(ruby_curl_easy *rbce) {
1457
1600
  rbce->network_allowed_hosts = NULL;
1458
1601
  rbce->network_allowed_cidr_rule_count = 0;
1459
1602
  rbce->network_allowed_host_count = 0;
1603
+ /* Callers that may hold a stream close it before zeroing. */
1604
+ rbce->stderr_file = NULL;
1460
1605
 
1461
1606
  /* various-typed opts */
1462
1607
  rbce->local_port = 0;
@@ -1466,6 +1611,8 @@ static void ruby_curl_easy_zero(ruby_curl_easy *rbce) {
1466
1611
  rbce->http_auth_types = 0;
1467
1612
  rbce->proxy_auth_types = 0;
1468
1613
  rbce->max_redirs = -1;
1614
+ rbce->max_redirs_set = 0;
1615
+ rbce->protocols_set = 0;
1469
1616
  rbce->timeout = 0;
1470
1617
  rbce->timeout_ms = 0;
1471
1618
  rbce->connect_timeout = 0;
@@ -1632,19 +1779,24 @@ static VALUE duplicate_upload(VALUE upload) {
1632
1779
  static VALUE ruby_curl_easy_clone(VALUE self) {
1633
1780
  ruby_curl_easy *rbce, *newrbce;
1634
1781
 
1782
+ FILE *clone_stderr_file = NULL;
1783
+
1635
1784
  rbce = ruby_curl_easy_get_handle(self);
1636
1785
 
1637
- newrbce = ALLOC(ruby_curl_easy);
1638
- if (!newrbce) {
1639
- rb_raise(rb_eNoMemError, "Failed to allocate memory for Curl::Easy clone");
1786
+ /* Duplicate first: this can raise, and nothing needs unwinding yet. */
1787
+ if (rbce->stderr_file) {
1788
+ clone_stderr_file = ruby_curl_easy_dup_stderr_file(fileno(rbce->stderr_file));
1640
1789
  }
1790
+
1791
+ newrbce = ALLOC(ruby_curl_easy);
1641
1792
  /* shallow copy */
1642
1793
  memcpy(newrbce, rbce, sizeof(ruby_curl_easy));
1643
1794
 
1644
1795
  /* now deep copy */
1645
1796
  newrbce->curl = curl_easy_duphandle(rbce->curl);
1646
1797
  if (!newrbce->curl) {
1647
- free(newrbce);
1798
+ xfree(newrbce);
1799
+ if (clone_stderr_file) fclose(clone_stderr_file);
1648
1800
  rb_raise(rb_eNoMemError, "Failed to duplicate Curl::Easy handle");
1649
1801
  }
1650
1802
  /* duphandle borrows HTTPPOST, whose form is freed when the original
@@ -1654,7 +1806,8 @@ static VALUE ruby_curl_easy_clone(VALUE self) {
1654
1806
  CURLcode ecode = curl_easy_setopt(newrbce->curl, CURLOPT_HTTPPOST, NULL);
1655
1807
  if (ecode != CURLE_OK) {
1656
1808
  curl_easy_cleanup(newrbce->curl);
1657
- free(newrbce);
1809
+ xfree(newrbce);
1810
+ if (clone_stderr_file) fclose(clone_stderr_file);
1658
1811
  raise_curl_easy_error_exception(ecode);
1659
1812
  }
1660
1813
  newrbce->multipart_form_active = 0;
@@ -1669,6 +1822,13 @@ static VALUE ruby_curl_easy_clone(VALUE self) {
1669
1822
  newrbce->network_allowed_hosts = NULL;
1670
1823
  newrbce->network_allowed_host_count = 0;
1671
1824
 
1825
+ /* duphandle copied the original's CURLOPT_STDERR pointer; give the clone
1826
+ * its own stream so neither handle can outlive the other's. */
1827
+ newrbce->stderr_file = clone_stderr_file;
1828
+ if (clone_stderr_file) {
1829
+ curl_easy_setopt(newrbce->curl, CURLOPT_STDERR, clone_stderr_file);
1830
+ }
1831
+
1672
1832
  /* A cloned easy should not retain ownership reference to the original multi. */
1673
1833
  newrbce->multi = Qnil;
1674
1834
  newrbce->callback_error = Qnil;
@@ -1789,6 +1949,8 @@ static VALUE ruby_curl_easy_reset(VALUE self) {
1789
1949
 
1790
1950
  ruby_curl_easy_cleanup(self, rbce);
1791
1951
  curl_easy_reset(rbce->curl);
1952
+ /* reset restored libcurl's default stderr, so ours is unused now. */
1953
+ ruby_curl_easy_close_stderr_file(rbce);
1792
1954
  ruby_curl_easy_zero(rbce);
1793
1955
  rbce->self = self;
1794
1956
 
@@ -2151,7 +2313,7 @@ static VALUE ruby_curl_easy_post_body_set_with_mode_body(VALUE argp) {
2151
2313
  char *data;
2152
2314
  long len;
2153
2315
 
2154
- rbce = ruby_curl_easy_get_handle(self);
2316
+ rbce = ruby_curl_easy_get_idle_handle(self);
2155
2317
 
2156
2318
  curl = rbce->curl;
2157
2319
 
@@ -2247,7 +2409,7 @@ static VALUE ruby_curl_easy_put_data_set(VALUE self, VALUE data) {
2247
2409
  VALUE headers;
2248
2410
  VALUE infile_size = Qnil;
2249
2411
 
2250
- rbce = ruby_curl_easy_get_handle(self);
2412
+ rbce = ruby_curl_easy_get_idle_handle(self);
2251
2413
 
2252
2414
  /*
2253
2415
  * Validate and prepare Ruby-visible state before mutating the CURL handle.
@@ -2744,14 +2906,27 @@ static VALUE ruby_curl_easy_proxy_auth_types_get(VALUE self) {
2744
2906
  * easy.max_redirects = fixnum or nil => fixnum or nil
2745
2907
  *
2746
2908
  * Set the maximum number of redirections to follow in the following +perform+
2747
- * calls. Set to nil or -1 allow an infinite number (the default). Setting this
2748
- * option only makes sense if +follow_location+ is also set true.
2909
+ * calls. Set to -1 to allow an infinite number. When nil (the default), at
2910
+ * most 30 redirects are followed, matching libcurl 8.3+ on every libcurl
2911
+ * version. Setting this option only makes sense if +follow_location+ is also
2912
+ * set true.
2749
2913
  *
2750
2914
  * With libcurl >= 7.15.1, setting this to 0 will cause libcurl to refuse any
2751
2915
  * redirect.
2752
2916
  */
2753
2917
  static VALUE ruby_curl_easy_max_redirects_set(VALUE self, VALUE max_redirs) {
2754
- CURB_IMMED_SETTER(ruby_curl_easy, max_redirs, -1);
2918
+ ruby_curl_easy *rbce;
2919
+
2920
+ TypedData_Get_Struct(self, ruby_curl_easy, &ruby_curl_easy_data_type, rbce);
2921
+ ruby_curl_easy_check_access(rbce);
2922
+ if (NIL_P(max_redirs)) {
2923
+ rbce->max_redirs = -1;
2924
+ rbce->max_redirs_set = 0;
2925
+ } else {
2926
+ rbce->max_redirs = NUM2LONG(max_redirs);
2927
+ rbce->max_redirs_set = 1;
2928
+ }
2929
+ return max_redirs;
2755
2930
  }
2756
2931
 
2757
2932
  /*
@@ -3318,7 +3493,7 @@ static VALUE ruby_curl_easy_use_netrc_q(VALUE self) {
3318
3493
  */
3319
3494
  static VALUE ruby_curl_easy_autoreferer_set(VALUE self, VALUE autoreferer) {
3320
3495
  ruby_curl_easy *rbce;
3321
- rbce = ruby_curl_easy_get_handle(self);
3496
+ rbce = ruby_curl_easy_get_idle_handle(self);
3322
3497
 
3323
3498
  if (Qtrue == autoreferer) {
3324
3499
  curl_easy_setopt(rbce->curl, CURLOPT_AUTOREFERER, 1);
@@ -3819,6 +3994,37 @@ static VALUE ruby_curl_easy_on_debug_set(int argc, VALUE *argv, VALUE self) {
3819
3994
 
3820
3995
  /* =================== PERFORM =====================*/
3821
3996
 
3997
+ /* CR or LF in a header line would let the value inject extra headers or
3998
+ * split the request; libcurl sends header strings verbatim. NUL would
3999
+ * silently truncate the line. */
4000
+ static void curb_check_header_line_len(const char *ptr, long len, const char *what) {
4001
+ if (memchr(ptr, '\r', (size_t)len) || memchr(ptr, '\n', (size_t)len) || memchr(ptr, '\0', (size_t)len)) {
4002
+ rb_raise(rb_eArgError, "%s must not contain CR, LF, or NUL characters", what);
4003
+ }
4004
+ }
4005
+
4006
+ static void curb_check_header_line(VALUE str, const char *what) {
4007
+ curb_check_header_line_len(RSTRING_PTR(str), RSTRING_LEN(str), what);
4008
+ }
4009
+
4010
+ /* A String for the option stored under key, converting like rb_easy_get_str. */
4011
+ static VALUE curb_easy_opt_string(ruby_curl_easy *rbce, const char *key) {
4012
+ VALUE value = rb_hash_aref(rbce->opts, rb_easy_hkey(key));
4013
+ return RB_TYPE_P(value, T_STRING) ? value : rb_str_to_str(value);
4014
+ }
4015
+
4016
+ static int curb_slist_has_header(const struct curl_slist *list, const char *name) {
4017
+ size_t name_len = strlen(name);
4018
+ for (; list; list = list->next) {
4019
+ const char *line = list->data;
4020
+ if (line && STRNCASECMP(line, name, name_len) == 0 &&
4021
+ (line[name_len] == ':' || line[name_len] == ';')) {
4022
+ return 1;
4023
+ }
4024
+ }
4025
+ return 0;
4026
+ }
4027
+
3822
4028
  /***********************************************
3823
4029
  * This is an rb_iterate callback used to set up http headers.
3824
4030
  */
@@ -3850,6 +4056,8 @@ static VALUE cb_each_http_header(VALUE header, VALUE wrap, int _c, const VALUE *
3850
4056
 
3851
4057
  //rb_p(header_str);
3852
4058
 
4059
+ StringValue(header_str);
4060
+ curb_check_header_line(header_str, "HTTP header");
3853
4061
  struct curl_slist *new_list = curl_slist_append(*list, StringValuePtr(header_str));
3854
4062
  if (!new_list) {
3855
4063
  rb_raise(rb_eNoMemError, "Failed to append to header list");
@@ -3886,6 +4094,8 @@ static VALUE cb_each_http_proxy_header(VALUE proxy_header, VALUE wrap, int _c, c
3886
4094
 
3887
4095
  //rb_p(header_str);
3888
4096
 
4097
+ StringValue(proxy_header_str);
4098
+ curb_check_header_line(proxy_header_str, "HTTP proxy header");
3889
4099
  struct curl_slist *new_list = curl_slist_append(*list, StringValuePtr(proxy_header_str));
3890
4100
  if (!new_list) {
3891
4101
  rb_raise(rb_eNoMemError, "Failed to append to proxy header list");
@@ -4031,6 +4241,17 @@ static VALUE ruby_curl_easy_setup_body(VALUE arg) {
4031
4241
  curl_easy_setopt(curl, CURLOPT_FORBID_REUSE, rbce->forbid_reuse_set ? rbce->forbid_reuse : 0L);
4032
4242
  #endif
4033
4243
  }
4244
+ /* With a protocol allowlist, libcurl enforces it (UnsupportedProtocolError)
4245
+ * and an explicit :file entry is a deliberate opt-in. */
4246
+ if (rbce->network_policy == CURB_NETWORK_POLICY_PUBLIC && !rbce->protocols_set) {
4247
+ curb_check_public_url_scheme(StringValueCStr(url));
4248
+ }
4249
+ #ifdef HAVE_CURLOPT_DOH_URL
4250
+ /* After the CIDR rules above are prepared. */
4251
+ if (rbce->network_policy == CURB_NETWORK_POLICY_PUBLIC && !rb_easy_nil("doh_url")) {
4252
+ curb_check_public_doh_url(rbce, rb_easy_get_str("doh_url"));
4253
+ }
4254
+ #endif
4034
4255
  #endif
4035
4256
 
4036
4257
  // network stuff and auth
@@ -4097,7 +4318,7 @@ static VALUE ruby_curl_easy_setup_body(VALUE arg) {
4097
4318
  /* clear out the body_data if it was set */
4098
4319
  rb_easy_del("body_data");
4099
4320
  } else {
4100
- rb_easy_set("body_data", rb_str_buf_new(32768));
4321
+ rb_easy_set("body_data", rb_str_buf_new(CURB_DEFAULT_BODY_BUFFER_CAPA));
4101
4322
  curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, (curl_write_callback)&default_body_handler);
4102
4323
  curl_easy_setopt(curl, CURLOPT_WRITEDATA, rbce);
4103
4324
  }
@@ -4108,7 +4329,7 @@ static VALUE ruby_curl_easy_setup_body(VALUE arg) {
4108
4329
  /* clear out the header_data if it was set */
4109
4330
  rb_easy_del("header_data");
4110
4331
  } else {
4111
- rb_easy_set("header_data", rb_str_buf_new(16384));
4332
+ rb_easy_set("header_data", rb_str_buf_new(CURB_DEFAULT_HEADER_BUFFER_CAPA));
4112
4333
  curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, (curl_write_callback)&default_header_handler);
4113
4334
  curl_easy_setopt(curl, CURLOPT_HEADERDATA, rbce);
4114
4335
  }
@@ -4152,14 +4373,20 @@ static VALUE ruby_curl_easy_setup_body(VALUE arg) {
4152
4373
 
4153
4374
  curl_easy_setopt(curl, CURLOPT_HEADER, rbce->header_in_body);
4154
4375
  curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, rbce->follow_location);
4376
+ /* Unset: cap at 30, libcurl's default since 8.3.0. Passing curb's old
4377
+ * default of -1 made redirects unlimited on every libcurl version. */
4378
+ if (!rbce->max_redirs_set) {
4379
+ curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 30L);
4380
+ } else {
4155
4381
  #if LIBCURL_VERSION_NUM == 0x081000
4156
- /* Work around 8.16.0 regression that clamps -1 (infinite) to zero */
4157
- if (rbce->max_redirs >= 0) {
4158
- curl_easy_setopt(curl, CURLOPT_MAXREDIRS, rbce->max_redirs);
4159
- }
4382
+ /* Work around 8.16.0 regression that clamps -1 (infinite) to zero */
4383
+ if (rbce->max_redirs >= 0) {
4384
+ curl_easy_setopt(curl, CURLOPT_MAXREDIRS, rbce->max_redirs);
4385
+ }
4160
4386
  #else
4161
- curl_easy_setopt(curl, CURLOPT_MAXREDIRS, rbce->max_redirs);
4387
+ curl_easy_setopt(curl, CURLOPT_MAXREDIRS, rbce->max_redirs);
4162
4388
  #endif
4389
+ }
4163
4390
 
4164
4391
  curl_easy_setopt(curl, CURLOPT_HTTPPROXYTUNNEL,
4165
4392
  public_policy_disables_proxy ? 0L : rbce->proxy_tunnel);
@@ -4275,9 +4502,7 @@ static VALUE ruby_curl_easy_setup_body(VALUE arg) {
4275
4502
  curl_easy_setopt(curl, CURLOPT_COOKIEFILE, "");
4276
4503
  }
4277
4504
 
4278
- if (!rb_easy_nil("cookies")) {
4279
- curl_easy_setopt(curl, CURLOPT_COOKIE, rb_easy_get_str("cookies"));
4280
- }
4505
+ /* cookies= is applied after the HTTP headers below. */
4281
4506
 
4282
4507
  /* Set up HTTPS cert handling if necessary */
4283
4508
  if (!rb_easy_nil("cert")) {
@@ -4322,7 +4547,9 @@ static VALUE ruby_curl_easy_setup_body(VALUE arg) {
4322
4547
 
4323
4548
  /* Set the user-agent string if specified */
4324
4549
  if (!rb_easy_nil("useragent")) {
4325
- curl_easy_setopt(curl, CURLOPT_USERAGENT, rb_easy_get_str("useragent"));
4550
+ VALUE useragent = curb_easy_opt_string(rbce, "useragent");
4551
+ curb_check_header_line(useragent, "useragent");
4552
+ curl_easy_setopt(curl, CURLOPT_USERAGENT, RSTRING_PTR(useragent));
4326
4553
  }
4327
4554
 
4328
4555
  /* Setup can be rerun for safety-policy changes while a handle is attached.
@@ -4339,6 +4566,7 @@ static VALUE ruby_curl_easy_setup_body(VALUE arg) {
4339
4566
  rb_block_call(rb_easy_get("headers"), rb_intern("each"), 0, NULL, cb_each_http_header, wrap);
4340
4567
  } else {
4341
4568
  VALUE headers_str = rb_obj_as_string(rb_easy_get("headers"));
4569
+ curb_check_header_line(headers_str, "HTTP header");
4342
4570
  struct curl_slist *new_list = curl_slist_append(*hdrs, StringValuePtr(headers_str));
4343
4571
  if (!new_list) {
4344
4572
  rb_raise(rb_eNoMemError, "Failed to append to headers list");
@@ -4351,6 +4579,28 @@ static VALUE ruby_curl_easy_setup_body(VALUE arg) {
4351
4579
  }
4352
4580
  }
4353
4581
 
4582
+ /* cookies=: libcurl sends CURLOPT_COOKIE to every host a redirect reaches,
4583
+ * but drops a custom Cookie header on cross-host redirects (7.83+). So
4584
+ * send it as a header, unless the cookie engine is on: libcurl would then
4585
+ * emit a second Cookie line, so CURLOPT_COOKIE is kept to merge them. */
4586
+ curl_easy_setopt(curl, CURLOPT_COOKIE, NULL);
4587
+ if (!rb_easy_nil("cookies")) {
4588
+ VALUE cookies = curb_easy_opt_string(rbce, "cookies");
4589
+ curb_check_header_line(cookies, "cookies");
4590
+ if (rbce->enable_cookies || rbce->cookielist_engine_enabled) {
4591
+ curl_easy_setopt(curl, CURLOPT_COOKIE, RSTRING_PTR(cookies));
4592
+ } else if (RSTRING_LEN(cookies) > 0 && !curb_slist_has_header(*hdrs, "Cookie")) {
4593
+ /* An explicit Cookie header already wins over cookies=, as before. */
4594
+ VALUE cookie_header = rb_str_plus(rb_str_new_cstr("Cookie: "), cookies);
4595
+ struct curl_slist *new_list = curl_slist_append(*hdrs, StringValueCStr(cookie_header));
4596
+ if (!new_list) {
4597
+ rb_raise(rb_eNoMemError, "Failed to append to headers list");
4598
+ }
4599
+ *hdrs = new_list;
4600
+ curl_easy_setopt(curl, CURLOPT_HTTPHEADER, *hdrs);
4601
+ }
4602
+ }
4603
+
4354
4604
  #ifdef HAVE_CURLOPT_PROXYHEADER
4355
4605
  /* Setup HTTP proxy headers if necessary */
4356
4606
  curl_easy_setopt(curl, CURLOPT_PROXYHEADER, NULL); // XXX: maybe we shouldn't be clearing this?
@@ -4361,6 +4611,7 @@ static VALUE ruby_curl_easy_setup_body(VALUE arg) {
4361
4611
  rb_block_call(rb_easy_get("proxy_headers"), rb_intern("each"), 0, NULL, cb_each_http_proxy_header, wrap);
4362
4612
  } else {
4363
4613
  VALUE proxy_headers_str = rb_obj_as_string(rb_easy_get("proxy_headers"));
4614
+ curb_check_header_line(proxy_headers_str, "HTTP proxy header");
4364
4615
  struct curl_slist *new_list = curl_slist_append(*phdrs, StringValuePtr(proxy_headers_str));
4365
4616
  if (!new_list) {
4366
4617
  rb_raise(rb_eNoMemError, "Failed to append to proxy headers list");
@@ -4440,7 +4691,7 @@ VALUE ruby_curl_easy_setup(ruby_curl_easy *rbce) {
4440
4691
  static VALUE ruby_curl_easy_setup_self(VALUE self) {
4441
4692
  ruby_curl_easy *rbce;
4442
4693
 
4443
- rbce = ruby_curl_easy_get_handle(self);
4694
+ rbce = ruby_curl_easy_get_idle_handle(self);
4444
4695
  ruby_curl_easy_setup(rbce);
4445
4696
 
4446
4697
  return self;
@@ -4546,11 +4797,13 @@ static VALUE ruby_curl_easy_perform_verb_str(VALUE self, const char *verb) {
4546
4797
  CURL *curl;
4547
4798
  VALUE retval;
4548
4799
 
4549
- rbce = ruby_curl_easy_get_handle(self);
4800
+ rbce = ruby_curl_easy_get_idle_handle(self);
4550
4801
  curl = rbce->curl;
4551
4802
 
4552
4803
  memset(rbce->err_buf, 0, CURL_ERROR_SIZE);
4553
4804
 
4805
+ if (verb) curb_check_header_line_len(verb, (long)strlen(verb), "HTTP verb");
4806
+
4554
4807
  /* Use method override and adjust related options for special verbs. */
4555
4808
  curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, verb);
4556
4809
 
@@ -4734,7 +4987,7 @@ static VALUE ruby_curl_easy_perform_post(int argc, VALUE *argv, VALUE self) {
4734
4987
 
4735
4988
  rb_scan_args(argc, argv, "*", &args_ary);
4736
4989
 
4737
- rbce = ruby_curl_easy_get_handle(self);
4990
+ rbce = ruby_curl_easy_get_idle_handle(self);
4738
4991
  curl = rbce->curl;
4739
4992
 
4740
4993
  memset(rbce->err_buf, 0, CURL_ERROR_SIZE);
@@ -4793,7 +5046,7 @@ static VALUE ruby_curl_easy_perform_patch(int argc, VALUE *argv, VALUE self) {
4793
5046
  VALUE args_ary;
4794
5047
 
4795
5048
  rb_scan_args(argc, argv, "*", &args_ary);
4796
- rbce = ruby_curl_easy_get_handle(self);
5049
+ rbce = ruby_curl_easy_get_idle_handle(self);
4797
5050
  curl = rbce->curl;
4798
5051
 
4799
5052
  /* Clear the error buffer */
@@ -4842,7 +5095,7 @@ static VALUE ruby_curl_easy_perform_put(int argc, VALUE *argv, VALUE self) {
4842
5095
  VALUE args_ary;
4843
5096
 
4844
5097
  rb_scan_args(argc, argv, "*", &args_ary);
4845
- rbce = ruby_curl_easy_get_handle(self);
5098
+ rbce = ruby_curl_easy_get_idle_handle(self);
4846
5099
  curl = rbce->curl;
4847
5100
 
4848
5101
  memset(rbce->err_buf, 0, CURL_ERROR_SIZE);
@@ -5747,6 +6000,7 @@ static VALUE ruby_curl_easy_set_opt(VALUE self, VALUE opt, VALUE val) {
5747
6000
  CURB_OBJECT_HSETTER(ruby_curl_easy, url);
5748
6001
  } break;
5749
6002
  case CURLOPT_CUSTOMREQUEST:
6003
+ if (!NIL_P(val)) curb_check_header_line(StringValue(val), "custom request method");
5750
6004
  curl_easy_setopt(rbce->curl, CURLOPT_CUSTOMREQUEST, NIL_P(val) ? NULL : StringValueCStr(val));
5751
6005
  break;
5752
6006
  case CURLOPT_HTTP_VERSION: {
@@ -5822,6 +6076,7 @@ static VALUE ruby_curl_easy_set_opt(VALUE self, VALUE opt, VALUE val) {
5822
6076
  #ifdef HAVE_CURLOPT_REQUEST_TARGET
5823
6077
  case CURLOPT_REQUEST_TARGET: {
5824
6078
  /* Forward request-target directly to libcurl as a string. */
6079
+ if (!NIL_P(val)) curb_check_header_line(StringValue(val), "request target");
5825
6080
  curl_easy_setopt(rbce->curl, CURLOPT_REQUEST_TARGET, NIL_P(val) ? NULL : StringValueCStr(val));
5826
6081
  } break;
5827
6082
  #endif
@@ -5978,23 +6233,36 @@ static VALUE ruby_curl_easy_set_opt(VALUE self, VALUE opt, VALUE val) {
5978
6233
  curl_easy_setopt(rbce->curl, CURLOPT_HAPROXYPROTOCOL, NUM2LONG(val));
5979
6234
  break;
5980
6235
  #endif
5981
- case CURLOPT_STDERR:
6236
+ case CURLOPT_STDERR: {
5982
6237
  // libcurl requires raw FILE pointer and this should be IO object in Ruby.
5983
6238
  // Tempfile or StringIO won't work.
6239
+ FILE *stderr_file;
5984
6240
  Check_Type(val, T_FILE);
5985
6241
  GetOpenFile(val, open_f_ptr);
5986
- curl_easy_setopt(rbce->curl, CURLOPT_STDERR, rb_io_stdio_file(open_f_ptr));
5987
- /* Retain a Ruby reference to the IO to prevent GC/finalization
5988
- * while libcurl still holds and writes to the underlying FILE*. */
6242
+ rb_io_check_writable(open_f_ptr);
6243
+ /* Hand libcurl a stream curb owns on a duplicate descriptor. Passing
6244
+ * the IO's own FILE* left libcurl writing through freed memory once the
6245
+ * caller closed the IO, including after Multi#add. */
6246
+ #ifdef HAVE_RB_IO_DESCRIPTOR
6247
+ stderr_file = ruby_curl_easy_dup_stderr_file(rb_io_descriptor(val));
6248
+ #else
6249
+ stderr_file = ruby_curl_easy_dup_stderr_file(open_f_ptr->fd);
6250
+ #endif
6251
+ curl_easy_setopt(rbce->curl, CURLOPT_STDERR, stderr_file);
6252
+ ruby_curl_easy_close_stderr_file(rbce);
6253
+ rbce->stderr_file = stderr_file;
6254
+ /* Kept so completion can flush the caller's own buffered output. */
5989
6255
  rb_easy_set("stderr_io", val);
5990
- break;
6256
+ } break;
5991
6257
  case CURLOPT_PROTOCOLS:
5992
6258
  case CURLOPT_REDIR_PROTOCOLS:
5993
6259
  curl_easy_setopt(rbce->curl, option, NUM2LONG(val));
6260
+ if (option == CURLOPT_PROTOCOLS) rbce->protocols_set = 1;
5994
6261
  break;
5995
6262
  #ifdef HAVE_CURLOPT_PROTOCOLS_STR
5996
6263
  case CURLOPT_PROTOCOLS_STR:
5997
6264
  curl_easy_setopt(rbce->curl, CURLOPT_PROTOCOLS_STR, NIL_P(val) ? NULL : StringValueCStr(val));
6265
+ rbce->protocols_set = !NIL_P(val);
5998
6266
  break;
5999
6267
  #endif
6000
6268
  #ifdef HAVE_CURLOPT_REDIR_PROTOCOLS_STR
data/ext/curb_easy.h CHANGED
@@ -65,6 +65,8 @@ typedef struct {
65
65
  long http_auth_types;
66
66
  long proxy_auth_types;
67
67
  long max_redirs;
68
+ char max_redirs_set; /* user configured max_redirs (nil = libcurl-style default) */
69
+ char protocols_set; /* CURLOPT_PROTOCOLS(_STR) allowlist configured */
68
70
  unsigned long timeout;
69
71
  unsigned long timeout_ms;
70
72
  unsigned long connect_timeout;
@@ -113,6 +115,9 @@ typedef struct {
113
115
  struct curl_slist *curl_connect_to;
114
116
  curb_cidr_rule *network_allowed_cidr_rules;
115
117
  char **network_allowed_hosts;
118
+ /* CURLOPT_STDERR target. curb owns this FILE* (on a duplicated descriptor)
119
+ * so closing or collecting the caller's IO cannot free it under libcurl. */
120
+ FILE *stderr_file;
116
121
 
117
122
  /* attachment_sequence of the multi at the time this easy was last added. */
118
123
  unsigned long multi_attachment_generation;