gitlab-org--gitlab-foss/lib/gitlab/ldap/dn.rb

274 lines
9.6 KiB
Ruby
Raw Normal View History

# -*- ruby encoding: utf-8 -*-
2017-09-20 14:01:11 -04:00
# 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.
2017-09-20 14:01:11 -04:00
module Gitlab
module LDAP
2017-09-20 19:57:45 -04:00
MalformedDnError = Class.new(StandardError)
UnsupportedDnFormatError = Class.new(StandardError)
2017-09-20 14:01:11 -04:00
class DN
##
# 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)
buffer = StringIO.new
2017-09-20 14:01:11 -04:00
args.each_index do |index|
buffer << "=" if index.odd?
buffer << "," if index.even? && index != 0
2017-09-20 14:49:04 -04:00
arg = args[index].downcase
buffer << if index < args.length - 1 || index.odd?
self.class.escape(arg)
else
arg
end
2017-09-20 14:01:11 -04:00
end
2017-09-20 14:01:11 -04:00
@dn = buffer.string
end
2017-09-20 14:01:11 -04:00
##
# Parse a DN into key value pairs using ASN from
# http://tools.ietf.org/html/rfc2253 section 3.
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/PerceivedComplexity
2017-09-20 14:01:11 -04:00
def each_pair
state = :key
key = StringIO.new
value = StringIO.new
hex_buffer = ""
2017-09-20 14:01:11 -04:00
@dn.each_char do |char|
case state
when :key then
case char
2017-09-20 14:49:04 -04:00
when 'a'..'z' then
2017-09-20 14:01:11 -04:00
state = :key_normal
key << char
when '0'..'9' then
state = :key_oid
key << char
when ' ' then state = :key
2017-09-20 19:57:45 -04:00
else raise(MalformedDnError, "Unrecognized first character of an RDN attribute type name \"#{char}\"")
2017-09-20 14:01:11 -04:00
end
when :key_normal then
case char
when '=' then state = :value
2017-09-20 14:49:04 -04:00
when 'a'..'z', '0'..'9', '-', ' ' then key << char
2017-09-20 19:57:45 -04:00
else raise(MalformedDnError, "Unrecognized RDN attribute type name character \"#{char}\"")
2017-09-20 14:01:11 -04:00
end
when :key_oid then
case char
when '=' then state = :value
when '0'..'9', '.', ' ' then key << char
2017-09-20 19:57:45 -04:00
else raise(MalformedDnError, "Unrecognized RDN OID attribute type name character \"#{char}\"")
2017-09-20 14:01:11 -04:00
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, value.string.rstrip
key = StringIO.new
value = StringIO.new
2017-09-20 14:01:11 -04:00
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, value.string.rstrip
key = StringIO.new
value = StringIO.new
when '+' then raise(UnsupportedDnFormatError, "Multivalued RDNs are not supported")
2017-09-20 14:01:11 -04:00
else value << char
end
when :value_normal_escape then
case char
2017-09-20 14:49:04 -04:00
when '0'..'9', 'a'..'f' then
2017-09-20 14:01:11 -04:00
state = :value_normal_escape_hex
hex_buffer = char
when /\s/ then
state = :value_normal_escape_whitespace
value << char
else
state = :value_normal
value << char
2017-09-20 14:01:11 -04:00
end
when :value_normal_escape_hex then
case char
2017-09-20 14:49:04 -04:00
when '0'..'9', 'a'..'f' then
2017-09-20 14:01:11 -04:00
state = :value_normal
value << "#{hex_buffer}#{char}".to_i(16).chr
2017-09-20 19:57:45 -04:00
else raise(MalformedDnError, "Invalid escaped hex code \"\\#{hex_buffer}#{char}\"")
2017-09-20 14:01:11 -04:00
end
2017-09-20 20:16:57 -04:00
when :value_normal_escape_whitespace then
2017-09-20 17:41:42 -04:00
case char
when '\\' then state = :value_normal_escape
when ',' then
state = :key
yield key.string.strip, value.string # Don't strip trailing escaped space!
key = StringIO.new
value = StringIO.new
when '+' then raise(UnsupportedDnFormatError, "Multivalued RDNs are not supported")
2017-09-20 17:41:42 -04:00
else value << char
end
2017-09-20 14:01:11 -04:00
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
2017-09-20 14:49:04 -04:00
when '0'..'9', 'a'..'f' then
2017-09-20 14:01:11 -04:00
state = :value_quoted_escape_hex
hex_buffer = char
else
state = :value_quoted
2017-09-20 14:01:11 -04:00
value << char
end
when :value_quoted_escape_hex then
case char
2017-09-20 14:49:04 -04:00
when '0'..'9', 'a'..'f' then
2017-09-20 14:01:11 -04:00
state = :value_quoted
value << "#{hex_buffer}#{char}".to_i(16).chr
2017-09-20 19:57:45 -04:00
else raise(MalformedDnError, "Expected the second character of a hex pair inside a double quoted value, but got \"#{char}\"")
2017-09-20 14:01:11 -04:00
end
when :value_hexstring then
case char
2017-09-20 14:49:04 -04:00
when '0'..'9', 'a'..'f' then
2017-09-20 14:01:11 -04:00
state = :value_hexstring_hex
value << char
when ' ' then state = :value_end
when ',' then
state = :key
yield key.string.strip, value.string.rstrip
key = StringIO.new
value = StringIO.new
2017-09-20 19:57:45 -04:00
else raise(MalformedDnError, "Expected the first character of a hex pair, but got \"#{char}\"")
2017-09-20 14:01:11 -04:00
end
when :value_hexstring_hex then
case char
2017-09-20 14:49:04 -04:00
when '0'..'9', 'a'..'f' then
2017-09-20 14:01:11 -04:00
state = :value_hexstring
value << char
2017-09-20 19:57:45 -04:00
else raise(MalformedDnError, "Expected the second character of a hex pair, but got \"#{char}\"")
2017-09-20 14:01:11 -04:00
end
when :value_end then
case char
when ' ' then state = :value_end
when ',' then
state = :key
yield key.string.strip, value.string.rstrip
key = StringIO.new
value = StringIO.new
2017-09-20 19:57:45 -04:00
else raise(MalformedDnError, "Expected the end of an attribute value, but got \"#{char}\"")
2017-09-20 14:01:11 -04:00
end
else raise "Fell out of state machine"
end
end
2017-09-20 14:01:11 -04:00
# Last pair
2017-09-20 19:57:45 -04:00
raise(MalformedDnError, 'DN string ended unexpectedly') unless
2017-09-20 14:01:11 -04:00
[:value, :value_normal, :value_hexstring, :value_end].include? state
2017-09-20 14:01:11 -04:00
yield key.string.strip, value.string.rstrip
end
2017-09-20 14:01:11 -04:00
##
# Returns the DN as an array in the form expected by the constructor.
def to_a
a = []
2017-09-20 18:29:45 -04:00
self.each_pair { |key, value| a << key << value } unless @dn.empty?
2017-09-20 14:01:11 -04:00
a
end
2017-09-20 14:01:11 -04:00
##
# Return the DN as an escaped string.
def to_s
@dn
end
2017-09-20 14:02:25 -04:00
##
# Return the DN as an escaped and normalized string.
def to_s_normalized
self.class.new(*to_a).to_s
end
2017-09-20 18:05:25 -04:00
# https://tools.ietf.org/html/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
2017-09-20 18:25:30 -04:00
# The following must be represented as escaped hex
HEX_ESCAPES = {
"\n" => '\0a',
"\r" => '\0d'
}.freeze
2017-09-20 18:25:30 -04:00
2017-09-20 14:01:11 -04:00
# 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 +
2017-09-20 14:01:11 -04:00
"])")
2017-09-20 18:25:30 -04:00
HEX_ESCAPE_RE = Regexp.new("([" +
HEX_ESCAPES.keys.map { |e| Regexp.escape(e) }.join +
"])")
2017-09-20 14:01:11 -04:00
##
# Escape a string for use in a DN value
def self.escape(string)
2017-09-20 18:25:30 -04:00
escaped = string.gsub(ESCAPE_RE) { |char| "\\" + char }
escaped.gsub(HEX_ESCAPE_RE) { |char| HEX_ESCAPES[char] }
2017-09-20 14:01:11 -04:00
end
2017-09-20 14:01:11 -04:00
##
# Proxy all other requests to the string object, because a DN is mainly
# used within the library as a string
# rubocop:disable GitlabSecurity/PublicSend
2017-09-20 14:01:11 -04:00
def method_missing(method, *args, &block)
@dn.send(method, *args, &block)
end
end
end
end