From e51834f7fc33b10aee082c4614dc4782b191f4da Mon Sep 17 00:00:00 2001 From: Alfred Wingate Date: Mon, 28 Sep 2026 05:57:49 +0300 Subject: [PATCH] Update resolv to 0.7.2 """ No release is planned for the 0.2.x line that the Ruby 3.2 series ships, because that series has reached its end of life. Install resolv 0.7.2 there instead. """ See-Also: https://www.ruby-lang.org/en/news/2026/08/27/multiple-vulnerabilities-in-resolv/ Signed-off-by: Alfred Wingate --- a/ext/win32/resolv/extconf.rb +++ b/ext/win32/resolv/extconf.rb @@ -1,3 +1,7 @@ -if have_library('iphlpapi', 'GetNetworkParams') +require 'mkmf' +if RUBY_ENGINE == "ruby" and have_library('iphlpapi', 'GetNetworkParams', ['windows.h', 'iphlpapi.h']) + have_library('advapi32', 'RegGetValueW', ['windows.h']) create_makefile('win32/resolv') +else + File.write('Makefile', "all clean install:\n\t@echo Done: $(@)\n") end --- /dev/null +++ b/ext/win32/resolv/lib/resolv.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: false +=begin += Win32 DNS and DHCP I/F + +=end + +require 'win32/resolv.so' + +module Win32 + module Resolv + # Error at Win32 API + class Error < StandardError + # +code+ Win32 Error code + # +message+ Formatted message for +code+ + def initialize(code, message) + super(message) + @code = code + end + + # Win32 error code + attr_reader :code + end + + def self.get_hosts_path + path = get_hosts_dir + path = File.expand_path('hosts', path) + File.exist?(path) ? path : nil + end + + def self.get_resolv_info + search, nameserver = get_info + if search.empty? + search = nil + else + search.delete("") + search.uniq! + end + if nameserver.empty? + nameserver = nil + else + nameserver.delete("") + nameserver.delete("0.0.0.0") + nameserver.uniq! + end + [ search, nameserver ] + end + + class << self + private + def get_hosts_dir + tcpip_params do |params| + params.value('DataBasePath') + end + end + + def get_info + search = nil + nameserver = get_dns_server_list + + tcpip_params do |params| + slist = params.value('SearchList') + search = slist.split(/,\s*/) if slist and !slist.empty? + + if add_search = search.nil? + search = [] + domain = params.value('Domain') + + if domain and !domain.empty? + search = [ domain ] + udmnd = params.value('UseDomainNameDevolution') + if udmnd&.nonzero? + if /^\w+\./ =~ domain + devo = $' + end + end + end + end + + params.open('Interfaces') do |reg| + reg.each_key do |iface| + next unless ns = %w[NameServer DhcpNameServer].find do |key| + ns = iface.value(key) + break ns.split(/[,\s]\s*/) if ns and !ns.empty? + end + + next if (nameserver & ns).empty? + + if add_search + [ 'Domain', 'DhcpDomain' ].each do |key| + dom = iface.value(key) + if dom and !dom.empty? + search.concat(dom.split(/,\s*/)) + break + end + end + end + end + end + + search << devo if add_search and devo + end + [ search.uniq, nameserver.uniq ] + end + end + end +end --- a/ext/win32/resolv/resolv.c +++ b/ext/win32/resolv/resolv.c @@ -1,22 +1,56 @@ #include #include #include +#include #ifndef NTDDI_VERSION #define NTDDI_VERSION 0x06000000 #endif #include +#ifndef numberof +#define numberof(array) ((int)(sizeof(array) / sizeof((array)[0]))) +#endif + static VALUE w32error_make_error(DWORD e) { - VALUE code = ULONG2NUM(e); - return rb_class_new_instance(1, &code, rb_path2class("Win32::Resolv::Error")); + char buffer[512], *p; + DWORD source = 0; + VALUE args[2]; + if (!FormatMessage(FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_IGNORE_INSERTS, &source, e, + MAKELANGID(LANG_ENGLISH, SUBLANG_ENGLISH_US), + buffer, sizeof(buffer), NULL)) { + snprintf(buffer, sizeof(buffer), "Unknown Error %lu", (unsigned long)e); + } + p = buffer; + while ((p = strpbrk(p, "\r\n")) != NULL) { + memmove(p, p + 1, strlen(p)); + if (!p[1]) { + p[0] = '\0'; + break; + } + } + args[0] = ULONG2NUM(e); + args[1] = rb_str_new_cstr(buffer); + return rb_class_new_instance(2, args, rb_path2class("Win32::Resolv::Error")); } static void -w32error_raise(DWORD e) +w32error_check(DWORD e) +{ + if (e != NO_ERROR) { + rb_exc_raise(w32error_make_error(e)); + } +} + +static VALUE +wchar_to_utf8(const WCHAR *w, int n) { - rb_exc_raise(w32error_make_error(e)); + int clen = WideCharToMultiByte(CP_UTF8, 0, w, n, NULL, 0, NULL, NULL); + VALUE str = rb_enc_str_new(NULL, clen, rb_utf8_encoding()); + WideCharToMultiByte(CP_UTF8, 0, w, n, RSTRING_PTR(str), clen, NULL, NULL); + return str; } static VALUE @@ -28,9 +62,7 @@ get_dns_server_list(VALUE self) VALUE buf, nameservers = Qnil; ret = GetNetworkParams(NULL, &buflen); - if (ret != NO_ERROR && ret != ERROR_BUFFER_OVERFLOW) { - w32error_raise(ret); - } + if (ret != ERROR_BUFFER_OVERFLOW) w32error_check(ret); fixedinfo = ALLOCV(buf, buflen); ret = GetNetworkParams(fixedinfo, &buflen); if (ret == NO_ERROR) { @@ -44,18 +76,181 @@ get_dns_server_list(VALUE self) } while ((ipaddr = ipaddr->Next) != NULL); } ALLOCV_END(buf); - if (ret != NO_ERROR) w32error_raise(ret); + w32error_check(ret); return nameservers; } + +static const WCHAR TCPIP_Params[] = L"SYSTEM\\CurrentControlSet\\Services\\Tcpip\\Parameters"; + +static void +hkey_finalize(void *p) +{ + RegCloseKey((HKEY)p); +} + +static const rb_data_type_t hkey_type = { + "RegKey", + {0, hkey_finalize}, + 0, 0, RUBY_TYPED_FREE_IMMEDIATELY, +}; + +static VALUE +hkey_close(VALUE self) +{ + RegCloseKey((HKEY)DATA_PTR(self)); + DATA_PTR(self) = 0; + return self; +} + +static VALUE reg_key_class; + +static VALUE +reg_open_key(VALUE klass, HKEY hkey, const WCHAR *wname) +{ + VALUE k = TypedData_Wrap_Struct(klass, &hkey_type, NULL); + DWORD e = RegOpenKeyExW(hkey, wname, 0, KEY_READ, (HKEY *)&DATA_PTR(k)); + if (e == ERROR_FILE_NOT_FOUND) return Qnil; + w32error_check(e); + return rb_ensure(rb_yield, k, hkey_close, k); +} + +static VALUE +tcpip_params_open(VALUE klass) +{ + return reg_open_key(reg_key_class, HKEY_LOCAL_MACHINE, TCPIP_Params); +} + +static int +to_wname(VALUE *name, WCHAR *wname, int wlen) +{ + const char *n = StringValueCStr(*name); + int nlen = RSTRING_LEN(*name); + int len = MultiByteToWideChar(CP_UTF8, 0, n, nlen, wname, wlen - 1); + if (len == 0) w32error_check(GetLastError()); + if (len >= wlen) rb_raise(rb_eArgError, "too long name"); + wname[len] = L'\0'; + return len; +} + +static VALUE +reg_open(VALUE self, VALUE name) +{ + HKEY hkey = DATA_PTR(self); + WCHAR wname[256]; + to_wname(&name, wname, numberof(wname)); + return reg_open_key(CLASS_OF(self), hkey, wname); +} + + +static VALUE +reg_each_key(VALUE self) +{ + WCHAR wname[256]; + HKEY hkey = DATA_PTR(self); + VALUE k = TypedData_Wrap_Struct(CLASS_OF(self), &hkey_type, NULL); + DWORD i, e, n; + for (i = 0; n = numberof(wname), (e = RegEnumKeyExW(hkey, i, wname, &n, NULL, NULL, NULL, NULL)) == ERROR_SUCCESS; i++) { + e = RegOpenKeyExW(hkey, wname, 0, KEY_READ, (HKEY *)&DATA_PTR(k)); + w32error_check(e); + rb_ensure(rb_yield, k, hkey_close, k); + } + if (e != ERROR_NO_MORE_ITEMS) w32error_check(e); + return self; +} + +static inline DWORD +swap_dw(DWORD x) +{ +#if defined(_MSC_VER) + return _byteswap_ulong(x); +#else + return __builtin_bswap32(x); +#endif +} + +static VALUE +reg_value(VALUE self, VALUE name) +{ + HKEY hkey = DATA_PTR(self); + DWORD type = 0, size = 0, e; + VALUE result, value_buffer; + void *buffer; + WCHAR wname[256]; + to_wname(&name, wname, numberof(wname)); + e = RegGetValueW(hkey, NULL, wname, RRF_RT_ANY, &type, NULL, &size); + if (e == ERROR_FILE_NOT_FOUND) return Qnil; + w32error_check(e); +# define get_value_2nd(data, dsize) do { \ + DWORD type2 = type; \ + w32error_check(RegGetValueW(hkey, NULL, wname, RRF_RT_ANY, &type2, data, dsize)); \ + if (type != type2) { \ + rb_raise(rb_eRuntimeError, "registry value type changed %lu -> %lu", \ + (unsigned long)type, (unsigned long)type2); \ + } \ + } while (0) + + switch (type) { + case REG_DWORD: case REG_DWORD_BIG_ENDIAN: + { + DWORD d; + if (size != sizeof(d)) rb_raise(rb_eRuntimeError, "invalid size returned: %lu", (unsigned long)size); + w32error_check(RegGetValueW(hkey, NULL, wname, RRF_RT_REG_DWORD, &type, &d, &size)); + if (type == REG_DWORD_BIG_ENDIAN) d = swap_dw(d); + return ULONG2NUM(d); + } + case REG_QWORD: + { + QWORD q; + if (size != sizeof(q)) rb_raise(rb_eRuntimeError, "invalid size returned: %lu", (unsigned long)size); + w32error_check(RegGetValueW(hkey, NULL, wname, RRF_RT_REG_QWORD, &type, &q, &size)); + return ULL2NUM(q); + } + case REG_SZ: case REG_MULTI_SZ: case REG_EXPAND_SZ: + if (size % sizeof(WCHAR)) rb_raise(rb_eRuntimeError, "invalid size returned: %lu", (unsigned long)size); + buffer = ALLOCV_N(char, value_buffer, size); + get_value_2nd(buffer, &size); + if (type == REG_MULTI_SZ) { + const WCHAR *w = (WCHAR *)buffer; + result = rb_ary_new(); + size /= sizeof(WCHAR); + size -= 1; + for (size_t i = 0; i < size; ++i) { + int n = lstrlenW(w+i); + rb_ary_push(result, wchar_to_utf8(w+i, n)); + i += n; + } + } + else { + result = wchar_to_utf8((WCHAR *)buffer, lstrlenW((WCHAR *)buffer)); + } + ALLOCV_END(value_buffer); + break; + default: + result = rb_str_new(0, size); + get_value_2nd(RSTRING_PTR(result), &size); + rb_str_set_len(result, size); + break; + } + return result; +} + void InitVM_resolv(void) { VALUE mWin32 = rb_define_module("Win32"); VALUE resolv = rb_define_module_under(mWin32, "Resolv"); VALUE singl = rb_singleton_class(resolv); + VALUE regkey = rb_define_class_under(resolv, "registry key", rb_cObject); + + reg_key_class = regkey; + rb_undef_alloc_func(regkey); rb_define_private_method(singl, "get_dns_server_list", get_dns_server_list, 0); + rb_define_private_method(singl, "tcpip_params", tcpip_params_open, 0); + rb_define_method(regkey, "open", reg_open, 1); + rb_define_method(regkey, "each_key", reg_each_key, 0); + rb_define_method(regkey, "value", reg_value, 1); } void --- a/lib/resolv.gemspec +++ b/lib/resolv.gemspec @@ -1,6 +1,13 @@ +name = File.basename(__FILE__, ".gemspec") +version = ["lib", Array.new(name.count("-")+1).join("/")].find do |dir| + break File.foreach(File.join(__dir__, dir, "#{name.tr('-', '/')}.rb")) do |line| + /^\s*VERSION\s*=\s*"(.*)"/ =~ line and break $1 + end rescue nil +end + Gem::Specification.new do |spec| - spec.name = "resolv" - spec.version = "0.2.3" + spec.name = name + spec.version = version spec.authors = ["Tanaka Akira"] spec.email = ["akr@fsij.org"] @@ -9,13 +16,13 @@ spec.homepage = "https://github.com/ruby/resolv" spec.required_ruby_version = Gem::Requirement.new(">= 2.3.0") spec.licenses = ["Ruby", "BSD-2-Clause"] + spec.extensions << "ext/win32/resolv/extconf.rb" spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage - spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do - `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } - end + excludes = %W[/.git* /bin /test /*file /#{File.basename(__FILE__)}] + spec.files = IO.popen(%W[git -C #{__dir__} ls-files -z --] + excludes.map {|e| ":^#{e}"}, &:read).split("\x0") spec.bindir = "exe" spec.executables = [] spec.require_paths = ["lib"] --- a/lib/resolv.rb +++ b/lib/resolv.rb @@ -3,11 +3,8 @@ require 'socket' require 'timeout' require 'io/wait' - -begin - require 'securerandom' -rescue LoadError -end +require 'securerandom' +require 'rbconfig' # Resolv is a thread-aware DNS resolver library written in Ruby. Resolv can # handle multiple DNS requests concurrently without blocking the entire Ruby @@ -37,6 +34,9 @@ class Resolv + # The version string + VERSION = "0.7.2" + ## # Looks up the first IP address for +name+. @@ -81,9 +81,22 @@ def self.each_name(address, &proc) ## # Creates a new Resolv using +resolvers+. + # + # If +resolvers+ is not given, a hash, or +nil+, uses a Hosts resolver and + # and a DNS resolver. If +resolvers+ is a hash, uses the hash as + # configuration for the DNS resolver. + + def initialize(resolvers=(arg_not_set = true; nil), use_ipv6: (keyword_not_set = true; nil)) + if !keyword_not_set && !arg_not_set + warn "Support for separate use_ipv6 keyword is deprecated, as it is ignored if an argument is provided. Do not provide a positional argument if using the use_ipv6 keyword argument.", uplevel: 1 + end - def initialize(resolvers=[Hosts.new, DNS.new]) - @resolvers = resolvers + @resolvers = case resolvers + when Hash, nil + [Hosts.new, DNS.new(DNS::Config.default_config_hash.merge(resolvers || {}))] + else + resolvers + end end ## @@ -166,14 +179,15 @@ class ResolvTimeout < Timeout::Error; end # Resolv::Hosts is a hostname resolver that uses the system hosts file. class Hosts - if /mswin|mingw|cygwin/ =~ RUBY_PLATFORM and + if /mswin|cygwin|mingw|bccwin/ =~ RUBY_PLATFORM || ::RbConfig::CONFIG['host_os'] =~ /mswin/ begin - require 'win32/resolv' - DefaultFileName = Win32::Resolv.get_hosts_path || IO::NULL + require 'win32/resolv' unless defined?(Win32::Resolv) + hosts = Win32::Resolv.get_hosts_path || IO::NULL rescue LoadError end end - DefaultFileName ||= '/etc/hosts' + # The default file name for host names + DefaultFileName = hosts || '/etc/hosts' ## # Creates a new Resolv::Hosts, using +filename+ for its data source. @@ -192,17 +206,10 @@ def lazy_initialize # :nodoc: File.open(@filename, 'rb') {|f| f.each {|line| line.sub!(/#.*/, '') - addr, hostname, *aliases = line.split(/\s+/) + addr, *hostnames = line.split(/\s+/) next unless addr - @addr2name[addr] = [] unless @addr2name.include? addr - @addr2name[addr] << hostname - @addr2name[addr] += aliases - @name2addr[hostname] = [] unless @name2addr.include? hostname - @name2addr[hostname] << addr - aliases.each {|n| - @name2addr[n] = [] unless @name2addr.include? n - @name2addr[n] << addr - } + (@addr2name[addr] ||= []).concat(hostnames) + hostnames.each {|hostname| (@name2addr[hostname] ||= []) << addr} } } @name2addr.each {|name, arr| arr.reverse!} @@ -310,6 +317,8 @@ def self.open(*args) # String:: Path to a file using /etc/resolv.conf's format. # Hash:: Must contain :nameserver, :search and :ndots keys. # :nameserver_port can be used to specify port number of nameserver address. + # :raise_timeout_errors can be used to raise timeout errors + # as exceptions instead of treating the same as an NXDOMAIN response. # # The value of :nameserver should be an address string or # an array of address strings. @@ -399,13 +408,20 @@ def getaddresses(name) # be a Resolv::IPv4 or Resolv::IPv6 def each_address(name) - each_resource(name, Resource::IN::A) {|resource| yield resource.address} if use_ipv6? each_resource(name, Resource::IN::AAAA) {|resource| yield resource.address} end + each_resource(name, Resource::IN::A) {|resource| yield resource.address} end def use_ipv6? # :nodoc: + @config.lazy_initialize unless @config.instance_variable_get(:@initialized) + + use_ipv6 = @config.use_ipv6? + unless use_ipv6.nil? + return use_ipv6 + end + begin list = Socket.ip_address_list rescue NotImplementedError @@ -471,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 # @@ -509,37 +530,44 @@ def each_resource(name, typeclass, &proc) } end + # :stopdoc: + def fetch_resource(name, typeclass) lazy_initialize - begin - requester = make_udp_requester + truncated = {} + requesters = {} + udp_requester = begin + make_udp_requester rescue Errno::EACCES # fall back to TCP end senders = {} + begin - @config.resolv(name) {|candidate, tout, nameserver, port| - requester ||= make_tcp_requester(nameserver, port) + @config.resolv(name) do |candidate, tout, nameserver, port| msg = Message.new msg.rd = 1 msg.add_question(candidate, typeclass) - unless sender = senders[[candidate, nameserver, port]] + + requester = requesters.fetch([nameserver, port]) do + if !truncated[candidate] && udp_requester + udp_requester + else + requesters[[nameserver, port]] = make_tcp_requester(nameserver, port) + end + end + + unless sender = senders[[candidate, requester, nameserver, port]] sender = requester.sender(msg, candidate, nameserver, port) next if !sender - senders[[candidate, nameserver, port]] = sender + senders[[candidate, requester, nameserver, port]] = sender end reply, reply_name = requester.request(sender, tout) case reply.rcode when RCode::NoError if reply.tc == 1 and not Requester::TCP === requester - requester.close # Retry via TCP: - requester = make_tcp_requester(nameserver, port) - senders = {} - # This will use TCP for all remaining candidates (assuming the - # current candidate does not already respond successfully via - # TCP). This makes sense because we already know the full - # response will not fit in an untruncated UDP packet. + truncated[candidate] = true redo else yield(reply, reply_name) @@ -550,9 +578,10 @@ def fetch_resource(name, typeclass) else raise Config::OtherResolvError.new(reply_name.to_s) end - } + end ensure - requester&.close + udp_requester&.close + requesters.each_value { |requester| requester&.close } end end @@ -567,6 +596,11 @@ def make_udp_requester # :nodoc: def make_tcp_requester(host, port) # :nodoc: return Requester::TCP.new(host, port) + rescue Errno::ECONNREFUSED + # Treat a refused TCP connection attempt to a nameserver like a timeout, + # as Resolv::DNS::Config#resolv considers ResolvTimeout exceptions as a + # hint to try the next nameserver: + raise ResolvTimeout end def extract_resources(msg, name, typeclass) # :nodoc: @@ -600,16 +634,10 @@ def extract_resources(msg, name, typeclass) # :nodoc: } end - if defined? SecureRandom - def self.random(arg) # :nodoc: - begin - SecureRandom.random_number(arg) - rescue NotImplementedError - rand(arg) - end - end - else - def self.random(arg) # :nodoc: + def self.random(arg) # :nodoc: + begin + SecureRandom.random_number(arg) + rescue NotImplementedError rand(arg) end end @@ -641,8 +669,20 @@ def self.free_request_id(host, port, id) # :nodoc: } end - def self.bind_random_port(udpsock, bind_host="0.0.0.0") # :nodoc: - begin + case RUBY_PLATFORM + when *[ + # https://www.rfc-editor.org/rfc/rfc6056.txt + # Appendix A. Survey of the Algorithms in Use by Some Popular Implementations + /freebsd/, /linux/, /netbsd/, /openbsd/, /solaris/, + /darwin/, # the same as FreeBSD + ] then + def self.bind_random_port(udpsock, bind_host="0.0.0.0") # :nodoc: + udpsock.bind(bind_host, 0) + end + else + # Sequential port assignment + def self.bind_random_port(udpsock, bind_host="0.0.0.0") # :nodoc: + # Ephemeral port number range recommended by RFC 6056 port = random(1024..65535) udpsock.bind(bind_host, port) rescue Errno::EADDRINUSE, # POSIX @@ -686,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 @@ -748,7 +789,7 @@ def lazy_initialize next if @socks_hash[bind_host] begin sock = UDPSocket.new(af) - rescue Errno::EAFNOSUPPORT + rescue Errno::EAFNOSUPPORT, Errno::EPROTONOSUPPORT next # The kernel doesn't support the address family. end @socks << sock @@ -895,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 @@ -965,13 +1009,13 @@ def Config.parse_resolv_conf(filename) next unless keyword case keyword when 'nameserver' - nameserver += args + nameserver.concat(args.each(&:freeze)) when 'domain' next if args.empty? - search = [args[0]] + search = [args[0].freeze] when 'search' next if args.empty? - search = args + search = args.each(&:freeze) when 'options' args.each {|arg| case arg @@ -982,28 +1026,28 @@ def Config.parse_resolv_conf(filename) end } } - return { :nameserver => nameserver, :search => search, :ndots => ndots } + return { :nameserver => nameserver.freeze, :search => search.freeze, :ndots => ndots.freeze }.freeze end def Config.default_config_hash(filename="/etc/resolv.conf") if File.exist? filename - config_hash = Config.parse_resolv_conf(filename) + Config.parse_resolv_conf(filename) + elsif defined?(Win32::Resolv) + search, nameserver = Win32::Resolv.get_resolv_info + config_hash = {} + config_hash[:nameserver] = nameserver if nameserver + config_hash[:search] = [search].flatten if search + config_hash else - if /mswin|cygwin|mingw|bccwin/ =~ RUBY_PLATFORM - require 'win32/resolv' - search, nameserver = Win32::Resolv.get_resolv_info - config_hash = {} - config_hash[:nameserver] = nameserver if nameserver - config_hash[:search] = [search].flatten if search - end + {} end - config_hash || {} end def lazy_initialize @mutex.synchronize { unless @initialized @nameserver_port = [] + @use_ipv6 = nil @search = nil @ndots = 1 case @config_info @@ -1028,8 +1072,12 @@ def lazy_initialize if config_hash.include? :nameserver_port @nameserver_port = config_hash[:nameserver_port].map {|ns, port| [ns, (port || Port)] } end + if config_hash.include? :use_ipv6 + @use_ipv6 = config_hash[:use_ipv6] + end @search = config_hash[:search] if config_hash.include? :search @ndots = config_hash[:ndots] if config_hash.include? :ndots + @raise_timeout_errors = config_hash[:raise_timeout_errors] if @nameserver_port.empty? @nameserver_port << ['0.0.0.0', Port] @@ -1083,6 +1131,10 @@ def nameserver_port @nameserver_port end + def use_ipv6? + @use_ipv6 + end + def generate_candidates(name) candidates = nil name = Name.create(name) @@ -1116,6 +1168,7 @@ def generate_timeouts def resolv(name) candidates = generate_candidates(name) timeouts = @timeouts || generate_timeouts + timeout_error = false begin candidates.each {|candidate| begin @@ -1127,11 +1180,13 @@ def resolv(name) end } } + timeout_error = true raise ResolvError.new("DNS resolv timeout: #{name}") rescue NXDomain end } rescue ResolvError + raise if @raise_timeout_errors && timeout_error end end @@ -1198,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. @@ -1243,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 @@ -1365,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 @@ -1477,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) @@ -1487,14 +1587,14 @@ def put_string_list(ds) } end - def put_name(d) - put_labels(d.to_a) + def put_name(d, compress: true) + put_labels(d.to_a, compress: compress) end - def put_labels(d) + def put_labels(d, compress: true) d.each_index {|i| domain = d[i..-1] - if idx = @names[domain] + if compress && idx = @names[domain] self.put_pack("n", 0xc000 | idx) return else @@ -1508,7 +1608,17 @@ def put_labels(d) 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 @@ -1518,13 +1628,15 @@ def Message.decode(m) id, flag, qdcount, ancount, nscount, arcount = msg.get_unpack('nnnnnn') o.id = id + o.tc = (flag >> 9) & 1 + o.rcode = flag & 15 + return o unless o.tc.zero? + o.qr = (flag >> 15) & 1 o.opcode = (flag >> 11) & 15 o.aa = (flag >> 10) & 1 - o.tc = (flag >> 9) & 1 o.rd = (flag >> 8) & 1 o.ra = (flag >> 7) & 1 - o.rcode = flag & 15 (1..qdcount).each { name, typeclass = msg.get_question o.add_question(name, typeclass) @@ -1616,6 +1728,14 @@ def get_string_list strings end + def get_list + [].tap do |values| + while @index < @limit + values << yield + end + end + end + def get_name return Name.new(self.get_labels) end @@ -1624,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) @@ -1655,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 @@ -1680,6 +1807,378 @@ def get_rr end end + ## + # SvcParams for service binding RRs. [RFC9460] + + class SvcParams + include Enumerable + + ## + # Create a list of SvcParams with the given initial content. + # + # +params+ has to be an enumerable of +SvcParam+s. + # If its content has +SvcParam+s with the duplicate key, + # the one appears last takes precedence. + + def initialize(params = []) + @params = {} + + params.each do |param| + add param + end + end + + ## + # Get SvcParam for the given +key+ in this list. + + def [](key) + @params[canonical_key(key)] + end + + ## + # Get the number of SvcParams in this list. + + def count + @params.count + end + + ## + # Get whether this list is empty. + + def empty? + @params.empty? + end + + ## + # Add the SvcParam +param+ to this list, overwriting the existing one with the same key. + + def add(param) + @params[param.class.key_number] = param + end + + ## + # Remove the +SvcParam+ with the given +key+ and return it. + + def delete(key) + @params.delete(canonical_key(key)) + end + + ## + # Enumerate the +SvcParam+s in this list. + + def each(&block) + return enum_for(:each) unless block + @params.each_value(&block) + end + + def encode(msg) # :nodoc: + @params.keys.sort.each do |key| + msg.put_pack('n', key) + msg.put_length16 do + @params.fetch(key).encode(msg) + end + end + end + + def self.decode(msg) # :nodoc: + params = msg.get_list do + key, = msg.get_unpack('n') + msg.get_length16 do + SvcParam::ClassHash[key].decode(msg) + end + end + + return self.new(params) + end + + private + + def canonical_key(key) # :nodoc: + case key + when Integer + key + when /\Akey(\d+)\z/ + Integer($1) + when Symbol + SvcParam::ClassHash[key].key_number + else + raise TypeError, 'key must be either String or Symbol' + end + end + end + + ## + # Base class for SvcParam. [RFC9460] + + class SvcParam + + ## + # Get the presentation name of the SvcParamKey. + + def self.key_name + const_get(:KeyName) + end + + ## + # Get the registered number of the SvcParamKey. + + def self.key_number + const_get(:KeyNumber) + end + + ClassHash = Hash.new do |h, key| # :nodoc: + case key + when Integer + Generic.create(key) + when /\Akey(?\d+)\z/ + Generic.create(key.to_int) + when Symbol + raise KeyError, "unknown key #{key}" + else + raise TypeError, 'key must be either String or Symbol' + end + end + + ## + # Generic SvcParam abstract class. + + class Generic < SvcParam + + ## + # SvcParamValue in wire-format byte string. + + attr_reader :value + + ## + # Create generic SvcParam + + def initialize(value) + @value = value + end + + def encode(msg) # :nodoc: + msg.put_bytes(@value) + end + + def self.decode(msg) # :nodoc: + return self.new(msg.get_bytes) + end + + def self.create(key_number) + c = Class.new(Generic) + key_name = :"key#{key_number}" + c.const_set(:KeyName, key_name) + c.const_set(:KeyNumber, key_number) + # 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 + + ## + # "mandatory" SvcParam -- Mandatory keys in service binding RR + + class Mandatory < SvcParam + KeyName = :mandatory + KeyNumber = 0 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Mandatory keys. + + attr_reader :keys + + ## + # Initialize "mandatory" ScvParam. + + def initialize(keys) + @keys = keys.map(&:to_int) + end + + def encode(msg) # :nodoc: + @keys.sort.each do |key| + msg.put_pack('n', key) + end + end + + def self.decode(msg) # :nodoc: + keys = msg.get_list { msg.get_unpack('n')[0] } + return self.new(keys) + end + end + + ## + # "alpn" SvcParam -- Additional supported protocols + + class ALPN < SvcParam + KeyName = :alpn + KeyNumber = 1 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Supported protocol IDs. + + attr_reader :protocol_ids + + ## + # Initialize "alpn" ScvParam. + + def initialize(protocol_ids) + @protocol_ids = protocol_ids.map(&:to_str) + end + + def encode(msg) # :nodoc: + msg.put_string_list(@protocol_ids) + end + + def self.decode(msg) # :nodoc: + return self.new(msg.get_string_list) + end + end + + ## + # "no-default-alpn" SvcParam -- No support for default protocol + + class NoDefaultALPN < SvcParam + KeyName = :'no-default-alpn' + KeyNumber = 2 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + def encode(msg) # :nodoc: + # no payload + end + + def self.decode(msg) # :nodoc: + return self.new + end + end + + ## + # "port" SvcParam -- Port for alternative endpoint + + class Port < SvcParam + KeyName = :port + KeyNumber = 3 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Port number. + + attr_reader :port + + ## + # Initialize "port" ScvParam. + + def initialize(port) + @port = port.to_int + end + + def encode(msg) # :nodoc: + msg.put_pack('n', @port) + end + + def self.decode(msg) # :nodoc: + port, = msg.get_unpack('n') + return self.new(port) + end + end + + ## + # "ipv4hint" SvcParam -- IPv4 address hints + + class IPv4Hint < SvcParam + KeyName = :ipv4hint + KeyNumber = 4 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Set of IPv4 addresses. + + attr_reader :addresses + + ## + # Initialize "ipv4hint" ScvParam. + + def initialize(addresses) + @addresses = addresses.map {|address| IPv4.create(address) } + end + + def encode(msg) # :nodoc: + @addresses.each do |address| + msg.put_bytes(address.address) + end + end + + def self.decode(msg) # :nodoc: + addresses = msg.get_list { IPv4.new(msg.get_bytes(4)) } + return self.new(addresses) + end + end + + ## + # "ipv6hint" SvcParam -- IPv6 address hints + + class IPv6Hint < SvcParam + KeyName = :ipv6hint + KeyNumber = 6 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Set of IPv6 addresses. + + attr_reader :addresses + + ## + # Initialize "ipv6hint" ScvParam. + + def initialize(addresses) + @addresses = addresses.map {|address| IPv6.create(address) } + end + + def encode(msg) # :nodoc: + @addresses.each do |address| + msg.put_bytes(address.address) + end + end + + def self.decode(msg) # :nodoc: + addresses = msg.get_list { IPv6.new(msg.get_bytes(16)) } + return self.new(addresses) + end + end + + ## + # "dohpath" SvcParam -- DNS over HTTPS path template [RFC9461] + + class DoHPath < SvcParam + KeyName = :dohpath + KeyNumber = 7 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # URI template for DoH queries. + + attr_reader :template + + ## + # Initialize "dohpath" ScvParam. + + def initialize(template) + @template = template.encode('utf-8') + end + + def encode(msg) # :nodoc: + msg.put_bytes(@template) + end + + def self.decode(msg) # :nodoc: + template = msg.get_bytes.force_encoding('utf-8') + return self.new(template) + end + end + end + ## # A DNS query abstract class. @@ -1703,7 +2202,14 @@ class Resource < Query attr_reader :ttl - ClassHash = {} # :nodoc: + ClassHash = Module.new do + module_function + + def []=(type_class_value, klass) + type_value, class_value = type_class_value + Resource.const_set(:"Type#{type_value}_Class#{class_value}", klass) + end + end def encode_rdata(msg) # :nodoc: raise NotImplementedError.new @@ -1741,7 +2247,9 @@ def hash # :nodoc: end def self.get_class(type_value, class_value) # :nodoc: - return ClassHash[[type_value, class_value]] || + cache = :"Type#{type_value}_Class#{class_value}" + + return (const_defined?(cache) && const_get(cache)) || Generic.create(type_value, class_value) end @@ -1770,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 @@ -2107,7 +2631,6 @@ def initialize(version, ssize, hprecision, vprecision, latitude, longitude, alti attr_reader :altitude - def encode_rdata(msg) # :nodoc: msg.put_bytes(@version) msg.put_bytes(@ssize.scalar) @@ -2145,8 +2668,70 @@ class ANY < Query TypeValue = 255 # :nodoc: end + ## + # CAA resource record defined in RFC 8659 + # + # These records identify certificate authority allowed to issue + # certificates for the given domain. + + class CAA < Resource + TypeValue = 257 + + ## + # Creates a new CAA for +flags+, +tag+ and +value+. + + def initialize(flags, tag, value) + unless (0..255) === flags + raise ArgumentError.new('flags must be an Integer between 0 and 255') + end + unless (1..15) === tag.bytesize + raise ArgumentError.new('length of tag must be between 1 and 15') + end + + @flags = flags + @tag = tag + @value = value + end + + ## + # Flags for this property: + # - Bit 0 : 0 = not critical, 1 = critical + + attr_reader :flags + + ## + # Property tag ("issue", "issuewild", "iodef"...). + + attr_reader :tag + + ## + # Property value. + + attr_reader :value + + ## + # Whether the critical flag is set on this property. + + def critical? + flags & 0x80 != 0 + end + + def encode_rdata(msg) # :nodoc: + msg.put_pack('C', @flags) + msg.put_string(@tag) + msg.put_bytes(@value) + end + + def self.decode_rdata(msg) # :nodoc: + flags, = msg.get_unpack('C') + tag = msg.get_string + value = msg.get_bytes + self.new flags, tag, value + end + end + ClassInsensitiveTypes = [ # :nodoc: - NS, CNAME, SOA, PTR, HINFO, MINFO, MX, TXT, LOC, ANY + NS, CNAME, SOA, PTR, HINFO, MINFO, MX, TXT, LOC, ANY, CAA ] ## @@ -2332,7 +2917,7 @@ def encode_rdata(msg) # :nodoc: msg.put_pack("n", @priority) msg.put_pack("n", @weight) msg.put_pack("n", @port) - msg.put_name(@target) + msg.put_name(@target, compress: false) end def self.decode_rdata(msg) # :nodoc: @@ -2343,6 +2928,84 @@ def self.decode_rdata(msg) # :nodoc: return self.new(priority, weight, port, target) end end + + ## + # Common implementation for SVCB-compatible resource records. + + class ServiceBinding + + ## + # Create a service binding resource record. + + def initialize(priority, target, params = []) + @priority = priority.to_int + @target = Name.create(target) + @params = SvcParams.new(params) + end + + ## + # The priority of this target host. + # + # The range is 0-65535. + # If set to 0, this RR is in AliasMode. Otherwise, it is in ServiceMode. + + attr_reader :priority + + ## + # The domain name of the target host. + + attr_reader :target + + ## + # The service parameters for the target host. + + attr_reader :params + + ## + # Whether this RR is in AliasMode. + + def alias_mode? + self.priority == 0 + end + + ## + # Whether this RR is in ServiceMode. + + def service_mode? + !alias_mode? + end + + def encode_rdata(msg) # :nodoc: + msg.put_pack("n", @priority) + msg.put_name(@target, compress: false) + @params.encode(msg) + end + + def self.decode_rdata(msg) # :nodoc: + priority, = msg.get_unpack("n") + target = msg.get_name + params = SvcParams.decode(msg) + return self.new(priority, target, params) + end + end + + ## + # SVCB resource record [RFC9460] + + class SVCB < ServiceBinding + TypeValue = 64 + ClassValue = IN::ClassValue + ClassHash[[TypeValue, ClassValue]] = self # :nodoc: + end + + ## + # HTTPS resource record [RFC9460] + + class HTTPS < ServiceBinding + TypeValue = 65 + ClassValue = IN::ClassValue + ClassHash[[TypeValue, ClassValue]] = self # :nodoc: + end end end end @@ -2352,15 +3015,21 @@ def self.decode_rdata(msg) # :nodoc: class IPv4 - ## - # Regular expression IPv4 addresses must match. - Regex256 = /0 |1(?:[0-9][0-9]?)? |2(?:[0-4][0-9]?|5[0-5]?|[6-9])? - |[3-9][0-9]?/x + |[3-9][0-9]?/x # :nodoc: + + ## + # Regular expression IPv4 addresses must match. Regex = /\A(#{Regex256})\.(#{Regex256})\.(#{Regex256})\.(#{Regex256})\z/ + ## + # Creates a new IPv4 address from +arg+ which may be: + # + # IPv4:: returns +arg+. + # String:: +arg+ must match the IPv4::Regex constant + def self.create(arg) case arg when IPv4 @@ -2562,11 +3231,7 @@ def initialize(address) # :nodoc: attr_reader :address def to_s # :nodoc: - address = sprintf("%x:%x:%x:%x:%x:%x:%x:%x", *@address.unpack("nnnnnnnn")) - unless address.sub!(/(^|:)0(:0)+(:|$)/, '::') - address.sub!(/(^|:)0(:|$)/, '::') - end - return address + sprintf("%x:%x:%x:%x:%x:%x:%x:%x", *@address.unpack("nnnnnnnn")).sub(/(^|:)0(:0)+(:|$)/, '::') end def inspect # :nodoc: @@ -2673,13 +3338,15 @@ def make_udp_requester # :nodoc: end - module LOC + module LOC # :nodoc: ## # A Resolv::LOC::Size class Size + # Regular expression LOC size must match. + Regex = /^(\d+\.*\d*)[m]$/ ## @@ -2705,6 +3372,7 @@ def self.create(arg) end end + # Internal use; use self.create. def initialize(scalar) @scalar = scalar end @@ -2742,6 +3410,8 @@ def hash # :nodoc: class Coord + # Regular expression LOC Coord must match. + Regex = /^(\d+)\s(\d+)\s(\d+\.\d+)\s([NESW])$/ ## @@ -2771,6 +3441,7 @@ def self.create(arg) end end + # Internal use; use self.create. def initialize(coordinates,orientation) unless coordinates.kind_of?(String) raise ArgumentError.new("Coord must be a 32bit unsigned integer in hex format: #{coordinates.inspect}") @@ -2833,6 +3504,8 @@ def hash # :nodoc: class Alt + # Regular expression LOC Alt must match. + Regex = /^([+-]*\d+\.*\d*)[m]$/ ## @@ -2858,6 +3531,7 @@ def self.create(arg) end end + # Internal use; use self.create. def initialize(altitude) @altitude = altitude end @@ -2911,4 +3585,3 @@ def DefaultResolver.replace_resolvers new_resolvers AddressRegex = /(?:#{IPv4::Regex})|(?:#{IPv6::Regex})/ end - --- a/test/resolv/test_dns.rb +++ b/test/resolv/test_dns.rb @@ -44,6 +44,16 @@ def teardown BasicSocket.do_not_reverse_lookup = @save_do_not_reverse_lookup end + def with_tcp(host, port) + t = TCPServer.new(host, port) + begin + t.listen(1) + yield t + ensure + t.close + end + end + def with_udp(host, port) u = UDPSocket.new begin @@ -54,6 +64,46 @@ def with_udp(host, port) end end + def with_udp_and_tcp(host, port) + if port == 0 + # Automatic port; we might need to retry until we find a port which is free on both UDP _and_ TCP. + retries_remaining = 10 + ts = [] + us = [] + begin + begin + us << UDPSocket.new + us.last.bind(host, 0) + _, udp_port, _, _ = us.last.addr + ts << TCPServer.new(host, udp_port) + ts.last.listen(1) + rescue Errno::EADDRINUSE, Errno::EACCES + # ADDRINUSE is what should get thrown if we try and bind a port which is already bound on UNIXen, + # but windows can sometimes throw EACCESS. + # See: https://stackoverflow.com/questions/48478869/cannot-bind-to-some-ports-due-to-permission-denied + retries_remaining -= 1 + retry if retries_remaining > 0 + # Windows and MinGW CI can't bind to the same port with ten retries. + omit if /mswin|mingw/ =~ RUBY_PLATFORM + raise + end + + # If we get to this point, we have a valid t & u socket + yield us.last, ts.last + ensure + ts.each(&:close) + us.each(&:close) + end + else + # Explicitly specified port, don't retry the bind. + with_udp(host, port) do |u| + with_tcp(host, port) do |t| + yield u, t + end + end + end + end + # [ruby-core:65836] def test_resolve_with_2_ndots conf = Resolv::DNS::Config.new :nameserver => ['127.0.0.1'], :ndots => 2 @@ -157,6 +207,166 @@ def test_query_ipv4_address } end + def test_query_ipv4_address_truncated_tcp_fallback + begin + OpenSSL + rescue LoadError + skip 'autoload problem. see [ruby-dev:45021][Bug #5786]' + end if defined?(OpenSSL) + + num_records = 50 + + with_udp_and_tcp('127.0.0.1', 0) {|u, t| + _, server_port, _, server_address = u.addr + client_thread = Thread.new { + Resolv::DNS.open(:nameserver_port => [[server_address, server_port]]) {|dns| + dns.getresources("foo.example.org", Resolv::DNS::Resource::IN::A) + } + } + udp_server_thread = Thread.new { + msg, (_, client_port, _, client_address) = Timeout.timeout(5) {u.recvfrom(4096)} + id, word2, qdcount, ancount, nscount, arcount = msg.unpack("nnnnnn") + qr = (word2 & 0x8000) >> 15 + opcode = (word2 & 0x7800) >> 11 + aa = (word2 & 0x0400) >> 10 + tc = (word2 & 0x0200) >> 9 + rd = (word2 & 0x0100) >> 8 + ra = (word2 & 0x0080) >> 7 + z = (word2 & 0x0070) >> 4 + rcode = word2 & 0x000f + rest = msg[12..-1] + assert_equal(0, qr) # 0:query 1:response + assert_equal(0, opcode) # 0:QUERY 1:IQUERY 2:STATUS + assert_equal(0, aa) # Authoritative Answer + assert_equal(0, tc) # TrunCation + assert_equal(1, rd) # Recursion Desired + assert_equal(0, ra) # Recursion Available + assert_equal(0, z) # Reserved for future use + assert_equal(0, rcode) # 0:No-error 1:Format-error 2:Server-failure 3:Name-Error 4:Not-Implemented 5:Refused + assert_equal(1, qdcount) # number of entries in the question section. + assert_equal(0, ancount) # number of entries in the answer section. + assert_equal(0, nscount) # number of entries in the authority records section. + assert_equal(0, arcount) # number of entries in the additional records section. + name = [3, "foo", 7, "example", 3, "org", 0].pack("Ca*Ca*Ca*C") + assert_operator(rest, :start_with?, name) + rest = rest[name.length..-1] + assert_equal(4, rest.length) + qtype, _ = rest.unpack("nn") + assert_equal(1, qtype) # A + assert_equal(1, qtype) # IN + id = id + qr = 1 + opcode = opcode + aa = 0 + tc = 1 + rd = rd + ra = 1 + z = 0 + rcode = 0 + qdcount = 0 + ancount = num_records + nscount = 0 + arcount = 0 + word2 = (qr << 15) | + (opcode << 11) | + (aa << 10) | + (tc << 9) | + (rd << 8) | + (ra << 7) | + (z << 4) | + rcode + msg = [id, word2, qdcount, ancount, nscount, arcount].pack("nnnnnn") + type = 1 + klass = 1 + ttl = 3600 + rdlength = 4 + num_records.times do |i| + rdata = [192,0,2,i].pack("CCCC") # 192.0.2.x (TEST-NET address) RFC 3330 + rr = [name, type, klass, ttl, rdlength, rdata].pack("a*nnNna*") + msg << rr + end + u.send(msg[0...512], 0, client_address, client_port) + } + tcp_server_thread = Thread.new { + ct = t.accept + msg = ct.recv(512) + msg.slice!(0..1) # Size (only for TCP) + id, word2, qdcount, ancount, nscount, arcount = msg.unpack("nnnnnn") + qr = (word2 & 0x8000) >> 15 + opcode = (word2 & 0x7800) >> 11 + aa = (word2 & 0x0400) >> 10 + tc = (word2 & 0x0200) >> 9 + rd = (word2 & 0x0100) >> 8 + ra = (word2 & 0x0080) >> 7 + z = (word2 & 0x0070) >> 4 + rcode = word2 & 0x000f + rest = msg[12..-1] + assert_equal(0, qr) # 0:query 1:response + assert_equal(0, opcode) # 0:QUERY 1:IQUERY 2:STATUS + assert_equal(0, aa) # Authoritative Answer + assert_equal(0, tc) # TrunCation + assert_equal(1, rd) # Recursion Desired + assert_equal(0, ra) # Recursion Available + assert_equal(0, z) # Reserved for future use + assert_equal(0, rcode) # 0:No-error 1:Format-error 2:Server-failure 3:Name-Error 4:Not-Implemented 5:Refused + assert_equal(1, qdcount) # number of entries in the question section. + assert_equal(0, ancount) # number of entries in the answer section. + assert_equal(0, nscount) # number of entries in the authority records section. + assert_equal(0, arcount) # number of entries in the additional records section. + name = [3, "foo", 7, "example", 3, "org", 0].pack("Ca*Ca*Ca*C") + assert_operator(rest, :start_with?, name) + rest = rest[name.length..-1] + assert_equal(4, rest.length) + qtype, _ = rest.unpack("nn") + assert_equal(1, qtype) # A + assert_equal(1, qtype) # IN + id = id + qr = 1 + opcode = opcode + aa = 0 + tc = 0 + rd = rd + ra = 1 + z = 0 + rcode = 0 + qdcount = 0 + ancount = num_records + nscount = 0 + arcount = 0 + word2 = (qr << 15) | + (opcode << 11) | + (aa << 10) | + (tc << 9) | + (rd << 8) | + (ra << 7) | + (z << 4) | + rcode + msg = [id, word2, qdcount, ancount, nscount, arcount].pack("nnnnnn") + type = 1 + klass = 1 + ttl = 3600 + rdlength = 4 + num_records.times do |i| + rdata = [192,0,2,i].pack("CCCC") # 192.0.2.x (TEST-NET address) RFC 3330 + rr = [name, type, klass, ttl, rdlength, rdata].pack("a*nnNna*") + msg << rr + end + msg = "#{[msg.bytesize].pack("n")}#{msg}" # Prefix with size + ct.send(msg, 0) + ct.close + } + result, _ = assert_join_threads([client_thread, udp_server_thread, tcp_server_thread]) + assert_instance_of(Array, result) + assert_equal(50, result.length) + result.each_with_index do |rr, i| + assert_instance_of(Resolv::DNS::Resource::IN::A, rr) + assert_instance_of(Resolv::IPv4, rr.address) + assert_equal("192.0.2.#{i}", rr.address.to_s) + assert_equal(3600, rr.ttl) + end + } + end + def test_query_ipv4_duplicate_responses begin OpenSSL @@ -168,7 +378,7 @@ def test_query_ipv4_duplicate_responses _, server_port, _, server_address = u.addr begin client_thread = Thread.new { - Resolv::DNS.open(:nameserver_port => [[server_address, server_port]], :search => ['bad1.com', 'bad2.com', 'good.com'], ndots: 5) {|dns| + Resolv::DNS.open(:nameserver_port => [[server_address, server_port]], :search => ['bad1.com', 'bad2.com', 'good.com'], ndots: 5, use_ipv6: false) {|dns| dns.getaddress("example") } } @@ -315,6 +525,8 @@ def test_no_server if RUBY_PLATFORM.match?(/mingw/) # cannot repo locally omit 'Timeout Error on MinGW CI' + elsif macos?([26,1]..[]) + omit 'Timeout Error on macOS 26.1+' else raise Timeout::Error end @@ -374,7 +586,8 @@ def test_ipv6_to_s ["2001:db8::1", "2001:db8::0:1"], ["::", "0:0:0:0:0:0:0:0"], ["2001::", "2001::0"], - ["2001:db8::1:1:1:1:1", "2001:db8:0:1:1:1:1:1"], + ["2001:db8:0:1:1:1:1:1", "2001:db8:0:1:1:1:1:1"], # RFC 5952 Section 4.2.2. + ["2001:db8::1:1:1:1", "2001:db8:0:0:1:1:1:1"], ["1::1:0:0:0:1", "1:0:0:1:0:0:0:1"], ["1::1:0:0:1", "1:0:0:0:1:0:0:1"], ] @@ -423,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 @@ -464,4 +876,269 @@ def dns.each_resource(name, typeclass) end assert_raise(Resolv::ResolvError) { dns.each_name('example.com') } end + + def test_unreachable_server + unreachable_ip = '127.0.0.1' + sock = UDPSocket.new + sock.connect(unreachable_ip, 53) + begin + sock.send('1', 0) + rescue Errno::ENETUNREACH, Errno::EHOSTUNREACH + else + omit('cannot test unreachable server, as IP used is reachable') + end + + config = { + :nameserver => [unreachable_ip], + :search => ['lan'], + :ndots => 1 + } + r = Resolv.new([Resolv::DNS.new(config)]) + assert_equal([], r.getaddresses('www.google.com')) + + config[:raise_timeout_errors] = true + r = Resolv.new([Resolv::DNS.new(config)]) + assert_raise(Resolv::ResolvError) { r.getaddresses('www.google.com') } + ensure + sock&.close + end + + def test_multiple_servers_with_timeout_and_truncated_tcp_fallback + begin + OpenSSL + rescue LoadError + skip 'autoload problem. see [ruby-dev:45021][Bug #5786]' + end if defined?(OpenSSL) + + num_records = 50 + + with_udp_and_tcp('127.0.0.1', 0) do |u1, t1| + with_udp_and_tcp('127.0.0.1', 0) do |u2,t2| + u2.close # XXX: u2 UDP socket is not used, but using #with_udp_and_tcp to enable Windows EACCES workaround + _, server1_port, _, server1_address = u1.addr + _, server2_port, _, server2_address = t2.addr + + client_thread = Thread.new do + Resolv::DNS.open(nameserver_port: [[server1_address, server1_port], [server2_address, server2_port]]) do |dns| + dns.timeouts = [0.1, 0.2] + dns.getresources('foo.example.org', Resolv::DNS::Resource::IN::A) + end + end + + udp_server1_thread = Thread.new do + msg, (_, client_port, _, client_address) = Timeout.timeout(5) { u1.recvfrom(4096) } + id, word2, _qdcount, _ancount, _nscount, _arcount = msg.unpack('nnnnnn') + opcode = (word2 & 0x7800) >> 11 + rd = (word2 & 0x0100) >> 8 + name = [3, 'foo', 7, 'example', 3, 'org', 0].pack('Ca*Ca*Ca*C') + qr = 1 + aa = 0 + tc = 1 + ra = 1 + z = 0 + rcode = 0 + qdcount = 0 + ancount = num_records + nscount = 0 + arcount = 0 + word2 = (qr << 15) | + (opcode << 11) | + (aa << 10) | + (tc << 9) | + (rd << 8) | + (ra << 7) | + (z << 4) | + rcode + msg = [id, word2, qdcount, ancount, nscount, arcount].pack('nnnnnn') + type = 1 + klass = 1 + ttl = 3600 + rdlength = 4 + num_records.times do |i| + rdata = [192, 0, 2, i].pack('CCCC') # 192.0.2.x (TEST-NET address) RFC 3330 + rr = [name, type, klass, ttl, rdlength, rdata].pack('a*nnNna*') + msg << rr + end + u1.send(msg[0...512], 0, client_address, client_port) + end + + tcp_server1_thread = Thread.new do + # Keep this socket open so that the client experiences a timeout + t1.accept + end + + tcp_server2_thread = Thread.new do + ct = t2.accept + msg = ct.recv(512) + msg.slice!(0..1) # Size (only for TCP) + id, word2, _qdcount, _ancount, _nscount, _arcount = msg.unpack('nnnnnn') + rd = (word2 & 0x0100) >> 8 + opcode = (word2 & 0x7800) >> 11 + name = [3, 'foo', 7, 'example', 3, 'org', 0].pack('Ca*Ca*Ca*C') + qr = 1 + aa = 0 + tc = 0 + ra = 1 + z = 0 + rcode = 0 + qdcount = 0 + ancount = num_records + nscount = 0 + arcount = 0 + word2 = (qr << 15) | + (opcode << 11) | + (aa << 10) | + (tc << 9) | + (rd << 8) | + (ra << 7) | + (z << 4) | + rcode + msg = [id, word2, qdcount, ancount, nscount, arcount].pack('nnnnnn') + type = 1 + klass = 1 + ttl = 3600 + rdlength = 4 + num_records.times do |i| + rdata = [192, 0, 2, i].pack('CCCC') # 192.0.2.x (TEST-NET address) RFC 3330 + rr = [name, type, klass, ttl, rdlength, rdata].pack('a*nnNna*') + msg << rr + end + msg = "#{[msg.bytesize].pack('n')}#{msg}" # Prefix with size + ct.send(msg, 0) + ct.close + end + result, _, tcp_server1_socket, = assert_join_threads([client_thread, udp_server1_thread, tcp_server1_thread, tcp_server2_thread]) + assert_instance_of(Array, result) + assert_equal(50, result.length) + result.each_with_index do |rr, i| + assert_instance_of(Resolv::DNS::Resource::IN::A, rr) + assert_instance_of(Resolv::IPv4, rr.address) + assert_equal("192.0.2.#{i}", rr.address.to_s) + assert_equal(3600, rr.ttl) + end + ensure + tcp_server1_socket&.close + 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 --- a/test/resolv/test_resource.rb +++ b/test/resolv/test_resource.rb @@ -23,4 +23,141 @@ def test_hash 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' + m = Resolv::DNS::Message.new(0) + m.add_answer('example.com', 0, Resolv::DNS::Resource::IN::SRV.new(0, 0, 0, 'www.example.com')) + assert_equal "\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x07example\x03com\x00\x00\x21\x00\x01\x00\x00\x00\x00\x00\x17\x00\x00\x00\x00\x00\x00\x03www\x07example\x03com\x00", m.encode, issue29 + end +end + +class TestResolvResourceCAA < Test::Unit::TestCase + def test_caa_roundtrip + raw_msg = "\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x03new\x07example\x03com\x00\x01\x01\x00\x01\x00\x00\x00\x00\x00\x16\x00\x05issueca1.example.net\xC0\x0C\x01\x01\x00\x01\x00\x00\x00\x00\x00\x0C\x80\x03tbsUnknown".b + + m = Resolv::DNS::Message.new(0) + m.add_answer('new.example.com', 0, Resolv::DNS::Resource::IN::CAA.new(0, 'issue', 'ca1.example.net')) + m.add_answer('new.example.com', 0, Resolv::DNS::Resource::IN::CAA.new(128, 'tbs', 'Unknown')) + assert_equal raw_msg, m.encode + + m = Resolv::DNS::Message.decode(raw_msg) + assert_equal 2, m.answer.size + _, _, caa0 = m.answer[0] + assert_equal 0, caa0.flags + assert_equal false, caa0.critical? + assert_equal 'issue', caa0.tag + assert_equal 'ca1.example.net', caa0.value + _, _, caa1 = m.answer[1] + assert_equal true, caa1.critical? + assert_equal 128, caa1.flags + assert_equal 'tbs', caa1.tag + assert_equal 'Unknown', caa1.value + end + + def test_caa_stackoverflow + # gathered in the wild + raw_msg = "\x8D\x32\x81\x80\x00\x01\x00\x0B\x00\x00\x00\x00\x0Dstackoverflow\x03com\x00\x01\x01\x00\x01\xC0\x0C\x01\x01\x00\x01\x00\x00\x01\x2C\x00\x13\x00\x05issuecomodoca.com\xC0\x0C\x01\x01\x00\x01\x00\x00\x01\x2C\x00\x2D\x00\x05issuedigicert.com; cansignhttpexchanges=yes\xC0\x0C\x01\x01\x00\x01\x00\x00\x01\x2C\x00\x16\x00\x05issueletsencrypt.org\xC0\x0C\x01\x01\x00\x01\x00\x00\x01\x2C\x00\x29\x00\x05issuepki.goog; cansignhttpexchanges=yes\xC0\x0C\x01\x01\x00\x01\x00\x00\x01\x2C\x00\x12\x00\x05issuesectigo.com\xC0\x0C\x01\x01\x00\x01\x00\x00\x01\x2C\x00\x17\x00\x09issuewildcomodoca.com\xC0\x0C\x01\x01\x00\x01\x00\x00\x01\x2C\x00\x31\x00\x09issuewilddigicert.com; cansignhttpexchanges=yes\xC0\x0C\x01\x01\x00\x01\x00\x00\x01\x2C\x00\x1A\x00\x09issuewildletsencrypt.org\xC0\x0C\x01\x01\x00\x01\x00\x00\x01\x2C\x00\x2D\x00\x09issuewildpki.goog; cansignhttpexchanges=yes\xC0\x0C\x01\x01\x00\x01\x00\x00\x01\x2C\x00\x16\x00\x09issuewildsectigo.com\xC0\x0C\x01\x01\x00\x01\x00\x00\x01\x2C\x00\x2D\x80\x05iodefmailto:sysadmin-team@stackoverflow.com".b + + m = Resolv::DNS::Message.decode(raw_msg) + assert_equal 11, m.answer.size + _, _, caa3 = m.answer[3] + assert_equal 0, caa3.flags + assert_equal 'issue', caa3.tag + assert_equal 'pki.goog; cansignhttpexchanges=yes', caa3.value + _, _, caa8 = m.answer[8] + assert_equal 0, caa8.flags + assert_equal 'issuewild', caa8.tag + assert_equal 'pki.goog; cansignhttpexchanges=yes', caa8.value + _, _, caa10 = m.answer[10] + assert_equal 128, caa10.flags + assert_equal 'iodef', caa10.tag + assert_equal 'mailto:sysadmin-team@stackoverflow.com', caa10.value + end + + def test_caa_flags + assert_equal 255, + Resolv::DNS::Resource::IN::CAA.new(255, 'issue', 'ca1.example.net').flags + assert_raise(ArgumentError) do + Resolv::DNS::Resource::IN::CAA.new(256, 'issue', 'ca1.example.net') + end + + assert_raise(ArgumentError) do + Resolv::DNS::Resource::IN::CAA.new(-1, 'issue', 'ca1.example.net') + end + end + + def test_caa_tag + assert_raise(ArgumentError, 'Empty tag should be rejected') do + Resolv::DNS::Resource::IN::CAA.new(0, '', 'ca1.example.net') + end + + assert_equal '123456789012345', + Resolv::DNS::Resource::IN::CAA.new(0, '123456789012345', 'ca1.example.net').tag + assert_raise(ArgumentError, 'Tag longer than 15 bytes should be rejected') do + Resolv::DNS::Resource::IN::CAA.new(0, '1234567890123456', 'ca1.example.net') + end + end end --- /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 --- /dev/null +++ b/test/resolv/test_svcb_https.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: false +require 'test/unit' +require 'resolv' + +class TestResolvSvcbHttps < Test::Unit::TestCase + # Wraps a RR in answer section + def wrap_rdata(rrtype, rrclass, rdata) + [ + "\x00\x00\x00\x00", # ID/FLAGS + [0, 1, 0, 0].pack('nnnn'), # QDCOUNT/ANCOUNT/NSCOUNT/ARCOUNT + "\x07example\x03com\x00", # NAME + [rrtype, rrclass, 0, rdata.bytesize].pack('nnNn'), # TYPE/CLASS/TTL/RDLENGTH + rdata, + ].join.b + end + + def test_svcparams + params = Resolv::DNS::SvcParams.new([Resolv::DNS::SvcParam::Mandatory.new([1])]) + + assert_equal 1, params.count + + params.add Resolv::DNS::SvcParam::NoDefaultALPN.new + params.add Resolv::DNS::SvcParam::ALPN.new(%w[h2 h3]) + + assert_equal 3, params.count + + assert_equal [1], params[:mandatory].keys + assert_equal [1], params[0].keys + + assert_equal %w[h2 h3], params[:alpn].protocol_ids + assert_equal %w[h2 h3], params[1].protocol_ids + + params.delete :mandatory + params.delete :alpn + + assert_equal 1, params.count + + assert_nil params[:mandatory] + assert_nil params[1] + + ary = params.each.to_a + + assert_instance_of Resolv::DNS::SvcParam::NoDefaultALPN, ary.first + end + + def test_svcb + rr = Resolv::DNS::Resource::IN::SVCB.new(0, 'example.com.') + + assert_equal 0, rr.priority + assert rr.alias_mode? + assert !rr.service_mode? + assert_equal Resolv::DNS::Name.create('example.com.'), rr.target + assert rr.params.empty? + + rr = Resolv::DNS::Resource::IN::SVCB.new(16, 'example.com.', [ + Resolv::DNS::SvcParam::ALPN.new(%w[h2 h3]), + ]) + + assert_equal 16, rr.priority + assert !rr.alias_mode? + assert rr.service_mode? + + assert_equal 1, rr.params.count + assert_instance_of Resolv::DNS::SvcParam::ALPN, rr.params[:alpn] + end + + def test_svcb_encode_order + msg = Resolv::DNS::Message.new(0) + msg.add_answer( + 'example.com.', 0, + Resolv::DNS::Resource::IN::SVCB.new(16, 'foo.example.org.', [ + Resolv::DNS::SvcParam::ALPN.new(%w[h2 h3-19]), + Resolv::DNS::SvcParam::Mandatory.new([4, 1]), + Resolv::DNS::SvcParam::IPv4Hint.new(['192.0.2.1']), + ]) + ) + + expected = wrap_rdata 64, 1, "\x00\x10\x03foo\x07example\x03org\x00" + + "\x00\x00\x00\x04\x00\x01\x00\x04" + + "\x00\x01\x00\x09\x02h2\x05h3-19" + + "\x00\x04\x00\x04\xc0\x00\x02\x01" + + assert_equal expected, msg.encode + end + + ## Test vectors from [RFC9460] + + def test_alias_mode + wire = wrap_rdata 65, 1, "\x00\x00\x03foo\x07example\x03com\x00" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 0, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.com.'), rr.target + assert_equal 0, rr.params.count + + assert_equal wire, msg.encode + end + + def test_target_name_is_root + wire = wrap_rdata 64, 1, "\x00\x01\x00" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 1, rr.priority + assert_equal Resolv::DNS::Name.create('.'), rr.target + assert_equal 0, rr.params.count + + assert_equal wire, msg.encode + end + + def test_specifies_port + wire = wrap_rdata 64, 1, "\x00\x10\x03foo\x07example\x03com\x00" + + "\x00\x03\x00\x02\x00\x35" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 16, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.com.'), rr.target + assert_equal 1, rr.params.count + assert_equal 53, rr.params[:port].port + + assert_equal wire, msg.encode + end + + def test_generic_key + wire = wrap_rdata 64, 1, "\x00\x01\x03foo\x07example\x03com\x00" + + "\x02\x9b\x00\x05hello" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 1, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.com.'), rr.target + assert_equal 1, rr.params.count + assert_equal 'hello', rr.params[:key667].value + + assert_equal wire, msg.encode + end + + def test_two_ipv6hints + wire = wrap_rdata 64, 1, "\x00\x01\x03foo\x07example\x03com\x00" + + "\x00\x06\x00\x20" + + ("\x20\x01\x0d\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" + + "\x20\x01\x0d\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x53\x00\x01") + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 1, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.com.'), rr.target + assert_equal 1, rr.params.count + assert_equal [Resolv::IPv6.create('2001:db8::1'), Resolv::IPv6.create('2001:db8::53:1')], + rr.params[:ipv6hint].addresses + + assert_equal wire, msg.encode + end + + def test_ipv6hint_embedded_ipv4 + wire = wrap_rdata 64, 1, "\x00\x01\x07example\x03com\x00" + + "\x00\x06\x00\x10\x20\x01\x0d\xb8\x01\x22\x03\x44\x00\x00\x00\x00\xc0\x00\x02\x21" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 1, rr.priority + assert_equal Resolv::DNS::Name.create('example.com.'), rr.target + assert_equal 1, rr.params.count + assert_equal [Resolv::IPv6.create('2001:db8:122:344::192.0.2.33')], + rr.params[:ipv6hint].addresses + + assert_equal wire, msg.encode + end + + def test_mandatory_alpn_ipv4hint + wire = wrap_rdata 64, 1, "\x00\x10\x03foo\x07example\x03org\x00" + + "\x00\x00\x00\x04\x00\x01\x00\x04" + + "\x00\x01\x00\x09\x02h2\x05h3-19" + + "\x00\x04\x00\x04\xc0\x00\x02\x01" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 16, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.org.'), rr.target + assert_equal 3, rr.params.count + assert_equal [1, 4], rr.params[:mandatory].keys + assert_equal ['h2', 'h3-19'], rr.params[:alpn].protocol_ids + assert_equal [Resolv::IPv4.create('192.0.2.1')], rr.params[:ipv4hint].addresses + + assert_equal wire, msg.encode + end + + def test_alpn_comma_backslash + wire = wrap_rdata 64, 1, "\x00\x10\x03foo\x07example\x03org\x00" + + "\x00\x01\x00\x0c\x08f\\oo,bar\x02h2" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 16, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.org.'), rr.target + assert_equal 1, rr.params.count + assert_equal ['f\oo,bar', 'h2'], rr.params[:alpn].protocol_ids + + assert_equal wire, msg.encode + end + + ## For [RFC9461] + + def test_dohpath + wire = wrap_rdata 64, 1, "\x00\x01\x03one\x03one\x03one\x03one\x00" + + "\x00\x01\x00\x03\x02h2" + + "\x00\x03\x00\x02\x01\xbb" + + "\x00\x04\x00\x08\x01\x01\x01\x01\x01\x00\x00\x01" + + "\x00\x06\x00\x20" + + ("\x26\x06\x47\x00\x47\x00\x00\x00\x00\x00\x00\x00\x00\x00\x11\x11" + + "\x26\x06\x47\x00\x47\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x01") + + "\x00\x07\x00\x10/dns-query{?dns}" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 1, rr.priority + assert_equal Resolv::DNS::Name.create('one.one.one.one.'), rr.target + assert_equal 5, rr.params.count + assert_equal ['h2'], rr.params[:alpn].protocol_ids + assert_equal 443, rr.params[:port].port + assert_equal [Resolv::IPv4.create('1.1.1.1'), Resolv::IPv4.create('1.0.0.1')], + rr.params[:ipv4hint].addresses + assert_equal [Resolv::IPv6.create('2606:4700:4700::1111'), Resolv::IPv6.create('2606:4700:4700::1001')], + rr.params[:ipv6hint].addresses + assert_equal '/dns-query{?dns}', rr.params[:dohpath].template + + assert_equal wire, msg.encode + end +end --- /dev/null +++ b/test/resolv/test_win32_config.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'test/unit' +require 'resolv' + +if defined?(Win32::Resolve) + class TestWin32Config < Test::Unit::TestCase + def test_get_item_property_string + # Test reading a string registry value + result = Win32::Resolv.send(:get_hosts_dir) + + # Should return a string (empty or with a path) + assert_instance_of String, result + end + + # Test reading a non-existent registry key + def test_nonexistent_key + assert_nil(Win32::Resolv.send(:tcpip_params) {|reg| reg.open('NonExistentKeyThatShouldNotExist')}) + end + + # Test reading a non-existent registry value + def test_nonexistent_value + assert_nil(Win32::Resolv.send(:tcpip_params) {|reg| reg.value('NonExistentKeyThatShouldNotExist')}) + end + end +end -- 2.55.0