cookiejar2 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.circleci/config.yml +15 -0
- data/.gitignore +8 -0
- data/.rspec +2 -0
- data/.travis.yml +17 -0
- data/Gemfile +2 -0
- data/LICENSE +10 -0
- data/README.markdown +37 -0
- data/Rakefile +30 -0
- data/_config.yml +1 -0
- data/contributors.json +14 -0
- data/cookiejar.gemspec +28 -0
- data/lib/cookiejar/cookie.rb +257 -0
- data/lib/cookiejar/cookie_validation.rb +410 -0
- data/lib/cookiejar/jar.rb +314 -0
- data/lib/cookiejar/version.rb +4 -0
- data/lib/cookiejar.rb +3 -0
- data/spec/cookie_spec.rb +176 -0
- data/spec/cookie_validation_spec.rb +236 -0
- data/spec/jar_spec.rb +232 -0
- data/spec/spec_helper.rb +9 -0
- metadata +139 -0
@@ -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
|