VYPR
Medium severity5.8NVD Advisory· Published Jun 9, 2026· Updated Jun 9, 2026

Net::IMAP: Command Injection via non-synchronizing literal in "raw" argument

CVE-2026-47240

Description

Several Net::IMAP commands accept a "raw data" argument that is sent verbatim after validation to prevent command injection. However, if a server does not support non-synchronizing literals, it may still be possible to inject arbitrary IMAP commands inside non-synchronizing literals.

Details

Raw data arguments support embedded literal values, both synchronizing and non-synchronizing. Non-synchronizing literals can only be safely sent when the server advertises any of the LITERAL+, LITERAL-, or IMAP4rev2 capabilities. But raw data arguments do not verify server support for non-synchronizing literals prior to sending.

Servers without support for non-synchronizing literals could handle them in several different ways: If a server sees a "}\r\n" byte sequence but can't parse the literal bytesize, it _may_ cautiously decide to close the connection, blocking any command injection attacks. However, a server without support for non-synchronizing literals may instead interpret the "+}\r\n" as the end of a malformed command line and respond with a tagged BAD. In that case, the contents of the literal will be interpreted as one or more new pipelined commands, allowing a CRLF command injection attack to succeed.

This affects the following commands' string arguments: * criteria for #search and #uid_search * search_keys for #sort, #thread, #uid_sort, and #uid_thread * attr for #fetch and #uid_fetch

Prior to net-imap v0.6.4, v0.5.14, and v0.4.24, raw data arguments were not validated in _any_ way, so they were also vulnerable to this attack. See CVE-2026-42257 (GHSA-hm49-wcqc-g2xg).

Impact

Fortunately, LITERAL- is supported by most modern IMAP servers. Even without support for non-synchronizing literals, cautious servers may handle invalid literal bytesize by closing the connection . However, servers which handle a non-synchronizing literal just like any other malformed command will enable this vulnerability.

If a developer passes an unvalidated user-controlled input for one of these method arguments, an attacker can append CRLF sequence followed by a new IMAP command (like DELETE mailbox). Although this does not directly enable data exfiltration, it could be combined with other attack vectors or knowledge of the target system's attributes, e.g.: shared mail folders or the application's installed response handlers.

Mitigation

Update to a version of net-imap which validates server support for non-synchronizing literals before sending them.

If upgrading net-imap is not possible: * Explicitly validate user-controlled inputs to prevent embedded non-synchronizing literals unless the server supports them. * For a simpler, more cautious approach: all embedded literals can be unconditionally prohibited, by checking that string inputs do not contain any CR or LF bytes. * Verify that the server advertises any of the LITERAL+, LITERAL-, or IMAP4rev2 capabilities before using untrusted string inputs for the affected "raw data" arguments.

Affected products

1

Patches

4
a2f61af6b7a7

🍒 pick 62a0da6d (#701): 🥅 Validate non-synchronizing literals support

https://github.com/ruby/net-imapnick evansMay 11, 2026Fixed in 0.5.15via ghsa-release-walk
2 files changed · +68 2
  • lib/net/imap/command_data.rb+17 1 modified
    @@ -86,12 +86,19 @@ def send_binary_literal(*a, **kw); send_literal(*a, **kw, binary: true) end
     
         # `non_sync` is an optional tri-state flag:
         # * `true`  -> Force non-synchronizing `LITERAL+`/`LITERAL-` behavior.
    -    #   TODO: raise or warn when capabilities don't allow non_sync.
    +    #   NOTE: raises DataFormatError when server doesn't support
    +    #   non-synchronizing literal, or literal is too large for LITERAL-.
         # * `false` -> Force normal synchronizing literal behavior.
         # * `nil`   -> (default) Currently behaves like `false` (will be dynamic).
         #   TODO: Dynamic, based on capabilities and bytesize.
         def send_literal(str, tag = nil, binary: false, non_sync: nil)
           synchronize do
    +        if non_sync && !non_sync_literal_allowed?(str.bytesize)
    +          # TODO: check in Printer, so we don't need to close the connection.
    +          @sock.close
    +          raise DataFormatError, "Connection closed: " \
    +            "Cannot send non-synchronizing literal without known server support"
    +        end
             prefix = "~" if binary
             plus = "+" if non_sync
             put_string("#{prefix}{#{str.bytesize}#{plus}}\r\n")
    @@ -113,6 +120,15 @@ def send_literal(str, tag = nil, binary: false, non_sync: nil)
           end
         end
     
    +    def non_sync_literal_allowed?(bytesize)
    +      return unless capabilities_cached?
    +      return "+" if capable?("LITERAL+")
    +      return "-" if capable_literal_minus? && bytesize <= 4096
    +      false
    +    end
    +
    +    def capable_literal_minus? = capable?("LITERAL-") || capable?("IMAP4rev2")
    +
         # NOTE: +num+ should already be an Integer
         def send_number_data(num)
           put_string(Integer(num).to_s)
    
  • test/net/imap/test_imap.rb+51 1 modified
    @@ -801,7 +801,7 @@ def test_raw_data
       end
     
       test("send literal args") do
    -    with_fake_server do |server, imap|
    +    with_fake_server(with_extensions: %w[LITERAL-]) do |server, imap|
           server.on "TEST", &:done_ok
           send_args = ->(*args) do
             imap.__send__(:send_command, "TEST", *args)
    @@ -850,6 +850,56 @@ def test_raw_data
         end
       end
     
    +  test("send non-synchronizing literals with LITERAL+") do
    +    with_fake_server(
    +      with_extensions: %w[LITERAL+], greeting_capabilities: true,
    +    ) do |server, imap|
    +      def imap.send_test_args(*args) = send_command("TEST", *args)
    +      server.on "TEST", &:done_ok
    +
    +      # imap.config.max_non_synchronizing_literal = 5_000
    +      # NOTE: support for automatic non-synchronizing literals added in v0.6
    +      large = "\xff".b * 5_000
    +      imap.send_test_args Net::IMAP::Literal[large, nil]
    +      assert_equal("{5000}\r\n#{large}".b, server.commands.pop.args)
    +
    +      large = "\xff".b * 10_000
    +      imap.send_test_args Net::IMAP::Literal[large, nil]
    +      assert_equal("{10000}\r\n#{large}".b, server.commands.pop.args)
    +
    +      imap.send_test_args Net::IMAP::Literal[large, true]
    +      assert_equal("{10000+}\r\n#{large}".b, server.commands.pop.args)
    +    end
    +  end
    +
    +  test("send non-synchronizing literal that's too large for LITERAL-") do
    +    with_fake_server(
    +      with_extensions: %w[LITERAL-], greeting_capabilities: true,
    +      ignore_abrupt_eof: true
    +    ) do |server, imap|
    +      def imap.send_test_args(*args) = send_command("TEST", *args)
    +      server.on "TEST", &:done_ok
    +      assert_raise(Net::IMAP::DataFormatError) do
    +        imap.send_test_args Net::IMAP::Literal["\xff".b * 5000, true]
    +      end
    +      assert imap.disconnected?
    +    end
    +  end
    +
    +  test("send non-synchronizing literal without known server support") do
    +    with_fake_server(
    +      with_extensions: %w[LITERAL+], greeting_capabilities: false,
    +      ignore_abrupt_eof: true
    +    ) do |server, imap|
    +      def imap.send_test_args(*args) = send_command("TEST", *args)
    +      server.on "TEST", &:done_ok
    +      assert_raise(Net::IMAP::DataFormatError) do
    +        imap.send_test_args Net::IMAP::Literal["\xff".b * 100, true]
    +      end
    +      assert imap.disconnected?
    +    end
    +  end
    +
       def test_disconnect
         server = create_tcp_server
         port = server.addr[1]
    
69da4a490dd1

🍒 pick 8d9397ab (#698): 🥅 Validate QuotedString contains only valid bytes

https://github.com/ruby/net-imapnick evansMay 2, 2026Fixed in 0.5.15via ghsa-release-walk
2 files changed · +113 18
  • lib/net/imap/command_data.rb+21 8 modified
    @@ -150,8 +150,8 @@ def validate
           end
         end
     
    -    # Represents IMAP +text+ data, which covers everything in the IMAP grammar,
    -    # except for +literal+, +literal8+, and the concluding +CRLF+.
    +    # Represents IMAP +text+ or +quoted+ data, which share the same
    +    # validations of decoded #data, and differ only in how they are formatted.
         #
         # +data+ may contain any 7-bit ASCII character except +NULL+, +CR+, or +LF+.
         # Any multibyte +UTF-8+ character is also allowed when the connection
    @@ -164,7 +164,7 @@ def validate
         #
         # The string's bytes must be valid ASCII or valid UTF-8.  The string's
         # reported encoding is ignored, but the string is _not_ transcoded.
    -    class RawText < CommandData # :nodoc:
    +    class ValidNonLiteralData < CommandData
           def initialize(data:)
             data = String(data.to_str)
             unless data.encoding in Encoding::ASCII | Encoding::UTF_8
    @@ -189,7 +189,17 @@ def validate
     
           def ascii_only? = data.ascii_only?
     
    -      def send_data(imap, tag) = imap.__send__(:put_string, data)
    +      def send_data(imap, tag = nil) = imap.__send__(:put_string, formatted)
    +    end
    +
    +    # Represents IMAP +text+ data, which covers everything in the IMAP grammar,
    +    # except for +literal+, +literal8+, and the concluding +CRLF+.
    +    #
    +    # NOTE: The current implementation does not verify that the connection
    +    # supports UTF-8.  Future versions may validate this.
    +    class RawText < ValidNonLiteralData # :nodoc:
    +      # raw: no formatting necessary
    +      alias formatted data
         end
     
         class RawData < CommandData # :nodoc:
    @@ -268,10 +278,13 @@ def send_data(imap, tag)
           end
         end
     
    -    class QuotedString < CommandData # :nodoc:
    -      def send_data(imap, tag)
    -        imap.__send__(:send_quoted_string, data)
    -      end
    +    # Represents a IMAP +quoted+ string, which can encode any valid ASCII or
    +    # UTF-8 string, unless it contains any +CR+, +LF+, or +NULL+ bytes.
    +    #
    +    # NOTE: The current implementation does not verify that the connection
    +    # supports UTF-8.  Future versions may validate this.
    +    class QuotedString < ValidNonLiteralData # :nodoc:
    +      def formatted = %("#{data.gsub(/["\\]/, "\\\\\\&")}")
         end
     
         class Literal < Data.define(:data, :non_sync) # :nodoc:
    
  • test/net/imap/test_command_data.rb+92 10 modified
    @@ -8,6 +8,7 @@ class CommandDataTest < Net::IMAP::TestCase
     
       Atom = Net::IMAP::Atom
       Flag = Net::IMAP::Flag
    +  QuotedString = Net::IMAP::QuotedString
       Literal = Net::IMAP::Literal
       Literal8 = Net::IMAP::Literal8
       RawText = Net::IMAP::RawText
    @@ -155,6 +156,83 @@ def send_data(*data, tag: TAG)
         ], imap.output
       end
     
    +  class QuotedStringTest < CommandDataTest
    +    test "quotes ASCII strings (no specials)" do
    +      assert_equal '"INBOX"', QuotedString["INBOX"].formatted
    +      imap.send_data(
    +        QuotedString["INBOX"],
    +        QuotedString["etc"]
    +      )
    +      assert_equal [
    +        Output.put_string('"INBOX"'),
    +        Output.put_string('"etc"'),
    +      ], imap.output
    +      imap.clear
    +    end
    +
    +    test "quotes ASCII strings (atom specials)" do
    +      [
    +        "  with  spaces  in  string  ",
    +        "with_parens()",
    +        "with_list_wildcards*",
    +        "with_list_wildcards%",
    +        "with_resp_special]",
    +        "with\x7fcontrol_char",
    +        %{(){}[]%*'},
    +      ].each do |string|
    +        imap.send_data QuotedString[string]
    +      end
    +      assert_equal [
    +        Output.put_string('"  with  spaces  in  string  "'),
    +        Output.put_string('"with_parens()"'),
    +        Output.put_string('"with_list_wildcards*"'),
    +        Output.put_string('"with_list_wildcards%"'),
    +        Output.put_string('"with_resp_special]"'),
    +        Output.put_string(%{"with\x7fcontrol_char"}),
    +        Output.put_string(%Q{"(){}[]%*'"}),
    +      ], imap.output
    +    end
    +
    +    test "escapes quoted specials" do
    +      [
    +        '"with" "quoted" "specials"',
    +        "\\with\\quoted\\specials\\",
    +        %{(){}[]%*"'\\},
    +      ].each do |string|
    +        imap.send_data QuotedString[string]
    +      end
    +      assert_equal [
    +        Output.put_string('"\"with\" \"quoted\" \"specials\""'),
    +        Output.put_string('"\\\\with\\\\quoted\\\\specials\\\\"'),
    +        Output.put_string(%q{"(){}[]%*\"'\\\\"}),
    +      ], imap.output
    +    end
    +
    +    test "ASCII compatible string with another encodings" do
    +      imap.send_data QuotedString.new("foo bar".encode("cp1252"))
    +      assert_equal [
    +        Output.put_string('"foo bar"'),
    +      ], imap.output
    +    end
    +
    +    test "allows ASCII control chars" do
    +      text = QuotedString.new("beep\b beep\b escape!\e delete this:\x1f")
    +      imap.send_data text
    +      assert_equal [
    +        Output.put_string(%{"beep\b beep\b escape!\e delete this:\x1f"}),
    +      ], imap.output
    +    end
    +
    +    test "quotes valid UTF-8 multibyte chars" do
    +      imap.send_data QuotedString.new("föó bär")
    +      imap.send_data QuotedString.new("ほげ ふが ぴよ")
    +      assert_equal [
    +        Output.put_string('"föó bär"'),
    +        Output.put_string('"ほげ ふが ぴよ"'),
    +      ], imap.output
    +    end
    +  end
    +
       class RawTextTest < CommandDataTest
         test "allows ASCII strings with no specials" do
           imap.send_data(
    @@ -229,14 +307,20 @@ class RawTextTest < CommandDataTest
             Output.put_string("ほげ ふが ぴよ"),
           ], imap.output
         end
    +  end
     
    +  SharedValidNonLiteralDataTests = ->(data_type) do
         data(
           "NULL" => ["with \0 NULL", /NULL\b.+\bbyte/i],
           "CR"   => ["with \r CR",   /CR\b.+\bbyte/i],
           "LF"   => ["with \n LF",   /LF\b.+\bbyte/i],
         )
         test "invalid ASCII byte" do |(text, error_message)|
    -      try_multiple_encodings(error_message, text)
    +      with_multiple_encodings(text) do |encoded|
    +        assert_raise_with_message(DataFormatError, error_message) do
    +          data_type[encoded]
    +        end
    +      end
         end
     
         # See Table 3-7, Well-Formed UTF-8 Byte Sequences, in The Unicode Standard:
    @@ -253,7 +337,11 @@ class RawTextTest < CommandDataTest
           "windows-1252"               => "åêïõü".encode("windows-1252"),
         )
         test "invalid UTF-8" do |text|
    -      try_multiple_encodings(/invalid UTF-8/i, text)
    +      with_multiple_encodings(text) do |encoded|
    +        assert_raise_with_message(DataFormatError, /invalid UTF-8/i) do
    +          data_type[encoded]
    +        end
    +      end
         end
     
         def with_multiple_encodings(data)
    @@ -262,15 +350,9 @@ def with_multiple_encodings(data)
           yield data.dup.force_encoding("UTF-8")
           yield data.dup.force_encoding("cp1252")
         end
    -
    -    def try_multiple_encodings(error_message, data)
    -      with_multiple_encodings(data) do |encoded|
    -        assert_raise_with_message(DataFormatError, error_message) do
    -          RawText[encoded]
    -        end
    -      end
    -    end
       end
    +  QuotedStringTest.class_exec QuotedString, &SharedValidNonLiteralDataTests
    +  RawTextTest     .class_exec RawText,      &SharedValidNonLiteralDataTests
     
       class RawDataTest < CommandDataTest
         test "simple raw text" do
    
2354465f9836

🍒 pick 0d508fa7 (#681): 🥅 Validate server's literal byte size format

https://github.com/ruby/net-imapnick evansApr 15, 2026Fixed in 0.5.15via ghsa-release-walk
4 files changed · +71 13
  • lib/net/imap/response_parser.rb+20 12 modified
    @@ -2189,10 +2189,7 @@ def next_token
                 if $1
                   return Token.new(T_SPACE, $+)
                 elsif $2
    -              len = $+.to_i
    -              val = @str[@pos, len]
    -              @pos += len
    -              return Token.new(T_LITERAL8, val)
    +              literal_token($+, T_LITERAL8)
                 elsif $3 && $7
                   # greedily match ATOM, prefixed with NUMBER, NIL, or PLUS.
                   return Token.new(T_ATOM, $3)
    @@ -2220,10 +2217,7 @@ def next_token
                 elsif $15
                   return Token.new(T_RBRA, $+)
                 elsif $16
    -              len = $+.to_i
    -              val = @str[@pos, len]
    -              @pos += len
    -              return Token.new(T_LITERAL, val)
    +              literal_token($+)
                 elsif $17
                   return Token.new(T_PERCENT, $+)
                 elsif $18
    @@ -2249,10 +2243,7 @@ def next_token
                 elsif $4
                   return Token.new(T_QUOTED, Patterns.unescape_quoted($+))
                 elsif $5
    -              len = $+.to_i
    -              val = @str[@pos, len]
    -              @pos += len
    -              return Token.new(T_LITERAL, val)
    +              literal_token($+)
                 elsif $6
                   return Token.new(T_LPAR, $+)
                 elsif $7
    @@ -2267,6 +2258,23 @@ def next_token
             else
               parse_error("invalid @lex_state - %s", @lex_state.inspect)
             end
    +      rescue DataFormatError => error
    +        parse_error error.message
    +      end
    +
    +      def literal_token(len, type = T_LITERAL)
    +        len = coerce_number64 len.to_i
    +        val = @str[@pos, len]
    +        @pos += len
    +        Token.new(type, val)
    +      end
    +
    +      # copied/adapted from NumValidator in v0.6
    +      def coerce_number64(num)
    +        int = num.to_i
    +        return int if 0 <= int && int <= 0x7fff_ffff_ffff_ffff
    +        raise DataFormatError,
    +          "number64 must be unsigned 63-bit integer: #{num}"
           end
     
         end
    
  • lib/net/imap/response_reader.rb+14 1 modified
    @@ -4,6 +4,8 @@ module Net
       class IMAP
         # See https://www.rfc-editor.org/rfc/rfc9051#section-2.2.2
         class ResponseReader # :nodoc:
    +      include NumValidator
    +
           attr_reader :client
     
           def initialize(client, sock)
    @@ -35,7 +37,10 @@ def done?               = line_done? && !literal_size
           def line_done?          = buff.end_with?(CRLF)
     
           def get_literal_size(buff)
    -        buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) && $1.to_i
    +        buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) &&
    +          coerce_number64($1)
    +      rescue DataFormatError
    +        raise DataFormatError, format("invalid response literal size (%s)", $1)
           end
     
           def read_line
    @@ -74,6 +79,14 @@ def max_response_remaining!
             )
           end
     
    +      # copied/adapted from NumValidator in v0.6
    +      def coerce_number64(num)
    +        int = num.to_i
    +        return int if 0 <= int && int <= 0x7fff_ffff_ffff_ffff
    +        raise DataFormatError,
    +          "number64 must be unsigned 63-bit integer: #{num}"
    +      end
    +
         end
       end
     end
    
  • test/net/imap/fixtures/response_parser/quirky_behaviors.yml+16 0 modified
    @@ -7,6 +7,22 @@
           data:
           raw_data: "* NOOP\r\n"
     
    +  "literal numeric formatted with zero-prefix":
    +    :response: "* 20367 FETCH (BODY[HEADER.FIELDS (Foo)] {012}\r\nFoo: bar\r\n\r\n)\r\n"
    +    :expected: !ruby/struct:Net::IMAP::UntaggedResponse
    +      name: FETCH
    +      data: !ruby/struct:Net::IMAP::FetchData
    +        seqno: 20367
    +        attr:
    +          BODY[HEADER.FIELDS (Foo)]: "Foo: bar\r\n\r\n"
    +      raw_data: "* 20367 FETCH (BODY[HEADER.FIELDS (Foo)] {012}\r\nFoo: bar\r\n\r\n)\r\n"
    +
    +  "invalid literal numeric format (too large)":
    +    :test_type: :assert_parse_failure
    +    :message: "number64 must be unsigned 63-bit integer: 99999999999999999999"
    +    :response:
    +      "* 20367 FETCH (BODY[] {99999999999999999999}\r\nwon't parse this)\r\n"
    +
       test_invalid_noop_response_with_unparseable_data:
         :response: "* NOOP froopy snood\r\n"
         :expected: !ruby/struct:Net::IMAP::IgnoredResponse
    
  • test/net/imap/test_response_reader.rb+21 0 modified
    @@ -25,6 +25,8 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}"
         zero_literal = "tag ok #{literal ""} #{literal ""}\r\n"
         illegal_crs  = "tag ok #{many_crs} #{many_crs}\r\n"
         illegal_lfs  = "tag ok #{literal "\r"}\n#{literal "\r"}\n\r\n"
    +    zero_padded  = "+ {010}\r\n1234567890\r\n" # NOTE: it's decimal, not octal!
    +    goofy_zero   = "+ {000}\r\n\r\n"
         io = StringIO.new([
           simple,
           long_line,
    @@ -33,6 +35,8 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}"
           zero_literal,
           illegal_crs,
           illegal_lfs,
    +      zero_padded,
    +      goofy_zero,
           simple,
         ].join)
         rcvr = Net::IMAP::ResponseReader.new(client, io)
    @@ -43,6 +47,8 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}"
         assert_equal zero_literal, rcvr.read_response_buffer.to_str
         assert_equal illegal_crs,  rcvr.read_response_buffer.to_str
         assert_equal illegal_lfs,  rcvr.read_response_buffer.to_str
    +    assert_equal zero_padded,  rcvr.read_response_buffer.to_str
    +    assert_equal goofy_zero,   rcvr.read_response_buffer.to_str
         assert_equal simple,       rcvr.read_response_buffer.to_str
         assert_equal "",           rcvr.read_response_buffer.to_str
       end
    @@ -82,4 +88,19 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}"
         end
       end
     
    +  data(
    +    bad_int64: "+ {99999999999999999999}\r\ndon't even try to read this...",
    +  )
    +  test "#read_response_buffer with invalid literal size" do |invalid|
    +    client  = FakeClient.new
    +    client.config.max_response_size = nil # any size is allowed!
    +    io = StringIO.new(invalid, "rb")
    +    rcvr = Net::IMAP::ResponseReader.new(client, io)
    +    assert_raise Net::IMAP::DataFormatError do
    +      result = rcvr.read_response_buffer
    +      flunk "Got result: %p" % [result]
    +    end
    +    # assert io.closed?
    +  end
    +
     end
    
134b68269e89
https://github.com/ruby/net-imapFixed in 0.5.15via ghsa-release-walk

Vulnerability mechanics

No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.

References

3

News mentions

0

No linked articles in our index yet.