From 23f000967da6daed15bc93b5e6e6fe644bd82f2f Mon Sep 17 00:00:00 2001
From: Hiroshi SHIBATA <hsbt@ruby-lang.org>
Date: Wed, 26 Aug 2026 16:03:50 +0900
Subject: [PATCH] Do not register on-demand classes for unknown DNS types and
 SvcParamKeys

Decoding an unknown (type, class) pair or an unknown SvcParamKey
registered the generated class in a constant and in ClassHash
permanently, so a malicious response could exhaust memory.

Dropping the registration means a fresh class per decode, which breaks
the class identity that Resource#== and Message#== relied on, so both now
compare through Resource::Generic.type_class_equal?.

Fixes CVE-2026-80212.

Reference:https://github.com/ruby/resolv/commit/23f000967da6daed15bc93b5e6e6fe644bd82f2f
Conflict:(1)don't change def self.create(key_number) function, This function was introduced in
https://github.com/ruby/resolv/commit/b3ced7f039
(2)don't addd test/resolv/test_resource_leak.rb file, it tests SvcParamKeys.
---
 lib/resolv.rb                     |  34 +++++++++-
 test/resolv/test_resource.rb      |  61 ++++++++++++++++++
 2 files changed, 92 insertions(+), 3 deletions(-)

diff --git a/lib/resolv.rb b/lib/resolv.rb
index 6764c2c2ac4f..3bf948ee1521 100644
--- a/lib/resolv.rb
+++ b/lib/resolv.rb
@@ -1372,12 +1372,24 @@ class Resolv
                @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
@@ -1777,12 +1789,28 @@ class Resolv
           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_resource.rb b/test/resolv/test_resource.rb
index b75cf67f3134..44e1bc2fab76 100644
--- a/test/resolv/test_resource.rb
+++ b/test/resolv/test_resource.rb
@@ -23,4 +23,65 @@ class TestResolvResource < Test::Unit::TestCase
   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
 end
-- 
2.43.0