From b0ddbb522cd76e58d965294dfb68133add52770c Mon Sep 17 00:00:00 2001
From: Hiroshi SHIBATA <hsbt@ruby-lang.org>
Date: Wed, 26 Aug 2026 16:02:44 +0900
Subject: [PATCH] Enforce DNS label and name size limits when encoding

put_string packed the length into a single octet with no range check, so
a label longer than 255 octets wrapped mod 256 while the data was written
unchanged, and the encoded name decoded to a different name than the
caller asked for.

Label::Str now rejects a label over 63 octets, Name.create counts the
encoded form against the 255 octet name limit, and a length octet outside
the label range is reported as DecodeError.

Fixes CVE-2026-80213.

Reference:https://github.com/ruby/resolv/commit/b0ddbb522cd76e58d965294dfb68133add52770c
Conflict:NA
---
 lib/resolv.rb           |  60 +++++++++++-
 test/resolv/test_dns.rb | 199 ++++++++++++++++++++++++++++++++++++++++
 2 files changed, 254 insertions(+), 5 deletions(-)

diff --git a/lib/resolv.rb b/lib/resolv.rb
index 3bf948ee1521..d8feb4f6791a 100644
--- a/lib/resolv.rb
+++ b/lib/resolv.rb
@@ -1205,6 +1205,13 @@ class Resolv
 
       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.
@@ -1250,7 +1257,26 @@ class Resolv
         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
@@ -1496,8 +1522,15 @@ class Resolv
         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)
@@ -1527,7 +1560,17 @@ class Resolv
         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
 
@@ -1643,7 +1686,9 @@ class Resolv
           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)
@@ -1674,6 +1719,11 @@ class Resolv
 
         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
diff --git a/test/resolv/test_dns.rb b/test/resolv/test_dns.rb
index 4cd8a1b580f8..26b4e61b3832 100644
--- a/test/resolv/test_dns.rb
+++ b/test/resolv/test_dns.rb
@@ -255,6 +255,205 @@ class TestResolvDNS < Test::Unit::TestCase
     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
-- 
2.43.0