# frozen_string_literal: true # Based on the `ruby-net-ldap` gem's `Net::LDAP::DN` # # For our purposes, this class is used to normalize DNs in order to allow proper # comparison. # # E.g. DNs should be compared case-insensitively (in basically all LDAP # implementations or setups), therefore we downcase every DN. ## # Objects of this class represent an LDAP DN ("Distinguished Name"). A DN # ("Distinguished Name") is a unique identifier for an entry within an LDAP # directory. It is made up of a number of other attributes strung together, # to identify the entry in the tree. # # Each attribute that makes up a DN needs to have its value escaped so that # the DN is valid. This class helps take care of that. # # A fully escaped DN needs to be unescaped when analysing its contents. This # class also helps take care of that. module Gitlab module Auth module Ldap class DN FormatError = Class.new(StandardError) MalformedError = Class.new(FormatError) UnsupportedError = Class.new(FormatError) def self.normalize_value(given_value) dummy_dn = "placeholder=#{given_value}" normalized_dn = new(*dummy_dn).to_normalized_s normalized_dn.delete_prefix('placeholder=') end ## # Initialize a DN, escaping as required. Pass in attributes in name/value # pairs. If there is a left over argument, it will be appended to the dn # without escaping (useful for a base string). # # Most uses of this class will be to escape a DN, rather than to parse it, # so storing the dn as an escaped String and parsing parts as required # with a state machine seems sensible. def initialize(*args) if args.length > 1 initialize_array(args) else initialize_string(args[0]) end end ## # Parse a DN into key value pairs using ASN from # https://www.rfc-editor.org/rfc/rfc2253 section 3. # rubocop:disable Metrics/AbcSize # rubocop:disable Metrics/CyclomaticComplexity # rubocop:disable Metrics/PerceivedComplexity def each_pair state = :key key = StringIO.new value = StringIO.new hex_buffer = "" @dn.each_char.with_index do |char, dn_index| case state when :key then case char when 'a'..'z', 'A'..'Z' then state = :key_normal key << char when '0'..'9' then state = :key_oid key << char when ' ' then state = :key else raise(MalformedError, "Unrecognized first character of an RDN attribute type name \"#{char}\"") end when :key_normal then case char when '=' then state = :value when 'a'..'z', 'A'..'Z', '0'..'9', '-', ' ' then key << char else raise(MalformedError, "Unrecognized RDN attribute type name character \"#{char}\"") end when :key_oid then case char when '=' then state = :value when '0'..'9', '.', ' ' then key << char else raise(MalformedError, "Unrecognized RDN OID attribute type name character \"#{char}\"") end when :value then case char when '\\' then state = :value_normal_escape when '"' then state = :value_quoted when ' ' then state = :value when '#' then state = :value_hexstring value << char when ',' then state = :key yield key.string.strip, rstrip_except_escaped(value.string, dn_index) key = StringIO.new value = StringIO.new else state = :value_normal value << char end when :value_normal then case char when '\\' then state = :value_normal_escape when ',' then state = :key yield key.string.strip, rstrip_except_escaped(value.string, dn_index) key = StringIO.new value = StringIO.new when '+' then raise(UnsupportedError, "Multivalued RDNs are not supported") else value << char end when :value_normal_escape then case char when '0'..'9', 'a'..'f', 'A'..'F' then state = :value_normal_escape_hex hex_buffer = char else state = :value_normal value << char end when :value_normal_escape_hex then case char when '0'..'9', 'a'..'f', 'A'..'F' then state = :value_normal value << "#{hex_buffer}#{char}".to_i(16).chr else raise(MalformedError, "Invalid escaped hex code \"\\#{hex_buffer}#{char}\"") end when :value_quoted then case char when '\\' then state = :value_quoted_escape when '"' then state = :value_end else value << char end when :value_quoted_escape then case char when '0'..'9', 'a'..'f', 'A'..'F' then state = :value_quoted_escape_hex hex_buffer = char else state = :value_quoted value << char end when :value_quoted_escape_hex then case char when '0'..'9', 'a'..'f', 'A'..'F' then state = :value_quoted value << "#{hex_buffer}#{char}".to_i(16).chr else raise(MalformedError, "Expected the second character of a hex pair inside a double quoted value, but got \"#{char}\"") end when :value_hexstring then case char when '0'..'9', 'a'..'f', 'A'..'F' then state = :value_hexstring_hex value << char when ' ' then state = :value_end when ',' then state = :key yield key.string.strip, rstrip_except_escaped(value.string, dn_index) key = StringIO.new value = StringIO.new else raise(MalformedError, "Expected the first character of a hex pair, but got \"#{char}\"") end when :value_hexstring_hex then case char when '0'..'9', 'a'..'f', 'A'..'F' then state = :value_hexstring value << char else raise(MalformedError, "Expected the second character of a hex pair, but got \"#{char}\"") end when :value_end then case char when ' ' then state = :value_end when ',' then state = :key yield key.string.strip, rstrip_except_escaped(value.string, dn_index) key = StringIO.new value = StringIO.new else raise(MalformedError, "Expected the end of an attribute value, but got \"#{char}\"") end else raise "Fell out of state machine" end end # Last pair raise(MalformedError, 'DN string ended unexpectedly') unless [:value, :value_normal, :value_hexstring, :value_end].include? state yield key.string.strip, rstrip_except_escaped(value.string, @dn.length) end def rstrip_except_escaped(str, dn_index) str_ends_with_whitespace = str.match(/\s\z/) if str_ends_with_whitespace dn_part_ends_with_escaped_whitespace = @dn[0, dn_index].match(/\\(\s+)\z/) if dn_part_ends_with_escaped_whitespace dn_part_rwhitespace = dn_part_ends_with_escaped_whitespace[1] num_chars_to_remove = dn_part_rwhitespace.length - 1 str = str[0, str.length - num_chars_to_remove] else str.rstrip! end end str end ## # Returns the DN as an array in the form expected by the constructor. def to_a a = [] self.each_pair { |key, value| a << key << value } unless @dn.empty? a end ## # Return the DN as an escaped string. def to_s @dn end ## # Return the DN as an escaped and normalized string. def to_normalized_s self.class.new(*to_a).to_s.downcase end # https://www.rfc-editor.org/rfc/rfc4514 section 2.4 lists these exceptions # for DN values. All of the following must be escaped in any normal string # using a single backslash ('\') as escape. The space character is left # out here because in a "normalized" string, spaces should only be escaped # if necessary (i.e. leading or trailing space). NORMAL_ESCAPES = [',', '+', '"', '\\', '<', '>', ';', '='].freeze # The following must be represented as escaped hex HEX_ESCAPES = { "\n" => '\0a', "\r" => '\0d' }.freeze # Compiled character class regexp using the keys from the above hash, and # checking for a space or # at the start, or space at the end, of the # string. ESCAPE_RE = Regexp.new("(^ |^#| $|[" + NORMAL_ESCAPES.map { |e| Regexp.escape(e) }.join + "])") HEX_ESCAPE_RE = Regexp.new("([" + HEX_ESCAPES.keys.map { |e| Regexp.escape(e) }.join + "])") ## # Escape a string for use in a DN value def self.escape(string) escaped = string.gsub(ESCAPE_RE) { |char| "\\" + char } escaped.gsub(HEX_ESCAPE_RE) { |char| HEX_ESCAPES[char] } end private def initialize_array(args) buffer = StringIO.new args.each_with_index do |arg, index| if index.even? # key buffer << "," if index > 0 buffer << arg else # value buffer << "=" buffer << self.class.escape(arg) end end @dn = buffer.string end def initialize_string(arg) @dn = arg.to_s end ## # Proxy all other requests to the string object, because a DN is mainly # used within the library as a string # rubocop:disable GitlabSecurity/PublicSend def method_missing(method, *args, &block) @dn.send(method, *args, &block) end ## # Redefined to be consistent with redefined `method_missing` behavior def respond_to?(sym, include_private = false) @dn.respond_to?(sym, include_private) end end end end end