Module: CookieJar::CookieValidation

Defined in:
lib/cookiejar/cookie_validation.rb

Overview

Contains logic to parse and validate cookie headers

Defined Under Namespace

Modules: PATTERN

Constant Summary collapse

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|;)", '', 'n'

Class Method Summary collapse

Class Method Details

.compute_search_domains(request_uri) ⇒ Array<String>

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.

Parameters:

  • request_uri (String, URI)

    requested uri

Returns:

  • (Array<String>)

    all cookie domain values which would match the requested uri


148
149
150
151
152
153
# File 'lib/cookiejar/cookie_validation.rb', line 148

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

.compute_search_domains_for_host(host) ⇒ Array<String>

Given a host, compute the relevant search domains for pre-existing cookies

Parameters:

  • host (String)

    host being requested

Returns:

  • (Array<String>)

    all cookie domain values which would match the requested uri


161
162
163
164
165
166
167
168
169
170
# File 'lib/cookiejar/cookie_validation.rb', line 161

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

Compute the base of a path, for default cookie path assignment

Parameters:

  • path, (String, URI, Cookie)

    or object holding path

Returns:

  • base path (all characters up to final '/')


117
118
119
# File 'lib/cookiejar/cookie_validation.rb', line 117

def self.cookie_base_path(path)
  BASE_PATH.match(to_path(path))[1]
end

.decode_value(value) ⇒ Object

Attempt to decipher a partially decoded version of text cookie values


346
347
348
349
350
351
352
# File 'lib/cookiejar/cookie_validation.rb', line 346

def self.decode_value(value)
  if /\A"(.*)"\Z/ =~ value
    value_to_string value
  else
    CGI.unescape value
  end
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 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

Parameters:

  • request_uri (String, URI)

    originally requested URI

  • cookie (String)

    domain value

Returns:

  • (String)

    effective host


188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/cookiejar/cookie_validation.rb', line 188

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

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

Parameters:

  • request (String, URI)

    URI yielding this cookie

  • path (String)

    on cookie


131
132
133
134
135
136
137
138
139
# File 'lib/cookiejar/cookie_validation.rb', line 131

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

.domains_match(tested_domain, base_domain) ⇒ String?

Compare a tested domain against the base domain to see if they match, or if the base domain is reachable.

Parameters:

  • tested_domain (String)

    domain to be tested against

  • base_domain (String)

    new domain being tested

Returns:

  • (String, nil)

    matching domain on success, nil on failure


93
94
95
96
97
98
99
# File 'lib/cookiejar/cookie_validation.rb', line 93

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

.effective_host(host_or_uri) ⇒ String

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

Parameters:

  • host_or_uridomain (String, URI)

    name, or absolute URI

Returns:

  • (String)

    effective host per RFC rules


209
210
211
212
213
214
215
216
217
218
# File 'lib/cookiejar/cookie_validation.rb', line 209

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

.hostname_reach(hostname) ⇒ String?

Compute the reach of a hostname (RFC 2965, section 1) Determines the next highest superdomain

Parameters:

  • hostname (String, URI, Cookie)

    hostname, or object holding hostname

Returns:

  • (String, nil)

    next highest hostname, or nil if none


106
107
108
109
110
111
# File 'lib/cookiejar/cookie_validation.rb', line 106

def self.hostname_reach(hostname)
  host = to_domain hostname
  host = host.downcase
  match = BASE_HOSTNAME.match host
  match[1] if match
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

Parameters:

  • set_cookie_value (String)

    a Set-Cookie header formatted cookie definition

Returns:

  • (Hash)

    Contains the parsed values of the cookie


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
# File 'lib/cookiejar/cookie_validation.rb', line 290

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_set_cookie2(set_cookie_value) ⇒ Hash

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

Parameters:

  • set_cookie_value (String)

    a Set-Cookie2 header formatted cookie definition

Returns:

  • (Hash)

    Contains the parsed values of the cookie


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
# File 'lib/cookiejar/cookie_validation.rb', line 361

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

.to_domain(uri_or_domain) ⇒ String

Converts an input cookie or uri to a string representing the domain. Assume strings are already domains. Value may not be an effective host.

Parameters:

  • object (String, URI, Cookie)

    containing the domain

Returns:

  • (String)

    domain information.


77
78
79
80
81
82
83
84
85
# File 'lib/cookiejar/cookie_validation.rb', line 77

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

.to_path(uri_or_path) ⇒ String

Converts an input cookie or uri to a string representing the path. Assume strings are already paths

Parameters:

  • object (String, URI, Cookie)

    containing the path

Returns:

  • (String)

    path information


64
65
66
67
68
69
70
# File 'lib/cookiejar/cookie_validation.rb', line 64

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

.to_uri(request_uri) ⇒ Object

Converts the input object to a URI (if not already a URI)

Parameters:

  • request_uri (String, URI)

    URI we are normalizing

  • URI (URI)

    representation of input string, or original URI


55
56
57
# File 'lib/cookiejar/cookie_validation.rb', line 55

def self.to_uri(request_uri)
  (request_uri.is_a? URI) ? request_uri : (URI.parse request_uri)
end

Check whether a cookie meets all of the rules to be created, based on its internal settings and the URI it came from.

Parameters:

  • request_uri (String, URI)

    originally requested URI

  • cookie (Cookie)

    object

  • will (true)

    always return true on success

Raises:


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
# File 'lib/cookiejar/cookie_validation.rb', line 227

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

.value_to_string(value) ⇒ Object

Parse a RFC 2965 value and convert to a literal string


336
337
338
339
340
341
342
343
# File 'lib/cookiejar/cookie_validation.rb', line 336

def self.value_to_string(value)
  if /\A"(.*)"\Z/ =~ value
    value = Regexp.last_match(1)
    value.gsub(/\\(.)/, '\1')
  else
    value
  end
end