diff --git a/ruby-4_0-pr18528-update-resolv-0_7_2.patch b/ruby-4_0-pr18528-update-resolv-0_7_2.patch new file mode 100644 index 0000000..e49f0d2 --- /dev/null +++ b/ruby-4_0-pr18528-update-resolv-0_7_2.patch @@ -0,0 +1,984 @@ +From 88ba1d4e1370f4221e060e4ed53ef191834e5838 Mon Sep 17 00:00:00 2001 +From: Hiroshi SHIBATA +Date: Thu, 27 Aug 2026 12:31:49 +0900 +Subject: [PATCH] Bump up resolv-0.7.2 for Ruby 4.0 + +Backport of the August 2026 resolv security release. + +CVE-2026-80212 +CVE-2026-80213 + +Co-Authored-By: Claude Opus 5 +--- + lib/resolv.rb | 114 +++++++++-- + test/resolv/test_dns.rb | 319 ++++++++++++++++++++++++++++++ + test/resolv/test_resource.rb | 61 ++++++ + test/resolv/test_resource_leak.rb | 101 ++++++++++ + 4 files changed, 582 insertions(+), 13 deletions(-) + create mode 100644 test/resolv/test_resource_leak.rb + +diff --git a/lib/resolv.rb b/lib/resolv.rb +index 0e62aaf8510496..a2fe692b38f6cf 100644 +--- a/lib/resolv.rb ++++ b/lib/resolv.rb +@@ -35,7 +35,7 @@ + class Resolv + + # The version string +- VERSION = "0.7.0" ++ VERSION = "0.7.2" + + ## + # Looks up the first IP address for +name+. +@@ -487,13 +487,18 @@ def each_name(address) + # * Resolv::DNS::Resource::IN::A + # * Resolv::DNS::Resource::IN::AAAA + # * Resolv::DNS::Resource::IN::ANY ++ # * Resolv::DNS::Resource::IN::CAA + # * Resolv::DNS::Resource::IN::CNAME + # * Resolv::DNS::Resource::IN::HINFO ++ # * Resolv::DNS::Resource::IN::HTTPS ++ # * Resolv::DNS::Resource::IN::LOC + # * Resolv::DNS::Resource::IN::MINFO + # * Resolv::DNS::Resource::IN::MX + # * Resolv::DNS::Resource::IN::NS + # * Resolv::DNS::Resource::IN::PTR + # * Resolv::DNS::Resource::IN::SOA ++ # * Resolv::DNS::Resource::IN::SRV ++ # * Resolv::DNS::Resource::IN::SVCB + # * Resolv::DNS::Resource::IN::TXT + # * Resolv::DNS::Resource::IN::WKS + # +@@ -721,7 +726,8 @@ def request(sender, tout) + begin + reply, from = recv_reply(select_result[0]) + rescue Errno::ECONNREFUSED, # GNU/Linux, FreeBSD +- Errno::ECONNRESET # Windows ++ Errno::ECONNRESET, # Windows ++ EOFError + # No name server running on the server? + # Don't wait anymore. + raise ResolvTimeout +@@ -930,8 +936,11 @@ def initialize(host, port=Port) + end + + def recv_reply(readable_socks) +- len = readable_socks[0].read(2).unpack('n')[0] ++ len_data = readable_socks[0].read(2) ++ raise EOFError if len_data.nil? || len_data.bytesize != 2 ++ len = len_data.unpack('n')[0] + reply = @socks[0].read(len) ++ raise EOFError if reply.nil? || reply.bytesize != len + return reply, nil + end + +@@ -1244,6 +1253,13 @@ def self.split(arg) + + class Str # :nodoc: + def initialize(string) ++ # A label is limited to 63 octets. [RFC 1035 2.3.4] Checking it here ++ # makes it an invariant of the object: every label, however it was ++ # built, fits in its length octet and cannot wrap it. Callers turn ++ # this into the error their own contract promises. ++ if string.bytesize > 63 ++ raise ArgumentError, "DNS label is too long (#{string.bytesize} bytes, max 63): #{string.inspect}" ++ end + @string = string + # case insensivity of DNS labels doesn't apply non-ASCII characters. [RFC 4343] + # This assumes @string is given in ASCII compatible encoding. +@@ -1289,7 +1305,26 @@ def self.create(arg) + when Name + return arg + when String +- return Name.new(Label.split(arg), /\.\z/ =~ arg ? true : false) ++ # A hostname is runtime data rather than a programming mistake, so ++ # both size limits surface as ResolvError to stay rescuable alongside ++ # the rest of name resolution. The type check below is a caller ++ # mistake and keeps raising ArgumentError. ++ begin ++ labels = Label.split(arg) ++ rescue ArgumentError => e ++ raise ResolvError.new(e.message) ++ end ++ # Label::Str enforces the per-label limit. Only the total is knowable ++ # here, and it counts the encoded form, so size starts at 1 for the ++ # root label's terminating zero octet. [RFC 1035 2.3.4, 3.1] ++ size = 1 ++ labels.each do |label| ++ size += 1 + label.string.bytesize ++ if size > 255 ++ raise ResolvError.new("DNS name is too long (#{size} octets, max 255): #{arg.inspect}") ++ end ++ end ++ return Name.new(labels, /\.\z/ =~ arg ? true : false) + else + raise ArgumentError.new("cannot interpret as DNS name: #{arg.inspect}") + end +@@ -1411,12 +1446,24 @@ def ==(other) + @rd == other.rd && + @ra == other.ra && + @rcode == other.rcode && +- @question == other.question && ++ question_equal?(other.question) && + @answer == other.answer && + @authority == other.authority && + @additional == other.additional + end + ++ # A question holds the resource class itself, and decoding creates a fresh ++ # class for each unknown type, so the classes cannot be compared by ++ # identity alone. ++ private def question_equal?(other_question) # :nodoc: ++ return false unless @question.length == other_question.length ++ @question.zip(other_question) {|(name, typeclass), (o_name, o_typeclass)| ++ return false unless name == o_name && ++ Resource::Generic.type_class_equal?(typeclass, o_typeclass) ++ } ++ return true ++ end ++ + def add_question(name, typeclass) + @question << [Name.create(name), typeclass] + end +@@ -1523,8 +1570,15 @@ def put_length16 + end + + def put_string(d) +- self.put_pack("C", d.length) +- @data << d ++ s = d.to_s ++ # A character-string is prefixed by a single length octet, so it can ++ # hold at most 255 octets. [RFC 1035 3.3] Reject anything longer to ++ # avoid silently truncating the length to its low 8 bits (mod 256). ++ if s.bytesize > 255 ++ raise ArgumentError, "character-string is too long (#{s.bytesize} bytes, max 255): #{s.inspect}" ++ end ++ self.put_pack("C", s.bytesize) ++ @data << s + end + + def put_string_list(ds) +@@ -1554,7 +1608,17 @@ def put_labels(d, compress: true) + end + + def put_label(d) +- self.put_string(d.to_s) ++ s = d.to_s ++ # Label::Str applies this limit when a label is built, so what is left ++ # for here is a raw string handed straight to put_labels. The two ways ++ # an over-long label goes wrong differ: 64 to 255 octets write a length ++ # octet in the reserved or compression pointer range, and 256 or more ++ # wrap it mod 256. Either way the encoded name stops being the name the ++ # caller asked for. [RFC 1035 2.3.4, 4.1.4] ++ if s.bytesize > 63 ++ raise ArgumentError, "DNS label is too long (#{s.bytesize} bytes, max 63): #{s.inspect}" ++ end ++ self.put_string(s) + end + end + +@@ -1680,7 +1744,9 @@ def get_labels + prev_index = @index + save_index = nil + d = [] +- size = -1 ++ # size counts the encoded form, so it starts at 1 for the root ++ # label's terminating zero octet. [RFC 1035 3.1] ++ size = 1 + while true + raise DecodeError.new("limit exceeded") if @limit <= @index + case @data.getbyte(@index) +@@ -1711,6 +1777,11 @@ def get_labels + + def get_label + return Label::Str.new(self.get_string) ++ rescue ArgumentError => e ++ # A length octet of 64..191 is reserved rather than a label length, ++ # but this decoder used to read it as one. [RFC 1035 4.1.4] Report it ++ # the way the rest of a malformed message is reported. ++ raise DecodeError.new(e.message) + end + + def get_question +@@ -1898,8 +1969,9 @@ def self.create(key_number) + key_name = :"key#{key_number}" + c.const_set(:KeyName, key_name) + c.const_set(:KeyNumber, key_number) +- self.const_set(:"Key#{key_number}", c) +- ClassHash[key_name] = ClassHash[key_number] = c ++ # Not registered in a constant or in ClassHash. ClassHash creates a ++ # class for every unknown SvcParamKey, so registering them ++ # permanently would let a malicious response exhaust memory. + return c + end + end +@@ -2206,12 +2278,28 @@ def self.decode_rdata(msg) # :nodoc: + return self.new(msg.get_bytes) + end + ++ # create makes a fresh class for each decoded resource, so the type and ++ # class values have to be compared instead of the class itself. ++ def self.type_class_equal?(klass, other) # :nodoc: ++ return true if klass.equal?(other) ++ Generic > klass && Generic > other && ++ klass::TypeValue == other::TypeValue && ++ klass::ClassValue == other::ClassValue ++ end ++ ++ def ==(other) # :nodoc: ++ return other.is_a?(Generic) && ++ Generic.type_class_equal?(self.class, other.class) && ++ @data == other.data ++ end ++ + def self.create(type_value, class_value) # :nodoc: + c = Class.new(Generic) + c.const_set(:TypeValue, type_value) + c.const_set(:ClassValue, class_value) +- Generic.const_set("Type#{type_value}_Class#{class_value}", c) +- ClassHash[[type_value, class_value]] = c ++ # Not registered in a constant or in ClassHash. get_class creates a ++ # class for every unknown (type, class) pair, so registering them ++ # permanently would let a malicious response exhaust memory. + return c + end + end +diff --git a/test/resolv/test_dns.rb b/test/resolv/test_dns.rb +index d5d2648e1bc649..34ae4aeb9a33c3 100644 +--- a/test/resolv/test_dns.rb ++++ b/test/resolv/test_dns.rb +@@ -636,6 +636,205 @@ def test_too_long_address + end + end + ++ # A DNS label is limited to 63 octets. [RFC 1035 2.3.4] Writing a longer label ++ # through the label path must raise instead of overflowing the length octet. ++ def test_put_label_rejects_label_over_63_octets ++ Resolv::DNS::Message::MessageEncoder.new {|msg| ++ assert_nothing_raised { msg.put_label("a" * 63) } ++ assert_raise_with_message(ArgumentError, /DNS label is too long/) do ++ msg.put_label("a" * 64) ++ end ++ } ++ # put_labels drives put_label, so the same guard applies to the name path. ++ Resolv::DNS::Message::MessageEncoder.new {|msg| ++ assert_raise_with_message(ArgumentError, /DNS label is too long/) do ++ msg.put_labels(["a" * 64]) ++ end ++ } ++ end ++ ++ # The per-label limit is an invariant of Label::Str, so no label object can ++ # exist that would overflow its length octet. [RFC 1035 2.3.4] ++ def test_label_str_rejects_label_over_63_octets ++ assert_nothing_raised { Resolv::DNS::Label::Str.new("a" * 63) } ++ assert_raise_with_message(ArgumentError, /DNS label is too long/) do ++ Resolv::DNS::Label::Str.new("a" * 64) ++ end ++ end ++ ++ # Every way of building a name goes through Label::Str, so the paths that ++ # skip Name.create are covered too. ++ def test_label_length_is_enforced_on_every_construction_path ++ assert_raise_with_message(ArgumentError, /DNS label is too long/) do ++ Resolv::DNS::Name.new(["a" * 64]) ++ end ++ assert_raise_with_message(ArgumentError, /DNS label is too long/) do ++ Resolv::DNS::Label.split("a" * 64) ++ end ++ # Config#generate_candidates appends search domains with Name.new, and the ++ # search list itself comes from Label.split, so a resolv.conf carrying an ++ # over-long label is rejected when the config is read. ++ config = Resolv::DNS::Config.new(nameserver: ['127.0.0.1'], ++ search: ["a" * 64], ndots: 1) ++ assert_raise_with_message(ArgumentError, /DNS label is too long/) do ++ config.lazy_initialize ++ end ++ end ++ ++ def test_name_create_rejects_too_long_label ++ assert_nothing_raised { Resolv::DNS::Name.create("a" * 63) } ++ assert_raise_with_message(Resolv::ResolvError, /DNS label is too long/) do ++ Resolv::DNS::Name.create("a" * 64) ++ end ++ end ++ ++ def test_name_create_rejects_too_long_name ++ # Five 63-octet labels total 321 encoded octets, over the 255 octet limit, ++ # while each individual label is still valid. ++ too_long = (["a" * 63] * 5).join(".") ++ assert_raise_with_message(Resolv::ResolvError, /DNS name is too long/) do ++ Resolv::DNS::Name.create(too_long) ++ end ++ end ++ ++ # A hostname is runtime data, so an over-long one has to stay rescuable the ++ # way the rest of name resolution is. It reaches Name.create through ++ # Config#generate_candidates, which runs outside Config#resolv's own rescue. ++ def test_oversized_name_is_rescuable_as_resolv_error ++ dns = Resolv::DNS.new(nameserver_port: [['127.0.0.1', 53]]) ++ assert_raise(Resolv::ResolvError) { dns.getaddress("a" * 64) } ++ assert_raise(Resolv::ResolvError) { dns.getaddress((["a" * 63] * 5).join(".")) } ++ ensure ++ dns&.close ++ end ++ ++ # A length octet of 64..191 is reserved, not a label length, but this decoder ++ # read it as one and accepted labels no encoder should ever produce. ++ # [RFC 1035 4.1.4] Rejecting them has to look like any other malformed ++ # message, so the caller's rescue DecodeError still covers it. ++ def test_decode_rejects_label_over_63_octets ++ message = ->(n) { ++ [0, 0x8180, 1, 0, 0, 0].pack("n*") + ++ [n].pack("C") + ("a" * n) + "\0" + [1, 1].pack("nn") ++ } ++ assert_nothing_raised { Resolv::DNS::Message.decode(message.call(63)) } ++ [64, 100, 191].each do |n| ++ assert_raise_with_message(Resolv::DNS::DecodeError, /DNS label is too long/) do ++ Resolv::DNS::Message.decode(message.call(n)) ++ end ++ end ++ end ++ ++ # The type check is a caller mistake rather than runtime data, so it keeps ++ # raising ArgumentError. ++ def test_name_create_still_raises_argument_error_for_wrong_type ++ assert_raise_with_message(ArgumentError, /cannot interpret as DNS name/) do ++ Resolv::DNS::Name.create(123) ++ end ++ end ++ ++ # The 255 octet limit counts the encoded form, including each label's length ++ # octet and the root label's terminating zero octet. [RFC 1035 2.3.4, 3.1] ++ # So the longest legal name encodes to exactly 255 octets. ++ def test_name_create_total_length_boundary ++ at_limit = (["a" * 63] * 3 + ["a" * 61]).join(".") ++ name = Resolv::DNS::Name.create(at_limit) ++ encoded = Resolv::DNS::Message::MessageEncoder.new {|msg| msg.put_name(name) }.to_s ++ assert_equal(255, encoded.bytesize, "longest legal name encodes to 255 octets") ++ ++ over_limit = (["a" * 63] * 3 + ["a" * 62]).join(".") ++ assert_raise_with_message(Resolv::ResolvError, /DNS name is too long/) do ++ Resolv::DNS::Name.create(over_limit) ++ end ++ ++ # Four 63-octet labels encode to 257 octets. Counting the presentation ++ # form instead of the encoded form lets these two extra octets through. ++ assert_raise_with_message(Resolv::ResolvError, /DNS name is too long/) do ++ Resolv::DNS::Name.create((["a" * 63] * 4).join(".")) ++ end ++ end ++ ++ # The decoder enforces the same limit, counted the same way. ++ def test_get_labels_total_length_boundary ++ encode = ->(labels) { ++ Resolv::DNS::Message::MessageEncoder.new {|msg| ++ msg.put_labels(labels.map {|l| Resolv::DNS::Label::Str.new(l) }) ++ }.to_s ++ } ++ ++ at_limit = encode.call(["a" * 63] * 3 + ["a" * 61]) ++ assert_equal(255, at_limit.bytesize) ++ Resolv::DNS::Message::MessageDecoder.new(at_limit) {|msg| ++ assert_equal(4, msg.get_labels.length) ++ } ++ ++ over_limit = encode.call(["a" * 63] * 4) ++ assert_equal(257, over_limit.bytesize) ++ assert_raise_with_message(Resolv::DNS::DecodeError, /name label data exceed 255 octets/) do ++ Resolv::DNS::Message::MessageDecoder.new(over_limit) {|msg| msg.get_labels } ++ end ++ end ++ ++ # A single 262-octet label whose bytes start with "target\x03com\x00". The ++ # old encoder wrote the length octet as 262 & 0xff == 6, so the wire bytes ++ # decoded to the unrelated name "target.com" (query name confusion / ++ # allowlist bypass). ++ def test_encoder_rejects_label_length_wrap ++ poc_label = "target".b + "\x03com\x00".b + ("a".b * 251) ++ assert_equal(262, poc_label.bytesize) ++ assert_equal(6, poc_label.bytesize & 0xff, "precondition: the length octet wraps to 6") ++ ++ # The bytes the buggy encoder would have emitted really do decode to a ++ # different name. This is the vulnerability being fixed. ++ wrapped = [poc_label.bytesize & 0xff].pack("C") + poc_label ++ Resolv::DNS::Message::MessageDecoder.new(wrapped) {|msg| ++ assert_equal("target.com", msg.get_labels.map(&:to_s).join(".")) ++ } ++ ++ # The fixed encoder refuses to emit it instead of silently wrapping, so it ++ # can no longer produce "target.com" from this input. ++ Resolv::DNS::Message::MessageEncoder.new {|msg| ++ assert_raise_with_message(ArgumentError, /DNS label is too long/) do ++ msg.put_label(poc_label) ++ end ++ } ++ assert_raise_with_message(Resolv::ResolvError, /DNS label is too long/) do ++ Resolv::DNS::Name.create(poc_label) ++ end ++ end ++ ++ # A character-string (e.g. TXT rdata) is prefixed by a single length octet and ++ # may legitimately be up to 255 octets, so the 63 octet label limit must not ++ # leak into put_string. [RFC 1035 3.3] ++ def test_put_string_allows_character_string_up_to_255 ++ [64, 200, 255].each do |n| ++ s = "a" * n ++ m = Resolv::DNS::Message::MessageEncoder.new {|msg| msg.put_string(s) } ++ encoded = m.to_s ++ assert_equal(n, encoded.getbyte(0), "length octet for #{n} byte string") ++ assert_equal(n + 1, encoded.bytesize) ++ Resolv::DNS::Message::MessageDecoder.new(encoded) {|msg| ++ assert_equal(s, msg.get_string) ++ } ++ end ++ end ++ ++ def test_txt_record_roundtrip_with_long_character_strings ++ txt = Resolv::DNS::Resource::IN::TXT.new("a" * 255, "b" * 64) ++ m = Resolv::DNS::Message.new(0) ++ m.add_answer("example.com.", 3600, txt) ++ decoded = Resolv::DNS::Message.decode(m.encode) ++ _, _, res = decoded.answer.first ++ assert_equal(["a" * 255, "b" * 64], res.strings) ++ end ++ ++ # put_string still guards against the length octet wrapping past 255 octets. ++ def test_put_string_rejects_over_255_octets ++ assert_raise_with_message(ArgumentError, /character-string is too long/) do ++ Resolv::DNS::Message::MessageEncoder.new {|msg| msg.put_string("a" * 256) } ++ end ++ end ++ + def assert_no_fd_leak + socket = assert_throw(self) do |tag| + Resolv::DNS.stub(:bind_random_port, ->(s, *) {throw(tag, s)}) do +@@ -822,4 +1021,124 @@ def test_multiple_servers_with_timeout_and_truncated_tcp_fallback + end + end + end ++ ++ def test_tcp_connection_closed_before_length ++ with_tcp('127.0.0.1', 0) do |t| ++ _, server_port, _, server_address = t.addr ++ ++ server_thread = Thread.new do ++ ct = t.accept ++ ct.recv(512) ++ ct.close ++ end ++ ++ client_thread = Thread.new do ++ requester = Resolv::DNS::Requester::TCP.new(server_address, server_port) ++ begin ++ msg = Resolv::DNS::Message.new ++ msg.add_question('example.org', Resolv::DNS::Resource::IN::A) ++ sender = requester.sender(msg, msg) ++ assert_raise(Resolv::ResolvTimeout) do ++ requester.request(sender, 2) ++ end ++ ensure ++ requester.close ++ end ++ end ++ ++ server_thread.join ++ client_thread.join ++ end ++ end ++ ++ def test_tcp_connection_closed_after_length ++ with_tcp('127.0.0.1', 0) do |t| ++ _, server_port, _, server_address = t.addr ++ ++ server_thread = Thread.new do ++ ct = t.accept ++ ct.recv(512) ++ ct.send([100].pack('n'), 0) ++ ct.close ++ end ++ ++ client_thread = Thread.new do ++ requester = Resolv::DNS::Requester::TCP.new(server_address, server_port) ++ begin ++ msg = Resolv::DNS::Message.new ++ msg.add_question('example.org', Resolv::DNS::Resource::IN::A) ++ sender = requester.sender(msg, msg) ++ assert_raise(Resolv::ResolvTimeout) do ++ requester.request(sender, 2) ++ end ++ ensure ++ requester.close ++ end ++ end ++ ++ server_thread.join ++ client_thread.join ++ end ++ end ++ ++ def test_tcp_connection_closed_with_partial_length_prefix ++ with_tcp('127.0.0.1', 0) do |t| ++ _, server_port, _, server_address = t.addr ++ ++ server_thread = Thread.new do ++ ct = t.accept ++ ct.recv(512) ++ ct.write "A" # 1 byte ++ ct.close ++ end ++ ++ client_thread = Thread.new do ++ requester = Resolv::DNS::Requester::TCP.new(server_address, server_port) ++ begin ++ msg = Resolv::DNS::Message.new ++ msg.add_question('example.org', Resolv::DNS::Resource::IN::A) ++ sender = requester.sender(msg, msg) ++ assert_raise(Resolv::ResolvTimeout) do ++ requester.request(sender, 2) ++ end ++ ensure ++ requester.close ++ end ++ end ++ ++ server_thread.join ++ client_thread.join ++ end ++ end ++ ++ def test_tcp_connection_closed_with_partial_message_body ++ with_tcp('127.0.0.1', 0) do |t| ++ _, server_port, _, server_address = t.addr ++ ++ server_thread = Thread.new do ++ ct = t.accept ++ ct.recv(512) ++ ct.write([10].pack('n')) # length 10 ++ ct.write "12345" # 5 bytes (partial) ++ ct.close ++ end ++ ++ client_thread = Thread.new do ++ requester = Resolv::DNS::Requester::TCP.new(server_address, server_port) ++ begin ++ msg = Resolv::DNS::Message.new ++ msg.add_question('example.org', Resolv::DNS::Resource::IN::A) ++ sender = requester.sender(msg, msg) ++ assert_raise(Resolv::ResolvTimeout) do ++ requester.request(sender, 2) ++ end ++ ensure ++ requester.close ++ end ++ end ++ ++ server_thread.join ++ client_thread.join ++ end ++ end + end +diff --git a/test/resolv/test_resource.rb b/test/resolv/test_resource.rb +index 434380236e6721..b4e56ce4bb8ff5 100644 +--- a/test/resolv/test_resource.rb ++++ b/test/resolv/test_resource.rb +@@ -24,6 +24,67 @@ def test_coord + Resolv::LOC::Coord.create('1 2 1.1 N') + end + ++ # Decoding an unknown (type, class) pair builds a fresh class every time, so ++ # equality must not rest on the class identity. ++ def test_generic_equality ++ wire = generic_answer(40000, "\x01\x02\x03") ++ rr1 = decode_generic(wire) ++ rr2 = decode_generic(wire) ++ ++ assert_not_same rr1.class, rr2.class ++ assert_equal rr1, rr2 ++ assert rr1.eql?(rr2) ++ assert_equal rr1.hash, rr2.hash ++ assert_equal Resolv::DNS::Message.decode(wire), Resolv::DNS::Message.decode(wire) ++ end ++ ++ # Any descendant counts, not just a class create returned. ++ def test_generic_equality_between_descendants ++ generic = Resolv::DNS::Resource::Generic ++ direct = generic.create(40000, 60000) ++ descendant = Class.new(generic.create(40000, 60000)) ++ ++ assert_equal direct.new("\x01\x02\x03"), descendant.new("\x01\x02\x03") ++ assert_equal descendant.new("\x01\x02\x03"), direct.new("\x01\x02\x03") ++ assert_equal generic.new("\x01\x02\x03"), generic.new("\x01\x02\x03") ++ assert_not_equal direct.new("\x01\x02\x03"), ++ Class.new(generic.create(40001, 60000)).new("\x01\x02\x03") ++ end ++ ++ def test_generic_inequality ++ rr = decode_generic(generic_answer(40000, "\x01\x02\x03")) ++ ++ assert_not_equal rr, decode_generic(generic_answer(40001, "\x01\x02\x03")) ++ assert_not_equal rr, decode_generic(generic_answer(40000, "\x09\x09\x09")) ++ assert_not_equal rr, Resolv::DNS::Resource::IN::A.new("192.168.0.1") ++ end ++ ++ # A question holds the resource class itself, so it needs the same treatment. ++ def test_generic_question_equality ++ wire = generic_question(40000) ++ ++ assert_equal Resolv::DNS::Message.decode(wire), Resolv::DNS::Message.decode(wire) ++ assert_not_equal Resolv::DNS::Message.decode(wire), ++ Resolv::DNS::Message.decode(generic_question(40001)) ++ end ++ ++ private def header(qdcount, ancount) ++ "\x00\x00\x00\x00".b + [qdcount, ancount, 0, 0].pack('nnnn') ++ end ++ ++ private def generic_answer(type, rdata) ++ rdata = rdata.b ++ (header(0, 1) + "\x00".b + [type, 60000, 0, rdata.bytesize].pack('nnNn') + rdata).b ++ end ++ ++ private def generic_question(type) ++ (header(1, 0) + "\x07example\x03com\x00".b + [type, 60000].pack('nn')).b ++ end ++ ++ private def decode_generic(wire) ++ Resolv::DNS::Message.decode(wire).answer.first[2] ++ end ++ + def test_srv_no_compress + # Domain name in SRV RDATA should not be compressed + issue29 = 'https://github.com/ruby/resolv/issues/29' +diff --git a/test/resolv/test_resource_leak.rb b/test/resolv/test_resource_leak.rb +new file mode 100644 +index 00000000000000..4628a3d2857677 +--- /dev/null ++++ b/test/resolv/test_resource_leak.rb +@@ -0,0 +1,101 @@ ++# frozen_string_literal: false ++require 'test/unit' ++require 'resolv' ++ ++# Decoding a response with unknown (type, class) pairs or unknown SvcParamKeys ++# used to register a generated class permanently, so a malicious response could ++# exhaust memory even after the response was discarded. ++class TestResolvResourceLeak < Test::Unit::TestCase ++ # Number of dynamically-registered "Type_Class" constants on +mod+. ++ def type_const_count(mod) ++ mod.constants(false).count { |c| c.to_s.match?(/\AType\d+_Class\d+\z/) } ++ end ++ ++ def svcparam_key_const_count ++ Resolv::DNS::SvcParam::Generic.constants(false).count { |c| c.to_s.match?(/\AKey\d+\z/) } ++ end ++ ++ # A DNS response whose answer section holds +count+ RRs, each with a distinct ++ # unknown (type, class) pair. ++ def unknown_typeclass_response(count) ++ body = "".b ++ count.times do |i| ++ type = 40000 + i ++ klass = 60000 ++ rdata = "\x01\x02\x03".b ++ body << "\x00".b # NAME = root ++ body << [type, klass, 0, rdata.bytesize].pack('nnNn') ++ body << rdata ++ end ++ header = "\x00\x00\x00\x00".b + [0, count, 0, 0].pack('nnnn') ++ (header + body).b ++ end ++ ++ # An SVCB RR (type 64) carrying +count+ distinct unknown SvcParamKeys. ++ def unknown_svcparam_response(count) ++ rdata = "".b ++ rdata << [1].pack('n') # SvcPriority ++ rdata << "\x03foo\x07example\x03com\x00".b # TargetName ++ count.times do |i| ++ key = 1000 + i ++ val = "x".b ++ rdata << [key, val.bytesize].pack('nn') << val ++ end ++ header = "\x00\x00\x00\x00".b + [0, 1, 0, 0].pack('nnnn') ++ name = "\x07example\x03com\x00".b ++ rr = name + [64, 1, 0, rdata.bytesize].pack('nnNn') + rdata ++ (header + rr).b ++ end ++ ++ def test_unknown_typeclass_does_not_leak_classes ++ resource = Resolv::DNS::Resource ++ generic = Resolv::DNS::Resource::Generic ++ ++ before_resource = type_const_count(resource) ++ before_generic = type_const_count(generic) ++ ++ [100, 1000].each do |count| ++ msg = unknown_typeclass_response(count) ++ 3.times { Resolv::DNS::Message.decode(msg) } ++ end ++ GC.start ++ ++ assert_equal before_resource, type_const_count(resource), ++ 'decoding unknown (type, class) RRs must not register new Resource constants' ++ assert_equal before_generic, type_const_count(generic), ++ 'decoding unknown (type, class) RRs must not register new Generic constants' ++ end ++ ++ def test_unknown_svcparam_key_does_not_leak_classes ++ class_hash = Resolv::DNS::SvcParam::ClassHash ++ ++ before_consts = svcparam_key_const_count ++ before_hash = class_hash.size ++ ++ [100, 1000].each do |count| ++ msg = unknown_svcparam_response(count) ++ 3.times { Resolv::DNS::Message.decode(msg) } ++ end ++ GC.start ++ ++ assert_equal before_consts, svcparam_key_const_count, ++ 'decoding unknown SvcParamKeys must not register new Generic constants' ++ assert_equal before_hash, class_hash.size, ++ 'decoding unknown SvcParamKeys must not grow SvcParam::ClassHash' ++ end ++ ++ # Dropping the permanent registration must not break decoding of the unknown ++ # values themselves. ++ def test_unknown_values_still_decode ++ msg = Resolv::DNS::Message.decode(unknown_typeclass_response(3)) ++ assert_equal 3, msg.answer.size ++ _, _, rr = msg.answer.first ++ assert_kind_of Resolv::DNS::Resource::Generic, rr ++ assert_equal "\x01\x02\x03".b, rr.data ++ ++ msg = Resolv::DNS::Message.decode(unknown_svcparam_response(3)) ++ _, _, svcb = msg.answer.first ++ assert_equal 3, svcb.params.count ++ assert_equal "x".b, svcb.params[:key1000].value ++ end ++end +--- ruby-4.0.6.orig/lib/rubygems/vendor/resolv/lib/resolv.rb 2026-07-14 09:22:10.000000000 +0900 ++++ ruby-4.0.6/lib/rubygems/vendor/resolv/lib/resolv.rb 2026-09-03 17:16:18.543025164 +0900 +@@ -35,7 +35,7 @@ + class Gem::Resolv + + # The version string +- VERSION = "0.7.0" ++ VERSION = "0.7.2" + + ## + # Looks up the first IP address for +name+. +@@ -487,13 +487,18 @@ def each_name(address) + # * Gem::Resolv::DNS::Resource::IN::A + # * Gem::Resolv::DNS::Resource::IN::AAAA + # * Gem::Resolv::DNS::Resource::IN::ANY ++ # * Gem::Resolv::DNS::Resource::IN::CAA + # * Gem::Resolv::DNS::Resource::IN::CNAME + # * Gem::Resolv::DNS::Resource::IN::HINFO ++ # * Gem::Resolv::DNS::Resource::IN::HTTPS ++ # * Gem::Resolv::DNS::Resource::IN::LOC + # * Gem::Resolv::DNS::Resource::IN::MINFO + # * Gem::Resolv::DNS::Resource::IN::MX + # * Gem::Resolv::DNS::Resource::IN::NS + # * Gem::Resolv::DNS::Resource::IN::PTR + # * Gem::Resolv::DNS::Resource::IN::SOA ++ # * Gem::Resolv::DNS::Resource::IN::SRV ++ # * Gem::Resolv::DNS::Resource::IN::SVCB + # * Gem::Resolv::DNS::Resource::IN::TXT + # * Gem::Resolv::DNS::Resource::IN::WKS + # +@@ -721,7 +726,8 @@ def request(sender, tout) + begin + reply, from = recv_reply(select_result[0]) + rescue Errno::ECONNREFUSED, # GNU/Linux, FreeBSD +- Errno::ECONNRESET # Windows ++ Errno::ECONNRESET, # Windows ++ EOFError + # No name server running on the server? + # Don't wait anymore. + raise ResolvTimeout +@@ -930,8 +936,11 @@ def initialize(host, port=Port) + end + + def recv_reply(readable_socks) +- len = readable_socks[0].read(2).unpack('n')[0] ++ len_data = readable_socks[0].read(2) ++ raise EOFError if len_data.nil? || len_data.bytesize != 2 ++ len = len_data.unpack('n')[0] + reply = @socks[0].read(len) ++ raise EOFError if reply.nil? || reply.bytesize != len + return reply, nil + end + +@@ -1244,6 +1253,13 @@ def self.split(arg) + + class Str # :nodoc: + def initialize(string) ++ # A label is limited to 63 octets. [RFC 1035 2.3.4] Checking it here ++ # makes it an invariant of the object: every label, however it was ++ # built, fits in its length octet and cannot wrap it. Callers turn ++ # this into the error their own contract promises. ++ if string.bytesize > 63 ++ raise ArgumentError, "DNS label is too long (#{string.bytesize} bytes, max 63): #{string.inspect}" ++ end + @string = string + # case insensivity of DNS labels doesn't apply non-ASCII characters. [RFC 4343] + # This assumes @string is given in ASCII compatible encoding. +@@ -1289,7 +1305,26 @@ def self.create(arg) + when Name + return arg + when String +- return Name.new(Label.split(arg), /\.\z/ =~ arg ? true : false) ++ # A hostname is runtime data rather than a programming mistake, so ++ # both size limits surface as ResolvError to stay rescuable alongside ++ # the rest of name resolution. The type check below is a caller ++ # mistake and keeps raising ArgumentError. ++ begin ++ labels = Label.split(arg) ++ rescue ArgumentError => e ++ raise ResolvError.new(e.message) ++ end ++ # Label::Str enforces the per-label limit. Only the total is knowable ++ # here, and it counts the encoded form, so size starts at 1 for the ++ # root label's terminating zero octet. [RFC 1035 2.3.4, 3.1] ++ size = 1 ++ labels.each do |label| ++ size += 1 + label.string.bytesize ++ if size > 255 ++ raise ResolvError.new("DNS name is too long (#{size} octets, max 255): #{arg.inspect}") ++ end ++ end ++ return Name.new(labels, /\.\z/ =~ arg ? true : false) + else + raise ArgumentError.new("cannot interpret as DNS name: #{arg.inspect}") + end +@@ -1411,12 +1446,24 @@ def ==(other) + @rd == other.rd && + @ra == other.ra && + @rcode == other.rcode && +- @question == other.question && ++ question_equal?(other.question) && + @answer == other.answer && + @authority == other.authority && + @additional == other.additional + end + ++ # A question holds the resource class itself, and decoding creates a fresh ++ # class for each unknown type, so the classes cannot be compared by ++ # identity alone. ++ private def question_equal?(other_question) # :nodoc: ++ return false unless @question.length == other_question.length ++ @question.zip(other_question) {|(name, typeclass), (o_name, o_typeclass)| ++ return false unless name == o_name && ++ Resource::Generic.type_class_equal?(typeclass, o_typeclass) ++ } ++ return true ++ end ++ + def add_question(name, typeclass) + @question << [Name.create(name), typeclass] + end +@@ -1523,8 +1570,15 @@ def put_length16 + end + + def put_string(d) +- self.put_pack("C", d.length) +- @data << d ++ s = d.to_s ++ # A character-string is prefixed by a single length octet, so it can ++ # hold at most 255 octets. [RFC 1035 3.3] Reject anything longer to ++ # avoid silently truncating the length to its low 8 bits (mod 256). ++ if s.bytesize > 255 ++ raise ArgumentError, "character-string is too long (#{s.bytesize} bytes, max 255): #{s.inspect}" ++ end ++ self.put_pack("C", s.bytesize) ++ @data << s + end + + def put_string_list(ds) +@@ -1554,7 +1608,17 @@ def put_labels(d, compress: true) + end + + def put_label(d) +- self.put_string(d.to_s) ++ s = d.to_s ++ # Label::Str applies this limit when a label is built, so what is left ++ # for here is a raw string handed straight to put_labels. The two ways ++ # an over-long label goes wrong differ: 64 to 255 octets write a length ++ # octet in the reserved or compression pointer range, and 256 or more ++ # wrap it mod 256. Either way the encoded name stops being the name the ++ # caller asked for. [RFC 1035 2.3.4, 4.1.4] ++ if s.bytesize > 63 ++ raise ArgumentError, "DNS label is too long (#{s.bytesize} bytes, max 63): #{s.inspect}" ++ end ++ self.put_string(s) + end + end + +@@ -1680,7 +1744,9 @@ def get_labels + prev_index = @index + save_index = nil + d = [] +- size = -1 ++ # size counts the encoded form, so it starts at 1 for the root ++ # label's terminating zero octet. [RFC 1035 3.1] ++ size = 1 + while true + raise DecodeError.new("limit exceeded") if @limit <= @index + case @data.getbyte(@index) +@@ -1711,6 +1777,11 @@ def get_labels + + def get_label + return Label::Str.new(self.get_string) ++ rescue ArgumentError => e ++ # A length octet of 64..191 is reserved rather than a label length, ++ # but this decoder used to read it as one. [RFC 1035 4.1.4] Report it ++ # the way the rest of a malformed message is reported. ++ raise DecodeError.new(e.message) + end + + def get_question +@@ -1898,8 +1969,9 @@ def self.create(key_number) + key_name = :"key#{key_number}" + c.const_set(:KeyName, key_name) + c.const_set(:KeyNumber, key_number) +- self.const_set(:"Key#{key_number}", c) +- ClassHash[key_name] = ClassHash[key_number] = c ++ # Not registered in a constant or in ClassHash. ClassHash creates a ++ # class for every unknown SvcParamKey, so registering them ++ # permanently would let a malicious response exhaust memory. + return c + end + end +@@ -2206,12 +2278,28 @@ def self.decode_rdata(msg) # :nodoc: + return self.new(msg.get_bytes) + end + ++ # create makes a fresh class for each decoded resource, so the type and ++ # class values have to be compared instead of the class itself. ++ def self.type_class_equal?(klass, other) # :nodoc: ++ return true if klass.equal?(other) ++ Generic > klass && Generic > other && ++ klass::TypeValue == other::TypeValue && ++ klass::ClassValue == other::ClassValue ++ end ++ ++ def ==(other) # :nodoc: ++ return other.is_a?(Generic) && ++ Generic.type_class_equal?(self.class, other.class) && ++ @data == other.data ++ end ++ + def self.create(type_value, class_value) # :nodoc: + c = Class.new(Generic) + c.const_set(:TypeValue, type_value) + c.const_set(:ClassValue, class_value) +- Generic.const_set("Type#{type_value}_Class#{class_value}", c) +- ClassHash[[type_value, class_value]] = c ++ # Not registered in a constant or in ClassHash. get_class creates a ++ # class for every unknown (type, class) pair, so registering them ++ # permanently would let a malicious response exhaust memory. + return c + end + end + diff --git a/ruby.spec b/ruby.spec index 519f90d..a2b36e1 100644 --- a/ruby.spec +++ b/ruby.spec @@ -41,7 +41,7 @@ %global rubygems_net_http_version 0.7.0 %global rubygems_net_protocol_version 0.2.2 %global rubygems_optparse_version 0.8.0 -%global rubygems_resolv_version 0.7.0 +%global rubygems_resolv_version 0.7.2 %global rubygems_securerandom_version 0.4.1 %global rubygems_timeout_version 0.4.4 %global rubygems_tsort_version 0.2.0 @@ -84,7 +84,7 @@ %global prettyprint_version 0.2.0 %global prism_version 1.8.1 %global psych_version 5.3.1 -%global resolv_version 0.7.0 +%global resolv_version 0.7.2 %global ruby2_keywords_version 0.0.5 %global securerandom_version 0.4.1 %global shellwords_version 0.2.2 @@ -189,7 +189,7 @@ Summary: An interpreter of object-oriented scripting language Name: ruby Version: %{ruby_version}%{?development_release} -Release: 37%{?dist} +Release: 38%{?dist} # Licenses, which are likely not included in binary RPMs: # Apache-2.0: # benchmark/gc/redblack.rb @@ -298,6 +298,10 @@ Patch8: ruby-4.0.1-Support-customizable-rustc_flags-for-rustc-builds.patch # Fix error with `gem install --document=rdoc,ri` # Fixed in rdoc 7.1.0 but not in 7.0.4 Patch9: rdoc-pr1531-fix-mutilple-document-installation.patch +# Backport from ruby_4_0 branch to update resolv to 0.7.2 (fixes CVE-2026-80212 CVE-2026-80213) +# https://github.com/ruby/ruby/pull/18528 +# Also copied the patch to apply the fix also for vendored resolv +Patch10: ruby-4_0-pr18528-update-resolv-0_7_2.patch Requires: %{name}-libs%{?_isa} = %{version}-%{release} %{?with_rubypick:Suggests: rubypick} @@ -808,6 +812,7 @@ popd %patch 6 -p1 %patch 7 -p1 %patch 8 -p1 +%patch 10 -p1 # Provide an example of usage of the tapset: cp -a %{SOURCE3} . @@ -1947,7 +1952,12 @@ make -C %{_vpath_builddir} runruby TESTRUN_SCRIPT=" \ %changelog -* Thu Jul 16 2026 Fedora Release Engineering +* Thu Sep 03 2026 Mamoru TASAKA - 4.0.6-38 +- Backport upstream patch to update resolv to 0.7.2 +- Resolves: CVE-2026-80212 (rhbz#2527308) +- Resolves: CVE-2026-80213 (rhbz#2527310) + +* Thu Jul 16 2026 Fedora Release Engineering - 4.0.6-37 - Rebuilt for https://fedoraproject.org/wiki/Fedora_45_Mass_Rebuild * Tue Jul 14 2026 Mamoru TASAKA - 4.0.6-36