cookiejar2 0.3.4

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