1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410
|
# frozen_string_literal: true
require 'cgi'
require 'uri'
module CookieJar
# Represents a set of cookie validation errors
class InvalidCookieError < StandardError
# [Array<String>] the specific validation issues encountered
attr_reader :messages
# Create a new instance
# @param [String, Array<String>] the validation issue(s) encountered
def initialize(message)
if message.is_a? Array
@messages = message
message = message.join ', '
else
@messages = [message]
end
super message
end
end
# Contains logic to parse and validate cookie headers
module CookieValidation
# REGEX cookie matching
module PATTERN
include URI::REGEXP::PATTERN
TOKEN = '[^(),\/<>@;:\\\"\[\]?={}\s]+'.freeze
VALUE1 = '([^;]*)'.freeze
IPADDR = "#{IPV4ADDR}|#{IPV6ADDR}".freeze
BASE_HOSTNAME = "(?:#{DOMLABEL}\\.)(?:((?:(?:#{DOMLABEL}\\.)+(?:#{TOPLABEL}\\.?))|local))".freeze
QUOTED_PAIR = '\\\\[\\x00-\\x7F]'.freeze
LWS = '\\r\\n(?:[ \\t]+)'.freeze
# TEXT="[\\t\\x20-\\x7E\\x80-\\xFF]|(?:#{LWS})"
QDTEXT = "[\\t\\x20-\\x21\\x23-\\x7E\\x80-\\xFF]|(?:#{LWS})".freeze
QUOTED_TEXT = "\\\"(?:#{QDTEXT}|#{QUOTED_PAIR})*\\\"".freeze
VALUE2 = "#{TOKEN}|#{QUOTED_TEXT}".freeze
end
BASE_HOSTNAME = /#{PATTERN::BASE_HOSTNAME}/
BASE_PATH = %r{\A((?:[^/?#]*/)*)}
IPADDR = /\A#{PATTERN::IPV4ADDR}\Z|\A#{PATTERN::IPV6ADDR}\Z/
HDN = /\A#{PATTERN::HOSTNAME}\Z/
TOKEN = /\A#{PATTERN::TOKEN}\Z/
PARAM1 = /\A(#{PATTERN::TOKEN})(?:=#{PATTERN::VALUE1})?\Z/
PARAM2 = Regexp.new("(#{PATTERN::TOKEN})(?:=(#{PATTERN::VALUE2}))?(?:\\Z|;)", Regexp::NOENCODING)
# TWO_DOT_DOMAINS = /\A\.(com|edu|net|mil|gov|int|org)\Z/
# Converts the input object to a URI (if not already a URI)
#
# @param [String, URI] request_uri URI we are normalizing
# @param [URI] URI representation of input string, or original URI
def self.to_uri(request_uri)
(request_uri.is_a? URI) ? request_uri : (URI.parse request_uri)
end
# Converts an input cookie or uri to a string representing the path.
# Assume strings are already paths
#
# @param [String, URI, Cookie] object containing the path
# @return [String] path information
def self.to_path(uri_or_path)
if (uri_or_path.is_a? URI) || (uri_or_path.is_a? Cookie)
uri_or_path.path
else
uri_or_path
end
end
# Converts an input cookie or uri to a string representing the domain.
# Assume strings are already domains. Value may not be an effective host.
#
# @param [String, URI, Cookie] object containing the domain
# @return [String] domain information.
def self.to_domain(uri_or_domain)
if uri_or_domain.is_a? URI
uri_or_domain.host
elsif uri_or_domain.is_a? Cookie
uri_or_domain.domain
else
uri_or_domain
end
end
# Compare a tested domain against the base domain to see if they match, or
# if the base domain is reachable.
#
# @param [String] tested_domain domain to be tested against
# @param [String] base_domain new domain being tested
# @return [String,nil] matching domain on success, nil on failure
def self.domains_match(tested_domain, base_domain)
base = effective_host base_domain
search_domains = compute_search_domains_for_host base
search_domains.find do |domain|
domain == tested_domain
end
end
# Compute the reach of a hostname (RFC 2965, section 1)
# Determines the next highest superdomain
#
# @param [String,URI,Cookie] hostname hostname, or object holding hostname
# @return [String,nil] next highest hostname, or nil if none
def self.hostname_reach(hostname)
host = to_domain hostname
host = host.downcase
match = BASE_HOSTNAME.match host
match[1] if match
end
# Compute the base of a path, for default cookie path assignment
#
# @param [String, URI, Cookie] path, or object holding path
# @return base path (all characters up to final '/')
def self.cookie_base_path(path)
BASE_PATH.match(to_path(path))[1]
end
# Processes cookie path data using the following rules:
# Paths are separated by '/' characters, and accepted values are truncated
# to the last '/' character. If no path is specified in the cookie, a path
# value will be taken from the request URI which was used for the site.
#
# Note that this will not attempt to detect a mismatch of the request uri
# domain and explicitly specified cookie path
#
# @param [String,URI] request URI yielding this cookie
# @param [String] path on cookie
def self.determine_cookie_path(request_uri, cookie_path)
uri = to_uri request_uri
cookie_path = to_path cookie_path
if cookie_path.nil? || cookie_path.empty?
cookie_path = cookie_base_path uri.path
end
cookie_path
end
# Given a URI, compute the relevant search domains for pre-existing
# cookies. This includes all the valid dotted forms for a named or IP
# domains.
#
# @param [String, URI] request_uri requested uri
# @return [Array<String>] all cookie domain values which would match the
# requested uri
def self.compute_search_domains(request_uri)
uri = to_uri request_uri
return nil unless uri.is_a? URI::HTTP
host = uri.host
compute_search_domains_for_host host
end
# Given a host, compute the relevant search domains for pre-existing
# cookies
#
# @param [String] host host being requested
# @return [Array<String>] all cookie domain values which would match the
# requested uri
def self.compute_search_domains_for_host(host)
host = effective_host host
result = [host]
unless host =~ IPADDR
result << ".#{host}"
base = hostname_reach host
result << ".#{base}" if base
end
result
end
# Processes cookie domain data using the following rules:
# Domains strings of the form .foo.com match 'foo.com' and all immediate
# subdomains of 'foo.com'. Domain strings specified of the form 'foo.com'
# are modified to '.foo.com', and as such will still apply to subdomains.
#
# Cookies without an explicit domain will have their domain value taken
# directly from the URL, and will _NOT_ have any leading dot applied. For
# example, a request to http://foo.com/ will cause an entry for 'foo.com'
# to be created - which applies to foo.com but no subdomain.
#
# Note that this will not attempt to detect a mismatch of the request uri
# domain and explicitly specified cookie domain
#
# @param [String, URI] request_uri originally requested URI
# @param [String] cookie domain value
# @return [String] effective host
def self.determine_cookie_domain(request_uri, cookie_domain)
uri = to_uri request_uri
domain = to_domain cookie_domain
return effective_host(uri.host) if domain.nil? || domain.empty?
domain = domain.downcase
if domain =~ IPADDR || domain.start_with?('.')
domain
else
".#{domain}"
end
end
# Compute the effective host (RFC 2965, section 1)
#
# Has the added additional logic of searching for interior dots
# specifically, and matches colons to prevent .local being suffixed on
# IPv6 addresses
#
# @param [String, URI] host_or_uridomain name, or absolute URI
# @return [String] effective host per RFC rules
def self.effective_host(host_or_uri)
hostname = to_domain host_or_uri
hostname = hostname.downcase
if /.[\.:]./.match(hostname) || hostname == '.local'
hostname
else
hostname + '.local'
end
end
# Check whether a cookie meets all of the rules to be created, based on
# its internal settings and the URI it came from.
#
# @param [String,URI] request_uri originally requested URI
# @param [Cookie] cookie object
# @param [true] will always return true on success
# @raise [InvalidCookieError] on failures, containing all validation errors
def self.validate_cookie(request_uri, cookie)
uri = to_uri request_uri
request_path = uri.path
cookie_host = cookie.domain
cookie_path = cookie.path
errors = []
# From RFC 2965, Section 3.3.2 Rejecting Cookies
# A user agent rejects (SHALL NOT store its information) if the
# Version attribute is missing. Note that the legacy Set-Cookie
# directive will result in an implicit version 0.
errors << 'Version missing' unless cookie.version
# The value for the Path attribute is not a prefix of the request-URI
# If the initial request path is empty then this will always fail
# so check if it is empty and if so then set it to /
request_path = '/' if request_path == ''
unless request_path.start_with? cookie_path
errors << 'Path is not a prefix of the request uri path'
end
unless cookie_host =~ IPADDR || # is an IPv4 or IPv6 address
cookie_host =~ /.\../ || # contains an embedded dot
cookie_host == '.local' # is the domain cookie for local addresses
errors << 'Domain format is illegal'
end
# The effective host name that derives from the request-host does
# not domain-match the Domain attribute.
#
# The request-host is a HDN (not IP address) and has the form HD,
# where D is the value of the Domain attribute, and H is a string
# that contains one or more dots.
unless domains_match cookie_host, uri
errors << 'Domain is inappropriate based on request URI hostname'
end
# The Port attribute has a "port-list", and the request-port was
# not in the list.
unless cookie.ports.nil? || !cookie.ports.empty?
unless cookie.ports.find_index uri.port
errors << 'Ports list does not contain request URI port'
end
end
fail InvalidCookieError, errors unless errors.empty?
# Note: 'secure' is not explicitly defined as an SSL channel, and no
# test is defined around validity and the 'secure' attribute
true
end
# Break apart a traditional (non RFC 2965) cookie value into its core
# components. This does not do any validation, or defaulting of values
# based on requested URI
#
# @param [String] set_cookie_value a Set-Cookie header formatted cookie
# definition
# @return [Hash] Contains the parsed values of the cookie
def self.parse_set_cookie(set_cookie_value)
args = {}
params = set_cookie_value.split(/;\s*/)
first = true
params.each do |param|
result = PARAM1.match param
unless result
fail InvalidCookieError,
"Invalid cookie parameter in cookie '#{set_cookie_value}'"
end
key = result[1].downcase.to_sym
keyvalue = result[2]
if first
args[:name] = result[1]
args[:value] = keyvalue
first = false
else
case key
when :expires
begin
args[:expires_at] = Time.parse keyvalue
rescue ArgumentError
raise unless $ERROR_INFO.message == 'time out of range'
args[:expires_at] = Time.at(0x7FFFFFFF)
end
when :"max-age"
args[:max_age] = keyvalue.to_i
when :domain, :path
args[key] = keyvalue
when :secure
args[:secure] = true
when :httponly
args[:http_only] = true
when :samesite
args[:samesite] = keyvalue.downcase
else
fail InvalidCookieError, "Unknown cookie parameter '#{key}'"
end
end
end
args[:version] = 0
args
end
# Parse a RFC 2965 value and convert to a literal string
def self.value_to_string(value)
if /\A"(.*)"\Z/ =~ value
value = Regexp.last_match(1)
value.gsub(/\\(.)/, '\1')
else
value
end
end
# Attempt to decipher a partially decoded version of text cookie values
def self.decode_value(value)
if /\A"(.*)"\Z/ =~ value
value_to_string value
else
CGI.unescape value
end
end
# Break apart a RFC 2965 cookie value into its core components.
# This does not do any validation, or defaulting of values
# based on requested URI
#
# @param [String] set_cookie_value a Set-Cookie2 header formatted cookie
# definition
# @return [Hash] Contains the parsed values of the cookie
def self.parse_set_cookie2(set_cookie_value)
args = {}
first = true
index = 0
begin
md = PARAM2.match set_cookie_value[index..-1]
if md.nil? || md.offset(0).first != 0
fail InvalidCookieError,
"Invalid Set-Cookie2 header '#{set_cookie_value}'"
end
index += md.offset(0)[1]
key = md[1].downcase.to_sym
keyvalue = md[2] || md[3]
if first
args[:name] = md[1]
args[:value] = keyvalue
first = false
else
keyvalue = value_to_string keyvalue
case key
when :comment, :commenturl, :domain, :path
args[key] = keyvalue
when :discard, :secure
args[key] = true
when :httponly
args[:http_only] = true
when :"max-age"
args[:max_age] = keyvalue.to_i
when :version
args[:version] = keyvalue.to_i
when :port
# must be in format '"port,port"'
ports = keyvalue.split(/,\s*/)
args[:ports] = ports.map(&:to_i)
else
fail InvalidCookieError, "Unknown cookie parameter '#{key}'"
end
end
end until md.post_match.empty?
# if our last match in the scan failed
if args[:version] != 1
fail InvalidCookieError,
'Set-Cookie2 declares a non RFC2965 version cookie'
end
args
end
end
end
|