rack 2.0.6 → 2.0.8
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of rack might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/lib/rack.rb +1 -1
- data/lib/rack/multipart/parser.rb +6 -9
- data/lib/rack/request.rb +1 -1
- data/lib/rack/session/abstract/id.rb +66 -1
- data/lib/rack/session/cookie.rb +11 -2
- data/lib/rack/session/memcache.rb +20 -14
- data/lib/rack/session/pool.rb +13 -6
- data/test/spec_request.rb +10 -1
- data/test/spec_session_memcache.rb +40 -3
- data/test/spec_session_pool.rb +40 -3
- metadata +3 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e9142cfb8ba777286d8118d2b094a23c1fe4698b302c99966cb80670041c67f5
|
4
|
+
data.tar.gz: 341991ef42232bfecf702d98a17e671ffbbf6a95a8ff70bcca40f7c9aa9f5e85
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 012e3ac8b25a2fa3c75e7cfbed5f5f4875010ecf96ee887aa9d4ed844badc614fa7decf04bfb889983004d0c310780763e8f9328c4dfb5243388a086f05ef059
|
7
|
+
data.tar.gz: 312252b7e153667c49c11fea9bdceaf679813d6e3176c6e8599fbe4741c326abc43510c3c0a48136a36d2027393e8286f11771a5c353c68269088a6f606b8372
|
data/lib/rack.rb
CHANGED
@@ -39,8 +39,6 @@ module Rack
|
|
39
39
|
str
|
40
40
|
end
|
41
41
|
|
42
|
-
def eof?; @content_length == @cursor; end
|
43
|
-
|
44
42
|
def rewind
|
45
43
|
@io.rewind
|
46
44
|
end
|
@@ -65,11 +63,11 @@ module Rack
|
|
65
63
|
io = BoundedIO.new(io, content_length) if content_length
|
66
64
|
|
67
65
|
parser = new(boundary, tmpfile, bufsize, qp)
|
68
|
-
parser.on_read io.read(bufsize)
|
66
|
+
parser.on_read io.read(bufsize)
|
69
67
|
|
70
68
|
loop do
|
71
69
|
break if parser.state == :DONE
|
72
|
-
parser.on_read io.read(bufsize)
|
70
|
+
parser.on_read io.read(bufsize)
|
73
71
|
end
|
74
72
|
|
75
73
|
io.rewind
|
@@ -181,8 +179,8 @@ module Rack
|
|
181
179
|
@collector = Collector.new tempfile
|
182
180
|
end
|
183
181
|
|
184
|
-
def on_read content
|
185
|
-
handle_empty_content!(content
|
182
|
+
def on_read content
|
183
|
+
handle_empty_content!(content)
|
186
184
|
@buf << content
|
187
185
|
run_parser
|
188
186
|
end
|
@@ -358,10 +356,9 @@ module Rack
|
|
358
356
|
end
|
359
357
|
|
360
358
|
|
361
|
-
def handle_empty_content!(content
|
359
|
+
def handle_empty_content!(content)
|
362
360
|
if content.nil? || content.empty?
|
363
|
-
raise EOFError
|
364
|
-
return true
|
361
|
+
raise EOFError
|
365
362
|
end
|
366
363
|
end
|
367
364
|
end
|
data/lib/rack/request.rb
CHANGED
@@ -261,7 +261,7 @@ module Rack
|
|
261
261
|
|
262
262
|
forwarded_ips = split_ip_addresses(get_header('HTTP_X_FORWARDED_FOR'))
|
263
263
|
|
264
|
-
return reject_trusted_ip_addresses(forwarded_ips).last || get_header("REMOTE_ADDR")
|
264
|
+
return reject_trusted_ip_addresses(forwarded_ips).last || forwarded_ips.first || get_header("REMOTE_ADDR")
|
265
265
|
end
|
266
266
|
|
267
267
|
# The media type (type/subtype) portion of the CONTENT_TYPE header
|
@@ -6,11 +6,38 @@ require 'time'
|
|
6
6
|
require 'rack/request'
|
7
7
|
require 'rack/response'
|
8
8
|
require 'securerandom'
|
9
|
+
require 'digest/sha2'
|
9
10
|
|
10
11
|
module Rack
|
11
12
|
|
12
13
|
module Session
|
13
14
|
|
15
|
+
class SessionId
|
16
|
+
ID_VERSION = 2
|
17
|
+
|
18
|
+
attr_reader :public_id
|
19
|
+
|
20
|
+
def initialize(public_id)
|
21
|
+
@public_id = public_id
|
22
|
+
end
|
23
|
+
|
24
|
+
def private_id
|
25
|
+
"#{ID_VERSION}::#{hash_sid(public_id)}"
|
26
|
+
end
|
27
|
+
|
28
|
+
alias :cookie_value :public_id
|
29
|
+
|
30
|
+
def empty?; false; end
|
31
|
+
def to_s; raise; end
|
32
|
+
def inspect; public_id.inspect; end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def hash_sid(sid)
|
37
|
+
Digest::SHA256.hexdigest(sid)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
14
41
|
module Abstract
|
15
42
|
# SessionHash is responsible to lazily load the session from store.
|
16
43
|
|
@@ -357,7 +384,7 @@ module Rack
|
|
357
384
|
req.get_header(RACK_ERRORS).puts("Deferring cookie for #{session_id}") if $VERBOSE
|
358
385
|
else
|
359
386
|
cookie = Hash.new
|
360
|
-
cookie[:value] = data
|
387
|
+
cookie[:value] = cookie_value(data)
|
361
388
|
cookie[:expires] = Time.now + options[:expire_after] if options[:expire_after]
|
362
389
|
cookie[:expires] = Time.now + options[:max_age] if options[:max_age]
|
363
390
|
set_cookie(req, res, cookie.merge!(options))
|
@@ -365,6 +392,10 @@ module Rack
|
|
365
392
|
end
|
366
393
|
public :commit_session
|
367
394
|
|
395
|
+
def cookie_value(data)
|
396
|
+
data
|
397
|
+
end
|
398
|
+
|
368
399
|
# Sets the cookie back to the client with session id. We skip the cookie
|
369
400
|
# setting if the value didn't change (sid is the same) or expires was given.
|
370
401
|
|
@@ -406,6 +437,40 @@ module Rack
|
|
406
437
|
end
|
407
438
|
end
|
408
439
|
|
440
|
+
class PersistedSecure < Persisted
|
441
|
+
class SecureSessionHash < SessionHash
|
442
|
+
def [](key)
|
443
|
+
if key == "session_id"
|
444
|
+
load_for_read!
|
445
|
+
id.public_id
|
446
|
+
else
|
447
|
+
super
|
448
|
+
end
|
449
|
+
end
|
450
|
+
end
|
451
|
+
|
452
|
+
def generate_sid(*)
|
453
|
+
public_id = super
|
454
|
+
|
455
|
+
SessionId.new(public_id)
|
456
|
+
end
|
457
|
+
|
458
|
+
def extract_session_id(*)
|
459
|
+
public_id = super
|
460
|
+
public_id && SessionId.new(public_id)
|
461
|
+
end
|
462
|
+
|
463
|
+
private
|
464
|
+
|
465
|
+
def session_class
|
466
|
+
SecureSessionHash
|
467
|
+
end
|
468
|
+
|
469
|
+
def cookie_value(data)
|
470
|
+
data.cookie_value
|
471
|
+
end
|
472
|
+
end
|
473
|
+
|
409
474
|
class ID < Persisted
|
410
475
|
def self.inherited(klass)
|
411
476
|
k = klass.ancestors.find { |kl| kl.respond_to?(:superclass) && kl.superclass == ID }
|
data/lib/rack/session/cookie.rb
CHANGED
@@ -45,7 +45,7 @@ module Rack
|
|
45
45
|
# })
|
46
46
|
#
|
47
47
|
|
48
|
-
class Cookie < Abstract::
|
48
|
+
class Cookie < Abstract::PersistedSecure
|
49
49
|
# Encode session cookies as Base64
|
50
50
|
class Base64
|
51
51
|
def encode(str)
|
@@ -153,6 +153,15 @@ module Rack
|
|
153
153
|
data
|
154
154
|
end
|
155
155
|
|
156
|
+
class SessionId < DelegateClass(Session::SessionId)
|
157
|
+
attr_reader :cookie_value
|
158
|
+
|
159
|
+
def initialize(session_id, cookie_value)
|
160
|
+
super(session_id)
|
161
|
+
@cookie_value = cookie_value
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
156
165
|
def write_session(req, session_id, session, options)
|
157
166
|
session = session.merge("session_id" => session_id)
|
158
167
|
session_data = coder.encode(session)
|
@@ -165,7 +174,7 @@ module Rack
|
|
165
174
|
req.get_header(RACK_ERRORS).puts("Warning! Rack::Session::Cookie data size exceeds 4K.")
|
166
175
|
nil
|
167
176
|
else
|
168
|
-
session_data
|
177
|
+
SessionId.new(session_id, session_data)
|
169
178
|
end
|
170
179
|
end
|
171
180
|
|
@@ -19,7 +19,7 @@ module Rack
|
|
19
19
|
# Note that memcache does drop data before it may be listed to expire. For
|
20
20
|
# a full description of behaviour, please see memcache's documentation.
|
21
21
|
|
22
|
-
class Memcache < Abstract::
|
22
|
+
class Memcache < Abstract::PersistedSecure
|
23
23
|
attr_reader :mutex, :pool
|
24
24
|
|
25
25
|
DEFAULT_OPTIONS = Abstract::ID::DEFAULT_OPTIONS.merge \
|
@@ -42,15 +42,15 @@ module Rack
|
|
42
42
|
def generate_sid
|
43
43
|
loop do
|
44
44
|
sid = super
|
45
|
-
break sid unless @pool.get(sid, true)
|
45
|
+
break sid unless @pool.get(sid.private_id, true)
|
46
46
|
end
|
47
47
|
end
|
48
48
|
|
49
|
-
def
|
50
|
-
with_lock(
|
51
|
-
unless sid and session =
|
49
|
+
def find_session(req, sid)
|
50
|
+
with_lock(req) do
|
51
|
+
unless sid and session = get_session_with_fallback(sid)
|
52
52
|
sid, session = generate_sid, {}
|
53
|
-
unless /^STORED/ =~ @pool.add(sid, session)
|
53
|
+
unless /^STORED/ =~ @pool.add(sid.private_id, session)
|
54
54
|
raise "Session collision on '#{sid.inspect}'"
|
55
55
|
end
|
56
56
|
end
|
@@ -58,25 +58,26 @@ module Rack
|
|
58
58
|
end
|
59
59
|
end
|
60
60
|
|
61
|
-
def
|
61
|
+
def write_session(req, session_id, new_session, options)
|
62
62
|
expiry = options[:expire_after]
|
63
63
|
expiry = expiry.nil? ? 0 : expiry + 1
|
64
64
|
|
65
|
-
with_lock(
|
66
|
-
@pool.set session_id, new_session, expiry
|
65
|
+
with_lock(req) do
|
66
|
+
@pool.set session_id.private_id, new_session, expiry
|
67
67
|
session_id
|
68
68
|
end
|
69
69
|
end
|
70
70
|
|
71
|
-
def
|
72
|
-
with_lock(
|
73
|
-
@pool.delete(session_id)
|
71
|
+
def delete_session(req, session_id, options)
|
72
|
+
with_lock(req) do
|
73
|
+
@pool.delete(session_id.public_id)
|
74
|
+
@pool.delete(session_id.private_id)
|
74
75
|
generate_sid unless options[:drop]
|
75
76
|
end
|
76
77
|
end
|
77
78
|
|
78
|
-
def with_lock(
|
79
|
-
@mutex.lock if
|
79
|
+
def with_lock(req)
|
80
|
+
@mutex.lock if req.multithread?
|
80
81
|
yield
|
81
82
|
rescue MemCache::MemCacheError, Errno::ECONNREFUSED
|
82
83
|
if $VERBOSE
|
@@ -88,6 +89,11 @@ module Rack
|
|
88
89
|
@mutex.unlock if @mutex.locked?
|
89
90
|
end
|
90
91
|
|
92
|
+
private
|
93
|
+
|
94
|
+
def get_session_with_fallback(sid)
|
95
|
+
@pool.get(sid.private_id) || @pool.get(sid.public_id)
|
96
|
+
end
|
91
97
|
end
|
92
98
|
end
|
93
99
|
end
|
data/lib/rack/session/pool.rb
CHANGED
@@ -24,7 +24,7 @@ module Rack
|
|
24
24
|
# )
|
25
25
|
# Rack::Handler::WEBrick.run sessioned
|
26
26
|
|
27
|
-
class Pool < Abstract::
|
27
|
+
class Pool < Abstract::PersistedSecure
|
28
28
|
attr_reader :mutex, :pool
|
29
29
|
DEFAULT_OPTIONS = Abstract::ID::DEFAULT_OPTIONS.merge :drop => false
|
30
30
|
|
@@ -37,15 +37,15 @@ module Rack
|
|
37
37
|
def generate_sid
|
38
38
|
loop do
|
39
39
|
sid = super
|
40
|
-
break sid unless @pool.key? sid
|
40
|
+
break sid unless @pool.key? sid.private_id
|
41
41
|
end
|
42
42
|
end
|
43
43
|
|
44
44
|
def find_session(req, sid)
|
45
45
|
with_lock(req) do
|
46
|
-
unless sid and session =
|
46
|
+
unless sid and session = get_session_with_fallback(sid)
|
47
47
|
sid, session = generate_sid, {}
|
48
|
-
@pool.store sid, session
|
48
|
+
@pool.store sid.private_id, session
|
49
49
|
end
|
50
50
|
[sid, session]
|
51
51
|
end
|
@@ -53,14 +53,15 @@ module Rack
|
|
53
53
|
|
54
54
|
def write_session(req, session_id, new_session, options)
|
55
55
|
with_lock(req) do
|
56
|
-
@pool.store session_id, new_session
|
56
|
+
@pool.store session_id.private_id, new_session
|
57
57
|
session_id
|
58
58
|
end
|
59
59
|
end
|
60
60
|
|
61
61
|
def delete_session(req, session_id, options)
|
62
62
|
with_lock(req) do
|
63
|
-
@pool.delete(session_id)
|
63
|
+
@pool.delete(session_id.public_id)
|
64
|
+
@pool.delete(session_id.private_id)
|
64
65
|
generate_sid unless options[:drop]
|
65
66
|
end
|
66
67
|
end
|
@@ -71,6 +72,12 @@ module Rack
|
|
71
72
|
ensure
|
72
73
|
@mutex.unlock if @mutex.locked?
|
73
74
|
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def get_session_with_fallback(sid)
|
79
|
+
@pool[sid.private_id] || @pool[sid.public_id]
|
80
|
+
end
|
74
81
|
end
|
75
82
|
end
|
76
83
|
end
|
data/test/spec_request.rb
CHANGED
@@ -1286,7 +1286,16 @@ EOF
|
|
1286
1286
|
res.body.must_equal '2.2.2.3'
|
1287
1287
|
end
|
1288
1288
|
|
1289
|
-
it "
|
1289
|
+
it "preserves ip for trusted proxy chain" do
|
1290
|
+
mock = Rack::MockRequest.new(Rack::Lint.new(ip_app))
|
1291
|
+
res = mock.get '/',
|
1292
|
+
'HTTP_X_FORWARDED_FOR' => '192.168.0.11, 192.168.0.7',
|
1293
|
+
'HTTP_CLIENT_IP' => '127.0.0.1'
|
1294
|
+
res.body.must_equal '192.168.0.11'
|
1295
|
+
|
1296
|
+
end
|
1297
|
+
|
1298
|
+
it "regards local addresses as proxies" do
|
1290
1299
|
req = make_request(Rack::MockRequest.env_for("/"))
|
1291
1300
|
req.trusted_proxy?('127.0.0.1').must_equal 0
|
1292
1301
|
req.trusted_proxy?('10.0.0.1').must_equal 0
|
@@ -226,15 +226,52 @@ begin
|
|
226
226
|
req = Rack::MockRequest.new(pool)
|
227
227
|
|
228
228
|
res0 = req.get("/")
|
229
|
-
session_id = (cookie = res0["Set-Cookie"])[session_match, 1]
|
230
|
-
ses0 = pool.pool.get(session_id, true)
|
229
|
+
session_id = Rack::Session::SessionId.new (cookie = res0["Set-Cookie"])[session_match, 1]
|
230
|
+
ses0 = pool.pool.get(session_id.private_id, true)
|
231
231
|
|
232
232
|
req.get("/", "HTTP_COOKIE" => cookie)
|
233
|
-
ses1 = pool.pool.get(session_id, true)
|
233
|
+
ses1 = pool.pool.get(session_id.private_id, true)
|
234
234
|
|
235
235
|
ses1.wont_equal ses0
|
236
236
|
end
|
237
237
|
|
238
|
+
it "can read the session with the legacy id" do
|
239
|
+
pool = Rack::Session::Memcache.new(incrementor)
|
240
|
+
req = Rack::MockRequest.new(pool)
|
241
|
+
|
242
|
+
res0 = req.get("/")
|
243
|
+
cookie = res0["Set-Cookie"]
|
244
|
+
session_id = Rack::Session::SessionId.new cookie[session_match, 1]
|
245
|
+
ses0 = pool.pool.get(session_id.private_id, true)
|
246
|
+
pool.pool.set(session_id.public_id, ses0, 0, true)
|
247
|
+
pool.pool.delete(session_id.private_id)
|
248
|
+
|
249
|
+
res1 = req.get("/", "HTTP_COOKIE" => cookie)
|
250
|
+
res1["Set-Cookie"].must_be_nil
|
251
|
+
res1.body.must_equal '{"counter"=>2}'
|
252
|
+
pool.pool.get(session_id.private_id, true).wont_be_nil
|
253
|
+
end
|
254
|
+
|
255
|
+
it "drops the session in the legacy id as well" do
|
256
|
+
pool = Rack::Session::Memcache.new(incrementor)
|
257
|
+
req = Rack::MockRequest.new(pool)
|
258
|
+
drop = Rack::Utils::Context.new(pool, drop_session)
|
259
|
+
dreq = Rack::MockRequest.new(drop)
|
260
|
+
|
261
|
+
res0 = req.get("/")
|
262
|
+
cookie = res0["Set-Cookie"]
|
263
|
+
session_id = Rack::Session::SessionId.new cookie[session_match, 1]
|
264
|
+
ses0 = pool.pool.get(session_id.private_id, true)
|
265
|
+
pool.pool.set(session_id.public_id, ses0, 0, true)
|
266
|
+
pool.pool.delete(session_id.private_id)
|
267
|
+
|
268
|
+
res2 = dreq.get("/", "HTTP_COOKIE" => cookie)
|
269
|
+
res2["Set-Cookie"].must_be_nil
|
270
|
+
res2.body.must_equal '{"counter"=>2}'
|
271
|
+
pool.pool.get(session_id.private_id, true).must_be_nil
|
272
|
+
pool.pool.get(session_id.public_id, true).must_be_nil
|
273
|
+
end
|
274
|
+
|
238
275
|
# anyone know how to do this better?
|
239
276
|
it "cleanly merges sessions when multithreaded" do
|
240
277
|
skip unless $DEBUG
|
data/test/spec_session_pool.rb
CHANGED
@@ -6,7 +6,7 @@ require 'rack/session/pool'
|
|
6
6
|
|
7
7
|
describe Rack::Session::Pool do
|
8
8
|
session_key = Rack::Session::Pool::DEFAULT_OPTIONS[:key]
|
9
|
-
session_match = /#{session_key}=[0-9a-fA-F]
|
9
|
+
session_match = /#{session_key}=([0-9a-fA-F]+);/
|
10
10
|
|
11
11
|
incrementor = lambda do |env|
|
12
12
|
env["rack.session"]["counter"] ||= 0
|
@@ -14,7 +14,7 @@ describe Rack::Session::Pool do
|
|
14
14
|
Rack::Response.new(env["rack.session"].inspect).to_a
|
15
15
|
end
|
16
16
|
|
17
|
-
|
17
|
+
get_session_id = Rack::Lint.new(lambda do |env|
|
18
18
|
Rack::Response.new(env["rack.session"].inspect).to_a
|
19
19
|
end)
|
20
20
|
|
@@ -143,6 +143,43 @@ describe Rack::Session::Pool do
|
|
143
143
|
pool.pool.size.must_equal 1
|
144
144
|
end
|
145
145
|
|
146
|
+
it "can read the session with the legacy id" do
|
147
|
+
pool = Rack::Session::Pool.new(incrementor)
|
148
|
+
req = Rack::MockRequest.new(pool)
|
149
|
+
|
150
|
+
res0 = req.get("/")
|
151
|
+
cookie = res0["Set-Cookie"]
|
152
|
+
session_id = Rack::Session::SessionId.new cookie[session_match, 1]
|
153
|
+
ses0 = pool.pool[session_id.private_id]
|
154
|
+
pool.pool[session_id.public_id] = ses0
|
155
|
+
pool.pool.delete(session_id.private_id)
|
156
|
+
|
157
|
+
res1 = req.get("/", "HTTP_COOKIE" => cookie)
|
158
|
+
res1["Set-Cookie"].must_be_nil
|
159
|
+
res1.body.must_equal '{"counter"=>2}'
|
160
|
+
pool.pool[session_id.private_id].wont_be_nil
|
161
|
+
end
|
162
|
+
|
163
|
+
it "drops the session in the legacy id as well" do
|
164
|
+
pool = Rack::Session::Pool.new(incrementor)
|
165
|
+
req = Rack::MockRequest.new(pool)
|
166
|
+
drop = Rack::Utils::Context.new(pool, drop_session)
|
167
|
+
dreq = Rack::MockRequest.new(drop)
|
168
|
+
|
169
|
+
res0 = req.get("/")
|
170
|
+
cookie = res0["Set-Cookie"]
|
171
|
+
session_id = Rack::Session::SessionId.new cookie[session_match, 1]
|
172
|
+
ses0 = pool.pool[session_id.private_id]
|
173
|
+
pool.pool[session_id.public_id] = ses0
|
174
|
+
pool.pool.delete(session_id.private_id)
|
175
|
+
|
176
|
+
res2 = dreq.get("/", "HTTP_COOKIE" => cookie)
|
177
|
+
res2["Set-Cookie"].must_be_nil
|
178
|
+
res2.body.must_equal '{"counter"=>2}'
|
179
|
+
pool.pool[session_id.private_id].must_be_nil
|
180
|
+
pool.pool[session_id.public_id].must_be_nil
|
181
|
+
end
|
182
|
+
|
146
183
|
# anyone know how to do this better?
|
147
184
|
it "should merge sessions when multithreaded" do
|
148
185
|
unless $DEBUG
|
@@ -191,7 +228,7 @@ describe Rack::Session::Pool do
|
|
191
228
|
end
|
192
229
|
|
193
230
|
it "does not return a cookie if cookie was not written (only read)" do
|
194
|
-
app = Rack::Session::Pool.new(
|
231
|
+
app = Rack::Session::Pool.new(get_session_id)
|
195
232
|
res = Rack::MockRequest.new(app).get("/")
|
196
233
|
res["Set-Cookie"].must_be_nil
|
197
234
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rack
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.0.
|
4
|
+
version: 2.0.8
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Leah Neukirchen
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2019-12-18 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: minitest
|
@@ -274,8 +274,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
274
274
|
- !ruby/object:Gem::Version
|
275
275
|
version: '0'
|
276
276
|
requirements: []
|
277
|
-
|
278
|
-
rubygems_version: 2.7.6
|
277
|
+
rubygems_version: 3.0.3
|
279
278
|
signing_key:
|
280
279
|
specification_version: 4
|
281
280
|
summary: a modular Ruby webserver interface
|