Compare commits

...

2 Commits
c10s ... c10

Author SHA1 Message Date
AlmaLinux RelEng Bot
ce1544e2d7 import UBI ruby4.0-4.0.3-35.el10_2 2026-06-30 13:26:47 -04:00
AlmaLinux RelEng Bot
d4586a2e5e import UBI ruby4.0-4.0.3-34.el10_2 2026-06-03 09:15:42 -04:00
11 changed files with 619 additions and 135 deletions

View File

@ -1 +0,0 @@
1

9
.gitignore vendored
View File

@ -1,6 +1,3 @@
/*/
/ruby-*.tar.xz
/*.rpm
/mysql2-*.gem
/pg-*.gem
!/plans/
mysql2-0.5.7.gem
pg-1.6.3.gem
ruby-4.0.3.tar.xz

View File

@ -1,6 +0,0 @@
--- !Policy
product_versions:
- rhel-10
decision_context: osci_compose_gate
rules:
- !PassingTestCaseRule {test_case_name: osci.brew-build.tier0.functional}

View File

@ -1,26 +0,0 @@
summary: Public (CentOS) beakerlib tests
adjust+:
- when: distro == centos-stream
because: Update all packages and reboot
discover+<:
- name: update-and-reboot
how: shell
tests:
- name: update
test: dnf update --refresh -y
- name: reboot
test: "[[ $TMT_REBOOT_COUNT == 0 ]] && tmt-reboot || echo Already rebooted"
environment+:
RUBY: "ruby4.0"
GEM: "gem4.0"
BUNDLE: "bundle4.0"
discover:
- name: centos
how: fmf
url: https://gitlab.com/redhat/centos-stream/tests/ruby
filter: 'component:ruby4.0'
execute:
how: tmt

View File

@ -1,21 +0,0 @@
badfuncs:
# Ruby implements the functionality for compatibility.
# Modern counterparts are also available and preferred.
allowed:
'/usr/lib*/ruby*/socket.so':
- gethostbyaddr
- gethostbyname
patches:
# These patches are applied manually with the `patch` binary.
# The contents are unpacked with Ruby itself in %build.
# %build does not have %patch macros available for use.
ignore_list:
- rubygem-pg-1.3.0-remove-rpath.patch
- rubygem-mysql2-0.5.7-Disable-RPATH-completely-in-extconf.rb.patch
rpmdeps:
ignore:
# The package will be pulled in transitively. Ruby takes care for the
# ABI compatibility in specific major.minor solib version.
requires: ^libruby.*\.so.*\(\)\(64bit\)$

View File

@ -14,9 +14,9 @@ index 8f9729ef28..a05c5e9657 100644
--- a/ext/json/parser/parser.c
+++ b/ext/json/parser/parser.c
@@ -400,14 +400,9 @@ static void emit_parse_warning(const char *message, JSON_ParserState *state)
#define PARSE_ERROR_FRAGMENT_LEN 32
-#ifdef RBIMPL_ATTR_NORETURN
-RBIMPL_ATTR_NORETURN()
-#endif
@ -26,7 +26,7 @@ index 8f9729ef28..a05c5e9657 100644
unsigned char buffer[PARSE_ERROR_FRAGMENT_LEN + 3];
- long line, column;
- cursor_position(state, &line, &column);
const char *ptr = "EOF";
if (state->cursor && state->cursor < state->end) {
@@ -442,11 +437,23 @@ static void raise_parse_error(const char *format, JSON_ParserState *state)
@ -35,7 +35,7 @@ index 8f9729ef28..a05c5e9657 100644
RB_GC_GUARD(msg);
+ return message;
+}
+static VALUE parse_error_new(VALUE message, long line, long column)
+{
VALUE exc = rb_exc_new_str(rb_path2class("JSON::ParserError"), message);
@ -52,12 +52,12 @@ index 8f9729ef28..a05c5e9657 100644
+ VALUE message = build_parse_error_message(format, state, line, column);
+ rb_exc_raise(parse_error_new(message, line, column));
}
#ifdef RBIMPL_ATTR_NORETURN
@@ -896,6 +903,11 @@ static void raise_duplicate_key_error(JSON_ParserState *state, VALUE duplicate_k
rb_inspect(duplicate_key)
);
+ long line, column;
+ cursor_position(state, &line, &column);
+ rb_str_concat(message, build_parse_error_message("", state, line, column)) ;
@ -73,7 +73,7 @@ index ec9391909d..61ea35d1f9 100644
@@ -411,6 +411,13 @@ def test_parse_duplicate_key
end
end
+ def test_parse_duplicate_key_escape
+ error = assert_raise(ParserError) do
+ JSON.parse('{"%s%s%s%s":1,"%s%s%s%s":2}', allow_duplicate_key: false)

View File

@ -1,61 +0,0 @@
# Keep matching patterns enough not to hide unintended errors and warnings.
# There is no way to implement this with `%{SOURCE0}` without `%{_sourcedir}`.
# The order in the .spec file could be possibly different.
addFilter(r'ruby\.(spec|src):20: E: use-of-RPM_SOURCE_DIR$')
# The used version is not obvious.
addFilter(r'ruby\.(spec|src):\d+: W: unversioned-explicit-provides bundled\(ccan-build_assert\)$')
addFilter(r'ruby\.(spec|src):\d+: W: unversioned-explicit-provides bundled\(ccan-check_type\)$')
addFilter(r'ruby\.(spec|src):\d+: W: unversioned-explicit-provides bundled\(ccan-container_of\)$')
addFilter(r'ruby\.(spec|src):\d+: W: unversioned-explicit-provides bundled\(ccan-list\)$')
# The template files do not have to have executable bits.
addFilter(r'^rubygem-bundler\.noarch: E: non-executable-script /usr/share/gems/gems/bundler-[\d\.]+/lib/bundler/templates/[\w/\.]+ 644 /usr/bin/env ')
# The bundled gem files permissions are overridden as 644 by `make install`.
# https://bugs.ruby-lang.org/issues/17840
# https://github.com/rubygems/rubygems/issues/5255
# https://github.com/ruby/debug/pull/481
# https://github.com/ruby/net-ftp/pull/12
# https://github.com/ruby/net-imap/pull/53
# https://github.com/ruby/net-pop/pull/7
# https://github.com/ruby/prime/pull/16
addFilter(r'^.*: E: non-executable-script /usr/share/gems/gems/(debug|net-(ftp|imap|pop)|prime)-[\d\.]+/bin/\w+ 644 ')
# Ruby provides API to set the cipher list.
addFilter(r'^ruby-libs\.\w+: W: crypto-policy-non-compliance-openssl /usr/lib(64)?/ruby/openssl.so SSL_CTX_set_cipher_list$')
# `gethostbyname` is part of deprecated Ruby API. There is also request to drop the API altogether:
# https://bugs.ruby-lang.org/issues/13097
# https://bugs.ruby-lang.org/issues/17944
addFilter(r'^ruby-libs\.\w+: W: binary-or-shlib-calls-gethostbyname /usr/lib(64)?/ruby/socket.so$')
# Rake ships some examples.
addFilter(r'^rubygem-rake.noarch: W: devel-file-in-non-devel-package /usr/share/gems/gems/rake-[\d\.]+/doc/example/\w+.c$')
# Some executables don't have their manual pages. Is it worth of use help2man?
addFilter(r'^.+: W: no-manual-page-for-binary (bundler|gem|racc|rbs|rdbg|rdoc|ruby-mri|syntax_suggest|typeprof)$')
# Default gems does not come with any documentation.
addFilter(r'^rubygem-(bigdecimal|io-console|json|psych)\.\w+: W: no-documentation$')
# rubygems-devel ships only RPM macros and generators. Their placement is given
# by RPM and can't be modified.
addFilter(r'rubygems-devel.noarch: W: only-non-binary-in-usr-lib$')
# Ignore some spelling false positives.
# Ignore spelling of technical terms
addFilter(r'^ruby-default-gems.noarch: E: spelling-error \(\'gemspec\'')
addFilter(r'^ruby-libs.x86_64: E: spelling-error \(\'libruby\'')
addFilter(r'^rubygem-test-unit.noarch: E: spelling-error \(\'xUnit\'')
addFilter(r'^rubygem-psych.x86_64: E: spelling-error \(\'libyaml\'')
addFilter(r'^rubygem-io-console.x86_64: E: spelling-error \(\'readline\'')
# `pyaml` is part of URL
addFilter(r'^rubygem-psych.x86_64: E: spelling-error \(\'pyyaml\'')
# `de-` is actually prefix
addFilter(r'^rubygem-psych.x86_64: E: spelling-error \(\'de\'')
# It does not seemt to be worth of changing rubygems to archful package due to
# single directory, unless it causes some real troubles.
addFilter(r'^rubygems.noarch: E: noarch-with-lib64$')

View File

@ -202,7 +202,7 @@
Summary: An interpreter of object-oriented scripting language
Name: ruby4.0
Version: %{ruby_version}%{?development_release}
Release: 34%{?dist}
Release: 35%{?dist}
# Licenses, which are likely not included in binary RPMs:
# Apache-2.0:
# benchmark/gc/redblack.rb
@ -335,12 +335,33 @@ Patch10: ruby-4.0.1-test_box-avoid-failure-with-program-suffix.patch
# option in Ruby's main build.
# https://github.com/brianmario/mysql2/issues/1201
Patch11: rubygem-mysql2-0.5.7-Disable-RPATH-completely-in-extconf.rb.patch
# CVE-2026-33210
# Fix for Denial of Service or Information Disclosure
# CVE-2026-33210
# Fix for Denial of Service or Information Disclosure
# via format string injection
# in Ruby JSON
# https://github.com/ruby/json/commit/393b41c3e5f87491e1e34fa59fa78ff6fa179a74
Patch12: ruby-4.0.3-Fix-a-format-string-injection-vulnerability.patch
# CVE-2026-42245
# Fix Net::IMAP ResponseReader quadratic complexity vulnerability
# Advisory: https://github.com/advisories/GHSA-q2mw-fvj9-vvcw
# Sourced from:
# - https://github.com/ruby/net-imap/commit/341ab628
# - https://github.com/ruby/net-imap/commit/49c516d6
# - https://github.com/ruby/net-imap/commit/6f82e28f
Patch13: rubygem-net-imap-0.6.2-ResponseReader-quadratic-complexity-CVE-2026-42245.patch
# CVE-2026-42246
# Fix Net::IMAP STARTTLS stripping vulnerability
# Advisory: https://github.com/advisories/GHSA-vcgp-9326-pqcp
# Sourced from:
# - https://github.com/ruby/net-imap/commit/62eea6ff
# - https://github.com/ruby/net-imap/commit/24d5c773
Patch14: rubygem-net-imap-0.6.2-STARTTLS-stripping-vulnerability-CVE-2026-42246.patch
# CVE-2026-42258
# Fix Net::IMAP command injection vulnerability via unvalidated Symbol arguments
# Advisory: https://github.com/advisories/GHSA-75xq-5h9v-w6px
# Sourced from:
# - https://github.com/ruby/net-imap/commit/9db3e9d
Patch15: rubygem-net-imap-0.6.2-flag-symbol-validation-CVE-2026-42258.patch
%{?with_rubypick:Suggests: rubypick}
@ -506,6 +527,12 @@ popd
%patch 10 -p1
%patch 12 -p1
pushd .bundle/gems/net-imap-%{net_imap_version}
%patch 13 -p1
%patch 14 -p1
%patch 15 -p1
popd
# Provide an example of usage of the tapset:
cp -a %{SOURCE3} .
@ -1646,15 +1673,24 @@ make -C %{_vpath_builddir} runruby TESTRUN_SCRIPT=" \
%{_libdir}/pkgconfig/%{pkgname}-%{major_minor_version}.pc
%changelog
* Wed Jun 10 2026 Tomas Juhasz <tjuhasz@redhat.com> - 4.0.3-35
- Fix Net::IMAP ResponseReader quadratic complexity vulnerability (CVE-2026-42245)
Includes core fix plus additional performance optimizations
Resolves: RHEL-181675
- Fix Net::IMAP STARTTLS stripping vulnerability (CVE-2026-42246)
Resolves: RHEL-181767
- Fix Net::IMAP command injection vulnerability via unvalidated Symbol arguments (CVE-2026-42258)
Resolves: RHEL-181793
* Wed Apr 29 2026 Tomas Juhasz <tjuhasz@redhat.com> - 4.0.3-34
- Upgrade to Ruby 4.0.3.
Resolves: RHEL-170933
- Fix ERB: Arbitrary code execution via deserialization bypass
Resolves: RHEL-171239
- Fix ERB: Arbitrary code execution via bypass
(CVE-2026-41316)
Resolves: RHEL-170911
Resolves: RHEL-170910
- Fix JSON: Denial of Service or Information Disclosure via format string injection
(CVE-2026-33210)
Resolves: RHEL-169964
Resolves: RHEL-173457
* Thu Feb 05 2026 Jarek Prokop <jprokop@redhat.com> - 4.0.1-33
- Initial package.

View File

@ -0,0 +1,343 @@
From 341ab628167da41f33db69e5c240918aa65b60cd Mon Sep 17 00:00:00 2001
From: nick evans <nick@rubinick.dev>
Date: Tue, 31 Mar 2026 19:28:20 -0400
Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8D=92=20pick=20341ab6281:=20?=
=?UTF-8?q?=E2=9A=A1=EF=B8=8F=F0=9F=94=92=EF=B8=8F=20Fix=20non-linear=20pe?=
=?UTF-8?q?rformance=20in=20ResponseReader?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A very large response with many small repeated literals can trigger
super-linear time. This happens because the regular expression that
checks for literal continuation matches from the beginning of the buffer
every time.
This could be mitigated by searching from an offset, based on what has
already been processed, or only searching the most recent line (before
merging it with the buffer), but that is still `O(n)` on line length.
The regexp is anchored to the end of the string, so searching in reverse
from the end of the string should be `O(1)`. This is accomplished by
converting `=~` to `rindex`.
Note that this _does_ slow down the "no literals" scenario.
```
$ benchmark-driver benchmarks/response_reader.yml --filter KiB
Warming up --------------------------------------
1KiB with no literals 143.564k i/s - 153.197k times in 1.067099s (6.97μs/i)
10KiB with no literals 27.394k i/s - 28.864k times in 1.053670s (36.50μs/i)
100KiB with no literals 2.926k i/s - 3.157k times in 1.079109s (341.81μs/i)
1KiB of 25B literals 2.786k i/s - 2.970k times in 1.066159s (358.98μs/i)
10KiB of 25B literals 263.498 i/s - 286.000 times in 1.085396s (3.80ms/i)
100KiB of 25B literals 19.470 i/s - 20.000 times in 1.027203s (51.36ms/i)
1KiB of 0B literals 530.014 i/s - 530.000 times in 0.999974s (1.89ms/i)
10KiB of 0B literals 45.239 i/s - 50.000 times in 1.105233s (22.10ms/i)
100KiB of 0B literals 3.075 i/s - 4.000 times in 1.300721s (325.18ms/i)
Calculating -------------------------------------
local YJIT
1KiB with no literals 137.049k 159.971k i/s - 430.691k times in 3.142607s 2.692304s
10KiB with no literals 27.272k 28.101k i/s - 82.181k times in 3.013413s 2.924470s
100KiB with no literals 2.941k 2.937k i/s - 8.776k times in 2.984095s 2.988129s
1KiB of 25B literals 2.803k 4.136k i/s - 8.357k times in 2.981249s 2.020772s
10KiB of 25B literals 262.978 385.394 i/s - 790.000 times in 3.004055s 2.049850s
100KiB of 25B literals 18.355 22.549 i/s - 58.000 times in 3.159962s 2.572152s
1KiB of 0B literals 505.733 759.572 i/s - 1.590k times in 3.143953s 2.093285s
10KiB of 0B literals 45.414 67.569 i/s - 135.000 times in 2.972648s 1.997962s
100KiB of 0B literals 2.722 3.510 i/s - 9.000 times in 3.306786s 2.564007s
Comparison:
1KiB with no literals
YJIT: 159971.1 i/s
local: 137049.0 i/s - 1.17x slower
10KiB with no literals
YJIT: 28101.2 i/s
local: 27271.7 i/s - 1.03x slower
100KiB with no literals
local: 2940.9 i/s
YJIT: 2937.0 i/s - 1.00x slower
1KiB of 25B literals
YJIT: 4135.5 i/s
local: 2803.2 i/s - 1.48x slower
10KiB of 25B literals
YJIT: 385.4 i/s
local: 263.0 i/s - 1.47x slower
100KiB of 25B literals
YJIT: 22.5 i/s
local: 18.4 i/s - 1.23x slower
1KiB of 0B literals
YJIT: 759.6 i/s
local: 505.7 i/s - 1.50x slower
10KiB of 0B literals
YJIT: 67.6 i/s
local: 45.4 i/s - 1.49x slower
100KiB of 0B literals
YJIT: 3.5 i/s
local: 2.7 i/s - 1.29x slower
```
For responses that are larger than 10KiB, the benchmarks do take another
dip. Despite that, I believe the algorithm _is_ still linear, and that
the performance hit on large responses is probably due to the large
strings inducing memory locality (paging/caching) bottlenecks.
---
lib/net/imap/response_reader.rb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/lib/net/imap/response_reader.rb b/lib/net/imap/response_reader.rb
index d3e819c..71f1980 100644
--- a/lib/net/imap/response_reader.rb
+++ b/lib/net/imap/response_reader.rb
@@ -32,7 +32,7 @@ module Net
def empty? = buff.empty?
def done? = line_done? && !get_literal_size
def line_done? = buff.end_with?(CRLF)
- def get_literal_size = /\{(\d+)\}\r\n\z/n =~ buff && $1.to_i
+ def get_literal_size = buff.rindex(/\{(\d+)\}\r\n\z/n) && $1.to_i
def read_line
buff << (@sock.gets(CRLF, read_limit) or throw :eof)
From 49c516d621bbffeaf4472e8ff23b85703fc83034 Mon Sep 17 00:00:00 2001
From: nick evans <nick@rubinick.dev>
Date: Wed, 15 Apr 2026 10:00:24 -0400
Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=8D=92=20pick=2049c516d62:=20?=
=?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Faster=20ResponseParser:=20short-circuit?=
=?UTF-8?q?=20no=20literal?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
I was suprised at how much slower `buff.rindex` is (vs `=~`) when it
doesn't match. This speeds up that case significantly (it's now faster
than it was prior to the `rindex` change), with only a small impact in
the case when it does match.
```
$ benchmark-driver benchmarks/response_reader.yml --filter KiB
Warming up --------------------------------------
1KiB with no literals 202.754k i/s - 210.144k times in 1.036449s (4.93μs/i)
10KiB with no literals 55.683k i/s - 57.541k times in 1.033362s (17.96μs/i)
100KiB with no literals 6.654k i/s - 7.176k times in 1.078491s (150.29μs/i)
1KiB of 25B literals 2.780k i/s - 2.959k times in 1.064363s (359.70μs/i)
10KiB of 25B literals 260.357 i/s - 286.000 times in 1.098491s (3.84ms/i)
100KiB of 25B literals 19.485 i/s - 20.000 times in 1.026445s (51.32ms/i)
1KiB of 0B literals 506.675 i/s - 550.000 times in 1.085508s (1.97ms/i)
10KiB of 0B literals 44.384 i/s - 45.000 times in 1.013872s (22.53ms/i)
100KiB of 0B literals 3.063 i/s - 4.000 times in 1.305939s (326.48ms/i)
Calculating -------------------------------------
local YJIT
1KiB with no literals 194.355k 247.756k i/s - 608.261k times in 3.129645s 2.455086s
10KiB with no literals 55.733k 58.585k i/s - 167.049k times in 2.997311s 2.851414s
100KiB with no literals 6.553k 6.453k i/s - 19.961k times in 3.045870s 3.093461s
1KiB of 25B literals 2.732k 4.061k i/s - 8.340k times in 3.052737s 2.053682s
10KiB of 25B literals 256.552 379.524 i/s - 781.000 times in 3.044220s 2.057840s
100KiB of 25B literals 17.804 23.286 i/s - 58.000 times in 3.257733s 2.490779s
1KiB of 0B literals 467.714 703.446 i/s - 1.520k times in 3.249846s 2.160791s
10KiB of 0B literals 45.376 65.876 i/s - 133.000 times in 2.931045s 2.018955s
100KiB of 0B literals 3.072 3.840 i/s - 9.000 times in 2.929458s 2.343586s
Comparison:
1KiB with no literals
YJIT: 247755.5 i/s
local: 194354.7 i/s - 1.27x slower
10KiB with no literals
YJIT: 58584.6 i/s
local: 55733.0 i/s - 1.05x slower
100KiB with no literals
local: 6553.5 i/s
YJIT: 6452.6 i/s - 1.02x slower
1KiB of 25B literals
YJIT: 4061.0 i/s
local: 2732.0 i/s - 1.49x slower
10KiB of 25B literals
YJIT: 379.5 i/s
local: 256.6 i/s - 1.48x slower
100KiB of 25B literals
YJIT: 23.3 i/s
local: 17.8 i/s - 1.31x slower
1KiB of 0B literals
YJIT: 703.4 i/s
local: 467.7 i/s - 1.50x slower
10KiB of 0B literals
YJIT: 65.9 i/s
local: 45.4 i/s - 1.45x slower
100KiB of 0B literals
YJIT: 3.8 i/s
local: 3.1 i/s - 1.25x slower
```
---
lib/net/imap/response_reader.rb | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/lib/net/imap/response_reader.rb b/lib/net/imap/response_reader.rb
index 71f1980..09d9a79 100644
--- a/lib/net/imap/response_reader.rb
+++ b/lib/net/imap/response_reader.rb
@@ -32,7 +32,10 @@ module Net
def empty? = buff.empty?
def done? = line_done? && !get_literal_size
def line_done? = buff.end_with?(CRLF)
- def get_literal_size = buff.rindex(/\{(\d+)\}\r\n\z/n) && $1.to_i
+
+ def get_literal_size
+ buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) && $1.to_i
+ end
def read_line
buff << (@sock.gets(CRLF, read_limit) or throw :eof)
From 6f82e28f714ecced2396144bce0155e2cdb0256e Mon Sep 17 00:00:00 2001
From: nick evans <nick@rubinick.dev>
Date: Tue, 14 Apr 2026 14:40:48 -0400
Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=8D=92=20pick=206f82e28f7:=20?=
=?UTF-8?q?=E2=9A=A1=EF=B8=8F=20Faster=20ResponseReader:=20parse=20literal?=
=?UTF-8?q?=20from=20line?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Unfortunately, neither `#rindex` nor even `#end_with?` appear to be
truly `O(1)` for very large strings (100K+). I'm guessing that this is
due to memory locality and caching issues. But, by parsing the literal
from the latest `line` (rather than the full buffer), we mostly avoid
that problem.
Also, by explicitly parsing literal_size immediately after reading the
line, we don't need to parse it again in `#done?`.
```
$ benchmark-driver benchmarks/response_reader.yml --filter KiB
Warming up --------------------------------------
1KiB with no literals 202.846k i/s - 214.181k times in 1.055878s (4.93μs/i)
10KiB with no literals 55.699k i/s - 57.354k times in 1.029717s (17.95μs/i)
100KiB with no literals 6.622k i/s - 6.688k times in 1.009943s (151.01μs/i)
1KiB of 25B literals 3.428k i/s - 3.751k times in 1.094065s (291.67μs/i)
10KiB of 25B literals 342.733 i/s - 350.000 times in 1.021202s (2.92ms/i)
100KiB of 25B literals 34.343 i/s - 36.000 times in 1.048234s (29.12ms/i)
1KiB of 0B literals 683.800 i/s - 690.000 times in 1.009066s (1.46ms/i)
10KiB of 0B literals 69.186 i/s - 70.000 times in 1.011759s (14.45ms/i)
100KiB of 0B literals 6.914 i/s - 7.000 times in 1.012449s (144.64ms/i)
Calculating -------------------------------------
local YJIT
1KiB with no literals 193.622k 250.330k i/s - 608.539k times in 3.142929s 2.430944s
10KiB with no literals 55.944k 58.881k i/s - 167.096k times in 2.986849s 2.837843s
100KiB with no literals 6.550k 6.480k i/s - 19.866k times in 3.033041s 3.065821s
1KiB of 25B literals 3.445k 5.520k i/s - 10.285k times in 2.985693s 1.863057s
10KiB of 25B literals 338.578 548.670 i/s - 1.028k times in 3.036224s 1.873620s
100KiB of 25B literals 33.829 55.860 i/s - 103.000 times in 3.044728s 1.843900s
1KiB of 0B literals 626.970 1.103k i/s - 2.051k times in 3.271287s 1.860275s
10KiB of 0B literals 66.065 108.347 i/s - 207.000 times in 3.133301s 1.910523s
100KiB of 0B literals 6.720 8.159 i/s - 20.000 times in 2.976273s 2.451265s
Comparison:
1KiB with no literals
YJIT: 250330.3 i/s
local: 193621.6 i/s - 1.29x slower
10KiB with no literals
YJIT: 58881.3 i/s
local: 55943.9 i/s - 1.05x slower
100KiB with no literals
local: 6549.9 i/s
YJIT: 6479.8 i/s - 1.01x slower
1KiB of 25B literals
YJIT: 5520.5 i/s
local: 3444.8 i/s - 1.60x slower
10KiB of 25B literals
YJIT: 548.7 i/s
local: 338.6 i/s - 1.62x slower
100KiB of 25B literals
YJIT: 55.9 i/s
local: 33.8 i/s - 1.65x slower
1KiB of 0B literals
YJIT: 1102.5 i/s
local: 627.0 i/s - 1.76x slower
10KiB of 0B literals
YJIT: 108.3 i/s
local: 66.1 i/s - 1.64x slower
100KiB of 0B literals
YJIT: 8.2 i/s
local: 6.7 i/s - 1.21x slower
```
---
lib/net/imap/response_reader.rb | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/lib/net/imap/response_reader.rb b/lib/net/imap/response_reader.rb
index 09d9a79..dd19d98 100644
--- a/lib/net/imap/response_reader.rb
+++ b/lib/net/imap/response_reader.rb
@@ -8,6 +8,7 @@ module Net
def initialize(client, sock)
@client, @sock = client, sock
+ @buff = @literal_size = nil
end
def read_response_buffer
@@ -15,13 +16,13 @@ module Net
catch :eof do
while true
read_line
- break unless (@literal_size = get_literal_size)
+ break unless literal_size
read_literal
end
end
buff
ensure
- @buff = nil
+ @buff = @literal_size = nil
end
private
@@ -30,16 +31,18 @@ module Net
def bytes_read = buff.bytesize
def empty? = buff.empty?
- def done? = line_done? && !get_literal_size
+ def done? = line_done? && !literal_size
def line_done? = buff.end_with?(CRLF)
- def get_literal_size
+ def get_literal_size(buff)
buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) && $1.to_i
end
def read_line
- buff << (@sock.gets(CRLF, read_limit) or throw :eof)
+ line = (@sock.gets(CRLF, read_limit) or throw :eof)
+ buff << line
max_response_remaining! unless line_done?
+ @literal_size = get_literal_size(line)
end
def read_literal

View File

@ -0,0 +1,125 @@
From 62eea6ffe1e390060065169474f97edbc42bd2b2 Mon Sep 17 00:00:00 2001
From: nick evans <nick@rubinick.dev>
Date: Fri, 27 Mar 2026 17:31:11 -0400
Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=92=F0=9F=A5=85=20Ensure=20STARTTL?=
=?UTF-8?q?S=20tagged=20response=20was=20handled?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Taking a "belt-and-suspenders" approach to a STARTTLS stripping attack:
This handles `STARTTLS` as a special-case: if the `STARTTLS` handler
did not run, for _whatever_ reason, an exception _must_ be raised and
the connection dropped.
_No_ command should ever receive a tagged `OK` prior to completely
sending the command. But `STARTTLS` is security-sensitive enough to
warrant this special-case handler.
---
lib/net/imap.rb | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/lib/net/imap.rb b/lib/net/imap.rb
index 00f1588..e2cbc5a 100644
--- a/lib/net/imap.rb
+++ b/lib/net/imap.rb
@@ -1390,9 +1390,11 @@ module Net
#
def starttls(**options)
@ssl_ctx_params, @ssl_ctx = build_ssl_ctx(options)
+ handled = false
error = nil
ok = send_command("STARTTLS") do |resp|
if resp.kind_of?(TaggedResponse) && resp.name == "OK"
+ handled = true
clear_cached_capabilities
clear_responses
start_tls_session
@@ -1404,6 +1406,13 @@ module Net
disconnect
raise error
end
+ unless handled
+ disconnect
+ raise InvalidResponseError,
+ "STARTTLS handler was bypassed, although server responded %p" % [
+ ok.raw_data.chomp
+ ]
+ end
ok
end
From 24d5c773d1bb76ca1cd0a26b2218195011c16969 Mon Sep 17 00:00:00 2001
From: nick evans <nick@rubinick.dev>
Date: Fri, 27 Mar 2026 18:00:09 -0400
Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=92=F0=9F=A5=85=20Handle=20tagged?=
=?UTF-8?q?=20"OK"=20to=20incomplete=20command?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Taking a "belt-and-suspenders" approach:
This is a potential problem for any command which registers a response
handler: a malicious server can easily guess what the next tag will be,
and send an `OK` response _before_ the client the response handler is
attached.
`STARTTLS` is an extreme example of this issue: if the `STARTTLS`
handler does not run, then `#starttls` will not start the TLS session,
and the connection is not secured, _but no error is raised._
We should _also_ attach the response handler before sending the `CRLF`,
but that is neither necessary (the response handler will added before
the `synchronize` mutex is unlocked) nor sufficient (the fake `OK` can
be sent _much_ earlier).
On the other hand, it _is_ okay for the server to send an error tagged
response (`NO` or `BAD`), before sending the command has completed.
---
lib/net/imap.rb | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/lib/net/imap.rb b/lib/net/imap.rb
index e2cbc5a..6af1ca5 100644
--- a/lib/net/imap.rb
+++ b/lib/net/imap.rb
@@ -3086,6 +3086,7 @@ module Net
synchronize do
tag = Thread.current[:net_imap_tag] = generate_tag
+ guard_against_tagged_response_skipping_handler!(tag, "IDLE")
put_string("#{tag} IDLE#{CRLF}")
begin
@@ -3550,6 +3551,7 @@ module Net
put_string(" ")
send_data(i, tag)
end
+ guard_against_tagged_response_skipping_handler!(tag, cmd)
put_string(CRLF)
if cmd == "LOGOUT"
@logout_command_tag = tag
@@ -3565,6 +3567,19 @@ module Net
end
end
end
+ rescue InvalidResponseError
+ disconnect
+ raise
+ end
+
+ def guard_against_tagged_response_skipping_handler!(tag, cmd)
+ return unless (resp = @tagged_responses[tag])&.name&.upcase == "OK"
+ raise InvalidResponseError, format(
+ "Received tagged 'OK' to incomplete %s command (tag=%s). " \
+ "This could indicate a malicious server, a man-in-the-middle, or " \
+ "client-side command injection. Disconnecting.",
+ cmd, tag
+ )
end
def generate_tag

View File

@ -0,0 +1,98 @@
From 9db3e9d Mon Sep 17 00:00:00 2001
From: nick evans <nick@rubinick.dev>
Date: Thu, 19 Feb 2026 15:06:08 -0500
Subject: [PATCH] =?UTF-8?q?=F0=9F=A5=85=20Strictly=20validate=20symbol=20(?=
=?UTF-8?q?\flag)=20arguments?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Flags should not allow `atom-specials`.
Previously, no validation was done on symbol data. Sending atom or flag
args which contain atom specials could lead to various errors.
Although this could theoretically include injection attacks, this is not
considered to be a critical vulnerability in `net-imap`, for the
following reason: Valid "system flag" inputs are restricted to an
enumerated set of RFC-defined flag types. User-defined "keyword" flags
are sent as atoms, not flags, which use string inputs (strings which
can't be sent as an atom will be quoted or sent as a literal). `\Seen`
as a flag (symbol argument) is semantically different from `Seen` as a
keyword (string argument). So there is no scenario where it is
appropriate to call `#to_sym` on unvetted user input. Any code which
calls `#to_sym` indiscriminately on user-input is already buggy.
Nevertheless, users should reasonably be able to rely on `net-imap` to
do very basic input validation on its basic input types.
---
lib/net/imap/command_data.rb | 33 +++++++++++++++++++++++++++------
1 file changed, 27 insertions(+), 6 deletions(-)
diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb
index 02b9a61..2671d5d 100644
--- a/lib/net/imap/command_data.rb
+++ b/lib/net/imap/command_data.rb
@@ -25,6 +25,7 @@ module Net
end
when Time, Date, DateTime
when Symbol
+ Flag.validate(data)
else
data.validate
end
@@ -45,7 +46,7 @@ module Net
when Date
send_date_data(data)
when Symbol
- send_symbol_data(data)
+ Flag[data].send_data(self, tag)
else
data.send_data(self, tag)
end
@@ -115,11 +116,13 @@ module Net
def send_date_data(date) put_string Net::IMAP.encode_date(date) end
def send_time_data(time) put_string Net::IMAP.encode_time(time) end
- def send_symbol_data(symbol)
- put_string("\\" + symbol.to_s)
- end
-
CommandData = Data.define(:data) do # :nodoc:
+ def self.validate(...)
+ data = new(...)
+ data.validate
+ data
+ end
+
def send_data(imap, tag)
raise NoMethodError, "#{self.class} must implement #{__method__}"
end
@@ -135,8 +138,26 @@ module Net
end
class Atom < CommandData # :nodoc:
+ def initialize(**)
+ super
+ validate
+ end
+
+ def validate
+ data.to_s.ascii_only? \
+ or raise DataFormatError, "#{self.class} must be ASCII only"
+ data.match?(ResponseParser::Patterns::ATOM_SPECIALS) \
+ and raise DataFormatError, "#{self.class} must not contain atom-specials"
+ end
+
def send_data(imap, tag)
- imap.__send__(:put_string, data)
+ imap.__send__(:put_string, data.to_s)
+ end
+ end
+
+ class Flag < Atom # :nodoc:
+ def send_data(imap, tag)
+ imap.__send__(:put_string, "\\#{data}")
end
end