cookiejar-future 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,314 @@
1
+ require 'cookiejar/cookie'
2
+
3
+ module CookieJar
4
+ # A cookie store for client side usage.
5
+ # - Enforces cookie validity rules
6
+ # - Returns just the cookies valid for a given URI
7
+ # - Handles expiration of cookies
8
+ # - Allows for persistence of cookie data (with or without session)
9
+ #
10
+ #--
11
+ #
12
+ # Internal format:
13
+ #
14
+ # Internally, the data structure is a set of nested hashes.
15
+ # Domain Level:
16
+ # At the domain level, the hashes are of individual domains,
17
+ # down-cased and without any leading period. For instance, imagine cookies
18
+ # for .foo.com, .bar.com, and .auth.bar.com:
19
+ #
20
+ # {
21
+ # "foo.com" : (host data),
22
+ # "bar.com" : (host data),
23
+ # "auth.bar.com" : (host data)
24
+ # }
25
+ #
26
+ # Lookups are done both for the matching entry, and for an entry without
27
+ # the first segment up to the dot, ie. for /^\.?[^\.]+\.(.*)$/.
28
+ # A lookup of auth.bar.com would match both bar.com and
29
+ # auth.bar.com, but not entries for com or www.auth.bar.com.
30
+ #
31
+ # Host Level:
32
+ # Entries are in an hash, with keys of the path and values of a hash of
33
+ # cookie names to cookie object
34
+ #
35
+ # {
36
+ # "/" : {"session" : (Cookie object), "cart_id" : (Cookie object)}
37
+ # "/protected" : {"authentication" : (Cookie Object)}
38
+ # }
39
+ #
40
+ # Paths are given a straight prefix string comparison to match.
41
+ # Further filters <secure, http only, ports> are not represented in this
42
+ # hierarchy.
43
+ #
44
+ # Cookies returned are ordered solely by specificity (length) of the
45
+ # path.
46
+ class Jar
47
+ # Create a new empty Jar
48
+ def initialize
49
+ @domains = {}
50
+ end
51
+
52
+ # Given a request URI and a literal Set-Cookie header value, attempt to
53
+ # add the cookie(s) to the cookie store.
54
+ #
55
+ # @param [String, URI] request_uri the resource returning the header
56
+ # @param [String] cookie_header_value the contents of the Set-Cookie
57
+ # @return [Cookie] which was created and stored
58
+ # @raise [InvalidCookieError] if the cookie header did not validate
59
+ def set_cookie(request_uri, cookie_header_values)
60
+ cookie_header_values.split(/, (?=[\w]+=)/).each do |cookie_header_value|
61
+ cookie = Cookie.from_set_cookie request_uri, cookie_header_value
62
+ add_cookie cookie
63
+ end
64
+ end
65
+
66
+ # Given a request URI and a literal Set-Cookie2 header value, attempt to
67
+ # add the cookie to the cookie store.
68
+ #
69
+ # @param [String, URI] request_uri the resource returning the header
70
+ # @param [String] cookie_header_value the contents of the Set-Cookie2
71
+ # @return [Cookie] which was created and stored
72
+ # @raise [InvalidCookieError] if the cookie header did not validate
73
+ def set_cookie2(request_uri, cookie_header_value)
74
+ cookie = Cookie.from_set_cookie2 request_uri, cookie_header_value
75
+ add_cookie cookie
76
+ end
77
+
78
+ # Given a request URI and some HTTP headers, attempt to add the cookie(s)
79
+ # (from Set-Cookie or Set-Cookie2 headers) to the cookie store. If a
80
+ # cookie is defined (by equivalent name, domain, and path) via Set-Cookie
81
+ # and Set-Cookie2, the Set-Cookie version is ignored.
82
+ #
83
+ # @param [String, URI] request_uri the resource returning the header
84
+ # @param [Hash<String,[String,Array<String>]>] http_headers a Hash
85
+ # which may have a key of "Set-Cookie" or "Set-Cookie2", and values of
86
+ # either strings or arrays of strings
87
+ # @return [Array<Cookie>,nil] the cookies created, or nil if none found.
88
+ # @raise [InvalidCookieError] if one of the cookie headers contained
89
+ # invalid formatting or data
90
+ def set_cookies_from_headers(request_uri, http_headers)
91
+ set_cookie_key = http_headers.keys.detect { |k| /\ASet-Cookie\Z/i.match k }
92
+ cookies = gather_header_values http_headers[set_cookie_key] do |value|
93
+ begin
94
+ Cookie.from_set_cookie request_uri, value
95
+ rescue InvalidCookieError
96
+ end
97
+ end
98
+
99
+ set_cookie2_key = http_headers.keys.detect { |k| /\ASet-Cookie2\Z/i.match k }
100
+ cookies += gather_header_values(http_headers[set_cookie2_key]) do |value|
101
+ begin
102
+ Cookie.from_set_cookie2 request_uri, value
103
+ rescue InvalidCookieError
104
+ end
105
+ end
106
+
107
+ # build the list of cookies, using a Jar. Since Set-Cookie2 values
108
+ # come second, they will replace the Set-Cookie versions.
109
+ jar = Jar.new
110
+ cookies.each do |cookie|
111
+ jar.add_cookie cookie
112
+ end
113
+ cookies = jar.to_a
114
+
115
+ # now add them all to our own store.
116
+ cookies.each do |cookie|
117
+ add_cookie cookie
118
+ end
119
+ cookies
120
+ end
121
+
122
+ # Add a pre-existing cookie object to the jar.
123
+ #
124
+ # @param [Cookie] cookie a pre-existing cookie object
125
+ # @return [Cookie] the cookie added to the store
126
+ def add_cookie(cookie)
127
+ domain_paths = find_or_add_domain_for_cookie cookie
128
+ add_cookie_to_path domain_paths, cookie
129
+ cookie
130
+ end
131
+
132
+ # Return an array of all cookie objects in the jar
133
+ #
134
+ # @return [Array<Cookie>] all cookies. Includes any expired cookies
135
+ # which have not yet been removed with expire_cookies
136
+ def to_a
137
+ result = []
138
+ @domains.values.each do |paths|
139
+ paths.values.each do |cookies|
140
+ cookies.values.inject result, :<<
141
+ end
142
+ end
143
+ result
144
+ end
145
+
146
+ # Return a JSON 'object' for the various data values. Allows for
147
+ # persistence of the cookie information
148
+ #
149
+ # @param [Array] a options controlling output JSON text
150
+ # (usually a State and a depth)
151
+ # @return [String] JSON representation of object data
152
+ def to_json(*a)
153
+ {
154
+ 'json_class' => self.class.name,
155
+ 'cookies' => to_a.to_json(*a)
156
+ }.to_json(*a)
157
+ end
158
+
159
+ # Create a new Jar from a JSON-backed hash
160
+ #
161
+ # @param o [Hash] the expanded JSON object
162
+ # @return [CookieJar] a new CookieJar instance
163
+ def self.json_create(o)
164
+ o = JSON.parse(o) if o.is_a? String
165
+ o = o['cookies'] if o.is_a? Hash
166
+ cookies = o.inject([]) do |result, cookie_json|
167
+ result << (Cookie.json_create cookie_json)
168
+ end
169
+ from_a cookies
170
+ end
171
+
172
+ # Create a new Jar from an array of Cookie objects. Expired cookies
173
+ # will still be added to the archive, and conflicting cookies will
174
+ # be overwritten by the last cookie in the array.
175
+ #
176
+ # @param [Array<Cookie>] cookies array of cookie objects
177
+ # @return [CookieJar] a new CookieJar instance
178
+ def self.from_a(cookies)
179
+ jar = new
180
+ cookies.each do |cookie|
181
+ jar.add_cookie cookie
182
+ end
183
+ jar
184
+ end
185
+
186
+ # Look through the jar for any cookies which have passed their expiration
187
+ # date, or session cookies from a previous session
188
+ #
189
+ # @param session [Boolean] whether session cookies should be expired,
190
+ # or just cookies past their expiration date.
191
+ def expire_cookies(session = false)
192
+ @domains.delete_if do |_domain, paths|
193
+ paths.delete_if do |_path, cookies|
194
+ cookies.delete_if do |_cookie_name, cookie|
195
+ cookie.expired? || (session && cookie.session?)
196
+ end
197
+ cookies.empty?
198
+ end
199
+ paths.empty?
200
+ end
201
+ end
202
+
203
+ # Given a request URI, return a sorted list of Cookie objects. Cookies
204
+ # will be in order per RFC 2965 - sorted by longest path length, but
205
+ # otherwise unordered.
206
+ #
207
+ # @param [String, URI] request_uri the address the HTTP request will be
208
+ # sent to. This must be a full URI, i.e. must include the protocol,
209
+ # if you pass digi.ninja it will fail to find the domain, you must pass
210
+ # http://digi.ninja
211
+ # @param [Hash] opts options controlling returned cookies
212
+ # @option opts [Boolean] :script (false) Cookies marked HTTP-only will be
213
+ # ignored if true
214
+ # @return [Array<Cookie>] cookies which should be sent in the HTTP request
215
+ def get_cookies(request_uri, opts = {})
216
+ uri = to_uri request_uri
217
+ hosts = Cookie.compute_search_domains uri
218
+
219
+ return [] if hosts.nil?
220
+
221
+ path = if uri.path == ''
222
+ '/'
223
+ else
224
+ uri.path
225
+ end
226
+
227
+ results = []
228
+ hosts.each do |host|
229
+ domain = find_domain host
230
+ domain.each do |apath, cookies|
231
+ next unless path.start_with? apath
232
+ results += cookies.values.select do |cookie|
233
+ cookie.should_send? uri, opts[:script]
234
+ end
235
+ end
236
+ end
237
+ # Sort by path length, longest first
238
+ results.sort do |lhs, rhs|
239
+ rhs.path.length <=> lhs.path.length
240
+ end
241
+ end
242
+
243
+ # Given a request URI, return a string Cookie header.Cookies will be in
244
+ # order per RFC 2965 - sorted by longest path length, but otherwise
245
+ # unordered.
246
+ #
247
+ # @param [String, URI] request_uri the address the HTTP request will be
248
+ # sent to
249
+ # @param [Hash] opts options controlling returned cookies
250
+ # @option opts [Boolean] :script (false) Cookies marked HTTP-only will be
251
+ # ignored if true
252
+ # @return String value of the Cookie header which should be sent on the
253
+ # HTTP request
254
+ def get_cookie_header(request_uri, opts = {})
255
+ cookies = get_cookies request_uri, opts
256
+ ver = [[], []]
257
+ cookies.each do |cookie|
258
+ ver[cookie.version] << cookie
259
+ end
260
+ if ver[1].empty?
261
+ # can do a netscape-style cookie header, relish the opportunity
262
+ cookies.map(&:to_s).join ';'
263
+ else
264
+ # build a RFC 2965-style cookie header. Split the cookies into
265
+ # version 0 and 1 groups so that we can reuse the '$Version' header
266
+ result = ''
267
+ unless ver[0].empty?
268
+ result << '$Version=0;'
269
+ result << ver[0].map do |cookie|
270
+ (cookie.to_s 1, false)
271
+ end.join(';')
272
+ # separate version 0 and 1 with a comma
273
+ result << ','
274
+ end
275
+ result << '$Version=1;'
276
+ ver[1].map do |cookie|
277
+ result << (cookie.to_s 1, false)
278
+ end
279
+ result
280
+ end
281
+ end
282
+
283
+ protected
284
+
285
+ def gather_header_values(http_header_value, &_block)
286
+ result = []
287
+ if http_header_value.is_a? Array
288
+ http_header_value.each do |value|
289
+ result << yield(value)
290
+ end
291
+ elsif http_header_value.is_a? String
292
+ result << yield(http_header_value)
293
+ end
294
+ result.compact
295
+ end
296
+
297
+ def to_uri(request_uri)
298
+ (request_uri.is_a? URI) ? request_uri : (URI.parse request_uri)
299
+ end
300
+
301
+ def find_domain(host)
302
+ @domains[host] || {}
303
+ end
304
+
305
+ def find_or_add_domain_for_cookie(cookie)
306
+ @domains[cookie.domain] ||= {}
307
+ end
308
+
309
+ def add_cookie_to_path(paths, cookie)
310
+ path_entry = (paths[cookie.path] ||= {})
311
+ path_entry[cookie.name] = cookie
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module CookieJar
3
+ VERSION = '0.3.3'.freeze
4
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+ require 'spec_helper'
3
+
4
+ include CookieJar
5
+
6
+ FOO_URL = 'http://localhost/foo'.freeze
7
+ AMMO_URL = 'http://localhost/ammo'.freeze
8
+ NETSCAPE_SPEC_SET_COOKIE_HEADERS =
9
+ [['CUSTOMER=WILE_E_COYOTE; path=/; expires=Wednesday, 09-Nov-99 23:12:40 GMT',
10
+ FOO_URL],
11
+ ['PART_NUMBER=ROCKET_LAUNCHER_0001; path=/',
12
+ FOO_URL],
13
+ ['SHIPPING=FEDEX; path=/foo',
14
+ FOO_URL],
15
+ ['PART_NUMBER=ROCKET_LAUNCHER_0001; path=/',
16
+ FOO_URL],
17
+ ['PART_NUMBER=RIDING_ROCKET_0023; path=/ammo',
18
+ AMMO_URL]].freeze
19
+
20
+ describe Cookie do
21
+ describe '#from_set_cookie' do
22
+ it 'should handle cookies from the netscape spec' do
23
+ NETSCAPE_SPEC_SET_COOKIE_HEADERS.each do |value|
24
+ header, url = *value
25
+ Cookie.from_set_cookie url, header
26
+ end
27
+ end
28
+ it 'should give back the input names and values' do
29
+ cookie = Cookie.from_set_cookie 'http://localhost/', 'foo=bar'
30
+ expect(cookie.name).to eq 'foo'
31
+ expect(cookie.value).to eq 'bar'
32
+ end
33
+ it 'should normalize domain names' do
34
+ cookie = Cookie.from_set_cookie 'http://localhost/', 'foo=Bar;domain=LoCaLHoSt.local'
35
+ expect(cookie.domain).to eq '.localhost.local'
36
+ end
37
+ it 'should accept non-normalized .local' do
38
+ cookie = Cookie.from_set_cookie 'http://localhost/', 'foo=bar;domain=.local'
39
+ expect(cookie.domain).to eq '.local'
40
+ end
41
+ it 'should accept secure cookies' do
42
+ cookie = Cookie.from_set_cookie 'https://www.google.com/a/blah', 'GALX=RgmSftjnbPM;Path=/a/;Secure'
43
+ expect(cookie.name).to eq 'GALX'
44
+ expect(cookie.secure).to be_truthy
45
+ end
46
+ end
47
+ describe '#from_set_cookie2' do
48
+ it 'should give back the input names and values' do
49
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'foo=bar;Version=1'
50
+ expect(cookie.name).to eq 'foo'
51
+ expect(cookie.value).to eq 'bar'
52
+ end
53
+ it 'should normalize domain names' do
54
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'foo=Bar;domain=LoCaLHoSt.local;Version=1'
55
+ expect(cookie.domain).to eq '.localhost.local'
56
+ end
57
+ it 'should accept non-normalized .local' do
58
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'foo=bar;domain=.local;Version=1'
59
+ expect(cookie.domain).to eq '.local'
60
+ end
61
+ it 'should accept secure cookies' do
62
+ cookie = Cookie.from_set_cookie2 'https://www.google.com/a/blah', 'GALX=RgmSftjnbPM;Path="/a/";Secure;Version=1'
63
+ expect(cookie.name).to eq 'GALX'
64
+ expect(cookie.path).to eq '/a/'
65
+ expect(cookie.secure).to be_truthy
66
+ end
67
+ it 'should fail on unquoted paths' do
68
+ expect(lambda do
69
+ Cookie.from_set_cookie2 'https://www.google.com/a/blah',
70
+ 'GALX=RgmSftjnbPM;Path=/a/;Secure;Version=1'
71
+ end).to raise_error InvalidCookieError
72
+ end
73
+ it 'should accept quoted values' do
74
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'foo="bar";Version=1'
75
+ expect(cookie.name).to eq 'foo'
76
+ expect(cookie.value).to eq '"bar"'
77
+ end
78
+ it 'should accept poorly chosen names' do
79
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'Version=mine;Version=1'
80
+ expect(cookie.name).to eq 'Version'
81
+ expect(cookie.value).to eq 'mine'
82
+ end
83
+ it 'should accept quoted parameter values' do
84
+ Cookie.from_set_cookie2 'http://localhost/', 'foo=bar;Version="1"'
85
+ end
86
+ it 'should honor the discard and max-age parameters' do
87
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;max-age=100;discard;Version=1'
88
+ expect(cookie).to be_session
89
+ expect(cookie).to_not be_expired
90
+
91
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;max-age=100;Version=1'
92
+ expect(cookie).to_not be_session
93
+
94
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;Version=1'
95
+ expect(cookie).to be_session
96
+ end
97
+ it 'should handle quotable quotes' do
98
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f="\"";Version=1'
99
+ expect(cookie.value).to eq '"\""'
100
+ end
101
+ it 'should handle quotable apostrophes' do
102
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f="\;";Version=1'
103
+ expect(cookie.value).to eq '"\;"'
104
+ end
105
+ end
106
+ describe '#decoded_value' do
107
+ it 'should leave normal values alone' do
108
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;Version=1'
109
+ expect(cookie.decoded_value).to eq 'b'
110
+ end
111
+ it 'should attempt to unencode quoted values' do
112
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f="\"b";Version=1'
113
+ expect(cookie.value).to eq '"\"b"'
114
+ expect(cookie.decoded_value).to eq '"b'
115
+ end
116
+ end
117
+ describe '#to_s' do
118
+ it 'should handle a simple cookie' do
119
+ cookie = Cookie.from_set_cookie 'http://localhost/', 'f=b'
120
+ expect(cookie.to_s).to eq 'f=b'
121
+ expect(cookie.to_s(1)).to eq '$Version=0;f=b;$Path="/"'
122
+ end
123
+ it 'should report an explicit domain' do
124
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;Version=1;Domain=.local'
125
+ expect(cookie.to_s(1)).to eq '$Version=1;f=b;$Path="/";$Domain=.local'
126
+ end
127
+ it 'should return specified ports' do
128
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;Version=1;Port="80,443"'
129
+ expect(cookie.to_s(1)).to eq '$Version=1;f=b;$Path="/";$Port="80,443"'
130
+ end
131
+ it 'should handle specified paths' do
132
+ cookie = Cookie.from_set_cookie 'http://localhost/bar/', 'f=b;path=/bar/'
133
+ expect(cookie.to_s).to eq 'f=b'
134
+ expect(cookie.to_s(1)).to eq '$Version=0;f=b;$Path="/bar/"'
135
+ end
136
+ it 'should omit $Version header when asked' do
137
+ cookie = Cookie.from_set_cookie 'http://localhost/', 'f=b'
138
+ expect(cookie.to_s(1, false)).to eq 'f=b;$Path="/"'
139
+ end
140
+ end
141
+ describe '#should_send?' do
142
+ it 'should not send if ports do not match' do
143
+ cookie = Cookie.from_set_cookie2 'http://localhost/', 'f=b;Version=1;Port="80"'
144
+ expect(cookie.should_send?('http://localhost/', false)).to be_truthy
145
+ expect(cookie.should_send?('https://localhost/', false)).to be_falsey
146
+ end
147
+ end
148
+ begin
149
+ require 'json'
150
+ describe '.to_json' do
151
+ it 'should serialize a cookie to JSON' do
152
+ c = Cookie.from_set_cookie 'https://localhost/', 'foo=bar;secure;expires=Fri, September 11 2009 18:10:00 -0700'
153
+ json = c.to_json
154
+ expect(json).to be_a String
155
+ end
156
+ end
157
+ describe '.json_create' do
158
+ it 'should deserialize JSON to a cookie' do
159
+ json = '{"name":"foo","value":"bar","domain":"localhost.local","path":"\\/","created_at":"2009-09-11 12:51:03 -0600","expiry":"2009-09-11 19:10:00 -0600","secure":true}'
160
+ hash = JSON.parse json
161
+ c = Cookie.json_create hash
162
+ CookieValidation.validate_cookie 'https://localhost/', c
163
+ end
164
+ it 'should automatically deserialize to a cookie' do
165
+ json = '{"json_class":"CookieJar::Cookie","name":"foo","value":"bar","domain":"localhost.local","path":"\\/","created_at":"2009-09-11 12:51:03 -0600","expiry":"2009-09-11 19:10:00 -0600","secure":true}'
166
+ c = JSON.parse json, create_additions: true
167
+ expect(c).to be_a Cookie
168
+ CookieValidation.validate_cookie 'https://localhost/', c
169
+ end
170
+ end
171
+ rescue LoadError
172
+ it 'does not appear the JSON library is installed' do
173
+ raise 'please install the JSON library'
174
+ end
175
+ end
176
+ end