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(-)
@@ -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
@@ -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