rack-proxy 0.7.7 → 2.0.1

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.
data/lib/rack/proxy.rb CHANGED
@@ -1,45 +1,127 @@
1
- require "net_http_hacked"
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "net/https"
2
5
  require "rack/http_streaming_response"
6
+ require "rack/proxy/version"
3
7
 
4
8
  module Rack
5
-
6
9
  # Subclass and bring your own #rewrite_request and #rewrite_response
7
10
  class Proxy
8
- VERSION = "0.7.7".freeze
9
-
10
11
  HOP_BY_HOP_HEADERS = {
11
- 'connection' => true,
12
- 'keep-alive' => true,
13
- 'proxy-authenticate' => true,
14
- 'proxy-authorization' => true,
15
- 'te' => true,
16
- 'trailer' => true,
17
- 'transfer-encoding' => true,
18
- 'upgrade' => true
12
+ "connection" => true,
13
+ "keep-alive" => true,
14
+ "proxy-authenticate" => true,
15
+ "proxy-authorization" => true,
16
+ "te" => true,
17
+ "trailer" => true,
18
+ "transfer-encoding" => true,
19
+ "upgrade" => true
19
20
  }.freeze
20
21
 
22
+ # Backend/network failures that must surface as 502 Bad Gateway rather than
23
+ # crashing the proxy with a raw 500. Construction- and policy-time failures
24
+ # are mapped separately (400/501/502) in #perform_request.
25
+ BACKEND_ERRORS = [
26
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ECONNABORTED,
27
+ Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT, Errno::EPIPE,
28
+ SocketError,
29
+ Timeout::Error, # includes Net::OpenTimeout
30
+ Net::ReadTimeout, Net::WriteTimeout,
31
+ IOError, # includes EOFError
32
+ OpenSSL::SSL::SSLError,
33
+ Net::ProtocolError,
34
+ # A malformed status line / header block raises these; they subclass
35
+ # StandardError directly, NOT Net::ProtocolError, so list them explicitly
36
+ # or a hostile backend crashes the proxy with a raw 500 instead of a 502.
37
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError
38
+ ].freeze
39
+
40
+ class InvalidRequest < StandardError; end
41
+
42
+ # Net::HTTP copies a body stream until EOF, even when Content-Length is
43
+ # smaller. Bound the input so extra bytes cannot become a second request.
44
+ class RequestBodyStream
45
+ def initialize(input, length)
46
+ @input, @remaining = input, length
47
+ end
48
+
49
+ def read(length = nil, buffer = nil)
50
+ return read_remaining(buffer) if length.nil?
51
+
52
+ if @remaining.zero?
53
+ buffer&.clear
54
+ return nil
55
+ end
56
+
57
+ data = @input.read([length, @remaining].min, buffer)
58
+ raise InvalidRequest, "request body is shorter than Content-Length" if data.nil? || data.empty?
59
+
60
+ @remaining -= data.bytesize
61
+ data
62
+ end
63
+
64
+ private
65
+
66
+ # IO#read without a length returns everything up to EOF ("" once there).
67
+ # Net::HTTP never calls it that way, but instrumentation layers wrapping
68
+ # Net::HTTP#request (WebMock's adapter, for one) do.
69
+ def read_remaining(buffer)
70
+ data = +"".b
71
+ while (chunk = read(16_384))
72
+ data << chunk
73
+ end
74
+ buffer ? buffer.replace(data) : data
75
+ end
76
+ end
77
+ private_constant :InvalidRequest, :RequestBodyStream
78
+
21
79
  class << self
22
80
  def extract_http_request_headers(env)
23
81
  headers = env.reject do |k, v|
24
- !(/^HTTP_[A-Z0-9_\.]+$/ === k) || v.nil?
82
+ !(/^HTTP_[A-Z0-9_.]+$/ === k) || v.nil?
25
83
  end.map do |k, v|
26
84
  [reconstruct_header_name(k), v]
27
85
  end.then { |pairs| build_header_hash(pairs) }
28
86
 
29
- x_forwarded_for = (headers['X-Forwarded-For'].to_s.split(/, +/) << env['REMOTE_ADDR']).join(', ')
87
+ # Strip hop-by-hop headers before forwarding. Relaying the client's
88
+ # Connection / TE / Transfer-Encoding (etc.) enables request smuggling
89
+ # and confuses the backend — these are connection-scoped, not end-to-end.
90
+ # Per RFC 7230 §6.1 any field named in the inbound Connection header is
91
+ # itself hop-by-hop for this hop. Use #delete (not #reject!) so the
92
+ # returned HeaderHash's case-insensitive index stays consistent on Rack 2.
93
+ connection_named = headers["Connection"].to_s.downcase.split(/,\s*/).map(&:strip)
94
+ headers.keys.each do |key|
95
+ headers.delete(key) if HOP_BY_HOP_HEADERS[key.downcase] || connection_named.include?(key.downcase)
96
+ end
97
+
98
+ x_forwarded_for = (headers["X-Forwarded-For"].to_s.split(/, +/) << env["REMOTE_ADDR"]).join(", ")
30
99
 
31
- headers.merge!('X-Forwarded-For' => x_forwarded_for)
100
+ headers.merge!("X-Forwarded-For" => x_forwarded_for)
32
101
  end
33
102
 
34
103
  def normalize_headers(headers)
35
104
  mapped = headers.map do |k, v|
36
- [titleize(k), if v.is_a? Array then v.join("\n") else v end]
105
+ value = if v.is_a?(Array)
106
+ if v.length == 1
107
+ v.first
108
+ elsif Rack.const_defined?(:Headers, false)
109
+ v
110
+ else
111
+ v.join("\n")
112
+ end
113
+ else
114
+ v
115
+ end
116
+ [titleize(k), value]
37
117
  end
38
- build_header_hash Hash[mapped]
118
+ build_header_hash mapped.to_h
39
119
  end
40
120
 
41
121
  def build_header_hash(pairs)
42
- if Rack.const_defined?(:Headers)
122
+ # Pass inherit: false so we only check Rack's own constants — otherwise
123
+ # a top-level ::Headers defined by the host app would falsely match.
124
+ if Rack.const_defined?(:Headers, false)
43
125
  # Rack::Headers is only available from Rack 3 onward
44
126
  Headers.new.tap { |headers| pairs.each { |k, v| headers[k] = v } }
45
127
  else
@@ -51,7 +133,7 @@ module Rack
51
133
  protected
52
134
 
53
135
  def reconstruct_header_name(name)
54
- titleize(name.sub(/^HTTP_/, "").gsub("_", "-"))
136
+ titleize(name.sub(/^HTTP_/, "").tr("_", "-"))
55
137
  end
56
138
 
57
139
  def titleize(str)
@@ -60,7 +142,7 @@ module Rack
60
142
  end
61
143
 
62
144
  # @option opts [String, URI::HTTP] :backend Backend host to proxy requests to
63
- def initialize(app = nil, opts= {})
145
+ def initialize(app = nil, opts = {})
64
146
  if app.is_a?(Hash)
65
147
  opts = app
66
148
  @app = nil
@@ -69,17 +151,55 @@ module Rack
69
151
  end
70
152
 
71
153
  @streaming = opts.fetch(:streaming, true)
72
- @ssl_verify_none = opts.fetch(:ssl_verify_none, false)
73
154
  @backend = opts[:backend] ? URI(opts[:backend]) : nil
155
+ # With no :backend (and no env["rack.backend"]), the destination is
156
+ # derived from the client-controlled Host header. Since 1.0 that dynamic
157
+ # mode is refused (502) unless explicitly opted into, because a bare
158
+ # proxy would otherwise be an open proxy / SSRF pivot (cloud metadata
159
+ # endpoints, loopback, RFC1918). Combine the opt-in with a
160
+ # #backend_allowed? allowlist — see the README "Security considerations".
161
+ @allow_dynamic_backend = opts.fetch(:allow_dynamic_backend, false)
74
162
  @read_timeout = opts.fetch(:read_timeout, 60)
163
+ # Connect and per-write deadlines. Without these a slow/hostile backend can
164
+ # stall a thread for Net::HTTP's 60s defaults even with a small read_timeout.
165
+ @open_timeout = opts[:open_timeout]
166
+ @write_timeout = opts[:write_timeout]
167
+ # Optional cap (in bytes) on the backend response size, to bound memory
168
+ # against a hostile/huge backend. Checked before buffering each chunk in
169
+ # either mode, as well as against declared lengths. Default: no cap.
170
+ @max_response_length = opts[:max_response_length]
171
+ # :ssl_version pins an exact protocol and is deprecated (it forbids TLS 1.3);
172
+ # prefer :min_version / :max_version, which map to Net::HTTP#min_version=/#max_version=.
75
173
  @ssl_version = opts[:ssl_version]
174
+ @min_version = opts[:min_version]
175
+ @max_version = opts[:max_version]
76
176
  @cert = opts[:cert]
77
177
  @key = opts[:key]
178
+ # Trust anchors for VERIFY_PEER: :ca_file is a PEM bundle path, :cert_store
179
+ # an OpenSSL::X509::Store. Use these for private-CA backends instead of
180
+ # disabling verification with ssl_verify_none.
181
+ @ca_file = opts[:ca_file]
182
+ @cert_store = opts[:cert_store]
183
+ # SSL verification: defaults to VERIFY_PEER (Ruby's Net::HTTP default).
184
+ # Pass ssl_verify_none: true to explicitly disable cert verification, or
185
+ # pass verify_mode: <OpenSSL::SSL::VERIFY_*> for full control.
78
186
  @verify_mode = opts[:verify_mode]
187
+ @verify_mode ||= OpenSSL::SSL::VERIFY_NONE if opts[:ssl_verify_none]
79
188
 
80
189
  @username = opts[:username]
81
190
  @password = opts[:password]
82
191
 
192
+ # Opt-in request hardening (see README "Security considerations"):
193
+ # :strip_credentials drops Cookie/Authorization from the forwarded
194
+ # request; :replace_x_forwarded_for discards the client-supplied
195
+ # X-Forwarded-For chain and forwards only this hop's REMOTE_ADDR.
196
+ @strip_credentials = opts[:strip_credentials]
197
+ @replace_x_forwarded_for = opts[:replace_x_forwarded_for]
198
+
199
+ # Optional logger for Net::HTTP debug output. Accepts anything with a #<< method
200
+ # (e.g. $stdout, a StringIO, or a Ruby Logger instance).
201
+ @logger = opts[:logger]
202
+
83
203
  @opts = opts
84
204
  end
85
205
 
@@ -97,72 +217,238 @@ module Rack
97
217
  triplet
98
218
  end
99
219
 
220
+ # SSRF guardrail, consulted for EVERY request with the resolved backend
221
+ # (`backend` responds to #host, #port and #scheme). Return false to refuse,
222
+ # which makes the proxy respond 502. The default allows the backend, because
223
+ # by the time this hook runs the destination is either app-configured
224
+ # (:backend / env["rack.backend"]) or the deployment has explicitly passed
225
+ # allow_dynamic_backend: true — override it to pin an allowlist on top:
226
+ #
227
+ # def backend_allowed?(backend)
228
+ # %w[api.internal.example.com].include?(backend.host)
229
+ # end
230
+ def backend_allowed?(backend)
231
+ true
232
+ end
233
+
100
234
  protected
101
235
 
102
236
  def perform_request(env)
103
237
  source_request = Rack::Request.new(env)
104
238
 
105
- # Initialize request
106
- if source_request.fullpath == ""
107
- full_path = URI.parse(env['REQUEST_URI']).request_uri
108
- else
109
- full_path = source_request.fullpath
110
- end
239
+ # Everything that can fail on a hostile/unreachable request or backend is
240
+ # mapped to a status code here so the proxy never surfaces a raw 500:
241
+ # 400 malformed request URI, 501 unknown method, 502 backend failure.
242
+ begin
243
+ # Initialize request
244
+ full_path = if source_request.fullpath == ""
245
+ URI.parse(env["REQUEST_URI"]).request_uri
246
+ else
247
+ source_request.fullpath
248
+ end
111
249
 
112
- target_request = Net::HTTP.const_get(source_request.request_method.capitalize, false).new(full_path)
250
+ request_class = net_http_request_class(source_request.request_method)
251
+ return [501, {}, []] if request_class.nil?
113
252
 
114
- # Setup headers
115
- target_request.initialize_http_header(self.class.extract_http_request_headers(source_request.env))
253
+ target_request = request_class.new(full_path)
254
+
255
+ # Setup headers
256
+ request_headers = self.class.extract_http_request_headers(source_request.env)
257
+ if @strip_credentials
258
+ request_headers.delete("Cookie")
259
+ request_headers.delete("Authorization")
260
+ end
261
+ if @replace_x_forwarded_for
262
+ if (remote_addr = env["REMOTE_ADDR"])
263
+ request_headers["X-Forwarded-For"] = remote_addr
264
+ else
265
+ request_headers.delete("X-Forwarded-For")
266
+ end
267
+ end
268
+ target_request.initialize_http_header(request_headers)
116
269
 
117
- # Setup body
118
- if target_request.request_body_permitted? && source_request.body
119
- target_request.body_stream = source_request.body
120
- target_request.content_length = source_request.content_length.to_i
121
- target_request.content_type = source_request.content_type if source_request.content_type
122
- target_request.body_stream.rewind
270
+ # Forward the backend response verbatim: don't let Net::HTTP transparently
271
+ # gzip-decode it, which would leave the forwarded Content-Length describing
272
+ # the compressed size (a body/Content-Length desync). The client decodes
273
+ # its own content-encoding.
274
+ target_request.instance_variable_set(:@decode_content, false) if target_request.instance_variable_defined?(:@decode_content)
275
+
276
+ # Rack supplies decoded input. Generate framing for this hop instead
277
+ # of forwarding the client's Transfer-Encoding or guessing a zero size.
278
+ if target_request.request_body_permitted? && source_request.body
279
+ input = source_request.body
280
+ input.rewind if input.respond_to?(:rewind)
281
+ if (length = source_request.content_length)
282
+ raise InvalidRequest, "invalid Content-Length" unless /\A[0-9]+\z/.match?(length)
283
+
284
+ target_request.content_length = length.to_i
285
+ target_request.body_stream = RequestBodyStream.new(input, length.to_i)
286
+ else
287
+ target_request.delete("Content-Length")
288
+ target_request["Transfer-Encoding"] = "chunked"
289
+ target_request.body_stream = input
290
+ end
291
+ target_request.content_type = source_request.content_type if source_request.content_type
292
+ end
293
+
294
+ # Use basic auth if we have to
295
+ target_request.basic_auth(@username, @password) if @username && @password
296
+
297
+ backend = env.delete("rack.backend") || @backend
298
+ # env["rack.backend"] is documented as a URI, but accept a URI-parseable
299
+ # string too (symmetric with the :backend option) rather than crash on
300
+ # #scheme later; a malformed string surfaces as 400 via the rescue.
301
+ backend = URI(backend) if backend.is_a?(String)
302
+ if backend.nil?
303
+ # Dynamic mode: the destination would come from the client-controlled
304
+ # Host header. Refused unless the deployment opted in (SSRF guard).
305
+ unless @allow_dynamic_backend
306
+ if @logger.respond_to?(:<<)
307
+ @logger << "rack-proxy: refusing Host-derived backend #{source_request.host.inspect} " \
308
+ "(no :backend configured; pass allow_dynamic_backend: true to opt in)\n"
309
+ end
310
+ return [502, {}, []]
311
+ end
312
+ backend = source_request
313
+ end
314
+ unless backend_allowed?(backend)
315
+ if @logger.respond_to?(:<<)
316
+ @logger << "rack-proxy: backend #{backend.host.inspect} refused by backend_allowed?\n"
317
+ end
318
+ return [502, {}, []]
319
+ end
320
+
321
+ use_ssl = backend.scheme == "https" || @cert
322
+ read_timeout = env.delete("http.read_timeout") || @read_timeout
323
+
324
+ if @streaming
325
+ # streaming response (the actual network communication is deferred, a.k.a. streamed)
326
+ target_response = HttpStreamingResponse.new(target_request, backend.host, backend.port) do |http|
327
+ configure_backend_connection(http, use_ssl: use_ssl, read_timeout: read_timeout)
328
+ end
329
+ target_response.logger = @logger if @logger
330
+ code = target_response.code
331
+ headers = prepare_response_headers(target_response.headers, code, target_request)
332
+ if response_body_permitted?(target_request, code)
333
+ target_response.max_response_length = @max_response_length
334
+ body = target_response
335
+ else
336
+ target_response.close
337
+ body = []
338
+ end
339
+ else
340
+ http = Net::HTTP.new(backend.host, backend.port)
341
+ configure_backend_connection(http, use_ssl: use_ssl, read_timeout: read_timeout)
342
+
343
+ http.start do
344
+ http.request(target_request) do |response|
345
+ code = response.code.to_i
346
+ headers = prepare_response_headers(response.to_hash, code, target_request)
347
+ body = []
348
+ if response_body_permitted?(target_request, code)
349
+ buffered = +"".b
350
+ response.read_body do |chunk|
351
+ check_response_length!(buffered.bytesize + chunk.bytesize)
352
+ buffered << chunk
353
+ end
354
+ if response.content_length && buffered.bytesize != response.content_length
355
+ raise EOFError, "backend response is shorter than Content-Length"
356
+ end
357
+ body << buffered unless buffered.empty?
358
+ end
359
+ end
360
+ end
361
+ end
362
+ rescue URI::InvalidURIError, InvalidRequest
363
+ target_response&.close
364
+ return [400, {}, []]
365
+ rescue *BACKEND_ERRORS, HttpStreamingResponse::ResponseTooLarge => e
366
+ target_response&.close
367
+ @logger << "rack-proxy: backend request failed: #{e.class}: #{e.message}\n" if @logger.respond_to?(:<<)
368
+ return [502, {}, []]
123
369
  end
124
370
 
125
- # Use basic auth if we have to
126
- target_request.basic_auth(@username, @password) if @username && @password
371
+ [code, headers, body]
372
+ end
127
373
 
128
- backend = env.delete('rack.backend') || @backend || source_request
129
- use_ssl = backend.scheme == "https" || @cert
130
- read_timeout = env.delete('http.read_timeout') || @read_timeout
374
+ private
131
375
 
132
- # Create the response
133
- if @streaming
134
- # streaming response (the actual network communication is deferred, a.k.a. streamed)
135
- target_response = HttpStreamingResponse.new(target_request, backend.host, backend.port)
136
- target_response.use_ssl = use_ssl
137
- target_response.read_timeout = read_timeout
138
- target_response.ssl_version = @ssl_version if @ssl_version
139
- target_response.verify_mode = (@verify_mode || OpenSSL::SSL::VERIFY_NONE) if use_ssl
140
- target_response.cert = @cert if @cert
141
- target_response.key = @key if @key
142
- else
143
- http = Net::HTTP.new(backend.host, backend.port)
144
- http.use_ssl = use_ssl if use_ssl
145
- http.read_timeout = read_timeout
146
- http.ssl_version = @ssl_version if @ssl_version
147
- http.verify_mode = (@verify_mode || OpenSSL::SSL::VERIFY_NONE if use_ssl) if use_ssl
148
- http.cert = @cert if @cert
149
- http.key = @key if @key
150
-
151
- target_response = http.start do
152
- http.request(target_request)
376
+ def response_body_permitted?(request, code)
377
+ request.response_body_permitted? && !Rack::Utils::STATUS_WITH_NO_ENTITY_BODY[code] && code != 205
378
+ end
379
+
380
+ def check_response_length!(length)
381
+ if @max_response_length && length && length > @max_response_length
382
+ raise HttpStreamingResponse::ResponseTooLarge, "backend response exceeded max_response_length=#{@max_response_length}"
383
+ end
384
+ end
385
+
386
+ # Validate framing before dropping hop-by-hop fields or reading a body.
387
+ # Net::HTTP dechunks responses but otherwise preserves their headers.
388
+ def prepare_response_headers(raw_headers, code, request)
389
+ # Rack 2 HeaderHash#each joins arrays with newlines. Read values directly
390
+ # so repeated framing/Connection fields stay distinct during validation.
391
+ headers = self.class.build_header_hash(raw_headers.keys.map { |key| [key, raw_headers[key]] })
392
+ transfer_encoding = headers["Transfer-Encoding"]
393
+ content_length = headers["Content-Length"]
394
+ if transfer_encoding
395
+ if content_length || Array(transfer_encoding).join(",").strip.downcase != "chunked"
396
+ raise Net::HTTPBadResponse, "ambiguous or unsupported backend transfer encoding"
397
+ end
398
+ end
399
+ if content_length
400
+ lengths = Array(content_length).flat_map { |value| value.split(",", -1).map(&:strip) }
401
+ unless lengths.all? { |value| /\A[0-9]+\z/.match?(value) } && lengths.map(&:to_i).uniq.length == 1
402
+ raise Net::HTTPBadResponse, "invalid backend Content-Length"
153
403
  end
404
+ headers["Content-Length"] = lengths.first.to_i.to_s
405
+ check_response_length!(lengths.first.to_i) if response_body_permitted?(request, code)
154
406
  end
155
407
 
156
- code = target_response.code
157
- headers = self.class.normalize_headers(target_response.respond_to?(:headers) ? target_response.headers : target_response.to_hash)
158
- body = target_response.body || [""]
159
- body = [body] unless body.respond_to?(:each)
408
+ connection_named = Array(headers["Connection"]).join(",").downcase.split(",").map(&:strip)
409
+ headers.keys.each do |key|
410
+ headers.delete(key) if HOP_BY_HOP_HEADERS[key.downcase] || connection_named.include?(key.downcase)
411
+ end
412
+ if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY[code]
413
+ headers.delete("Content-Length")
414
+ headers.delete("Content-Type")
415
+ end
416
+ self.class.normalize_headers(headers)
417
+ end
160
418
 
161
- # According to https://tools.ietf.org/html/draft-ietf-httpbis-p1-messaging-14#section-7.1.3.1Acc
162
- # should remove hop-by-hop header fields
163
- headers.reject! { |k| HOP_BY_HOP_HEADERS[k.downcase] }
419
+ # Resolve the Net::HTTP request class for an HTTP method, or nil if there is
420
+ # no matching Net::HTTP::<Verb> (unknown/unsupported method -> 501).
421
+ def net_http_request_class(method)
422
+ Net::HTTP.const_get(method.capitalize, false)
423
+ rescue NameError
424
+ nil
425
+ end
164
426
 
165
- [code, headers, body]
427
+ # Single source of truth for TLS/timeout setup, applied to the (real
428
+ # Net::HTTP) connection on both the streaming and non-streaming paths so a
429
+ # TLS option — notably the VERIFY_PEER default — can never land on only one.
430
+ def configure_backend_connection(conn, use_ssl:, read_timeout:)
431
+ conn.use_ssl = use_ssl
432
+ # Request input need not be rewindable. A transport retry could replay an
433
+ # operation with an empty or partial body, so neither path retries it.
434
+ conn.max_retries = 0
435
+ conn.read_timeout = read_timeout
436
+ conn.open_timeout = @open_timeout if @open_timeout
437
+ conn.write_timeout = @write_timeout if @write_timeout
438
+
439
+ if use_ssl
440
+ conn.verify_mode = @verify_mode || OpenSSL::SSL::VERIFY_PEER
441
+ conn.ca_file = @ca_file if @ca_file
442
+ conn.cert_store = @cert_store if @cert_store
443
+ conn.min_version = @min_version if @min_version
444
+ conn.max_version = @max_version if @max_version
445
+ conn.ssl_version = @ssl_version if @ssl_version # deprecated; prefer min/max_version
446
+ conn.cert = @cert if @cert
447
+ conn.key = @key if @key
448
+ end
449
+
450
+ conn.set_debug_output(@logger) if @logger
451
+ conn
166
452
  end
167
453
  end
168
454
  end
data/lib/rack-proxy.rb CHANGED
@@ -1 +1,3 @@
1
- require "rack/proxy"
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/proxy"
data/rack-proxy.gemspec CHANGED
@@ -1,25 +1,30 @@
1
- # -*- encoding: utf-8 -*-
2
1
  $:.push File.expand_path("../lib", __FILE__)
3
- require "rack-proxy"
2
+ require "rack/proxy/version"
4
3
 
5
4
  Gem::Specification.new do |s|
6
- s.name = "rack-proxy"
7
- s.version = Rack::Proxy::VERSION
8
- s.platform = Gem::Platform::RUBY
9
- s.license = 'MIT'
10
- s.authors = ["Jacek Becela"]
11
- s.email = ["jacek.becela@gmail.com"]
12
- s.homepage = "https://github.com/ncr/rack-proxy"
13
- s.summary = %q{A request/response rewriting HTTP proxy. A Rack app.}
14
- s.description = %q{A Rack app that provides request/response rewriting proxy capabilities with streaming.}
15
- s.required_ruby_version = '>= 2.6'
5
+ s.name = "rack-proxy"
6
+ s.version = Rack::Proxy::VERSION
7
+ s.platform = Gem::Platform::RUBY
8
+ s.license = "MIT"
9
+ s.authors = ["Jacek Becela"]
10
+ s.email = ["jacek.becela@gmail.com"]
11
+ s.homepage = "https://github.com/ncr/rack-proxy"
12
+ s.summary = "A request/response rewriting HTTP proxy. A Rack app."
13
+ s.description = "A Rack app that provides request/response rewriting proxy capabilities with streaming."
14
+ s.required_ruby_version = ">= 3.0"
16
15
 
17
- s.files = `git ls-files`.split("\n")
18
- s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19
- s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
16
+ s.metadata = {
17
+ "source_code_uri" => "https://github.com/ncr/rack-proxy",
18
+ "changelog_uri" => "https://github.com/ncr/rack-proxy/blob/master/CHANGELOG.md",
19
+ "bug_tracker_uri" => "https://github.com/ncr/rack-proxy/issues",
20
+ "rubygems_mfa_required" => "true"
21
+ }
22
+
23
+ s.files = Dir["lib/**/*.rb"] + %w[README.md LICENSE CHANGELOG.md SECURITY.md rack-proxy.gemspec]
20
24
  s.require_paths = ["lib"]
21
25
 
22
- s.add_dependency("rack")
26
+ s.add_dependency("rack", ">= 2.0", "< 4")
23
27
  s.add_development_dependency("rack-test")
24
28
  s.add_development_dependency("test-unit")
29
+ s.add_development_dependency("webrick")
25
30
  end