rack-proxy 0.8.3 → 1.0.0

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.
@@ -1,9 +1,38 @@
1
- require "net_http_hacked"
1
+ # frozen_string_literal: true
2
+
3
+ require "net/https"
2
4
  require "stringio"
3
5
 
4
6
  module Rack
5
- # Wraps the hacked net/http in a Rack way.
7
+ # A lazy Rack body that streams a backend Net::HTTP response without
8
+ # buffering it.
9
+ #
10
+ # The request runs inside a Fiber, using only the public block form of
11
+ # Net::HTTP#request: the Fiber pauses (`Fiber.yield res`) the moment the
12
+ # status and headers are available, and #each resumes it to pull body chunks
13
+ # as the server consumes them. This replaced the 2010-era monkey-patch of
14
+ # private net/http internals (`net_http_hacked.rb`, deleted in 1.0) and
15
+ # inherits upstream's handling of 1xx interim responses, keep-alive
16
+ # negotiation, and transport errors.
17
+ #
18
+ # A Fiber can only be resumed from the thread that created it. The request
19
+ # Fiber is created lazily on first use (#code/#headers/#each), so the normal
20
+ # Rack flow — one thread calls the app and then iterates the body — is fine.
21
+ # If #close is called from a different thread (some servers do this on client
22
+ # abort), the Fiber unwind is skipped and the connection is hard-closed
23
+ # instead, which releases the socket either way.
6
24
  class HttpStreamingResponse
25
+ # Raised while streaming when the backend body exceeds max_response_length.
26
+ # The status/headers are already sent, so the transfer is aborted mid-stream.
27
+ class ResponseTooLarge < StandardError; end
28
+
29
+ # Raised INTO the request Fiber to unwind it on early termination (client
30
+ # abort, HEAD, 204/304, oversize response). Deliberately a direct
31
+ # StandardError subclass — it must never match the network-error classes
32
+ # Net::HTTP#request retries on, or aborting a stream could replay the
33
+ # request against the backend.
34
+ class StreamAborted < StandardError; end
35
+
7
36
  STATUSES_WITH_NO_ENTITY_BODY = {
8
37
  204 => true,
9
38
  205 => true,
@@ -11,9 +40,24 @@ module Rack
11
40
  }.freeze
12
41
 
13
42
  attr_accessor :use_ssl, :verify_mode, :read_timeout, :ssl_version, :cert, :key, :logger
14
-
15
- def initialize(request, host, port = nil)
16
- @request, @host, @port = request, host, port
43
+ attr_accessor :max_response_length
44
+
45
+ # An optional block receives the Net::HTTP instance for configuration before
46
+ # it connects — this is the single source of truth used by Rack::Proxy (see
47
+ # Rack::Proxy#configure_backend_connection). When no block is given, the
48
+ # public accessors above are applied instead (backward-compatible path for
49
+ # direct users of this class).
50
+ def initialize(request, host, port = nil, &configure)
51
+ @request, @host, @port, @configure = request, host, port, configure
52
+
53
+ # Forward the backend body verbatim. Without this, Net::HTTP inflates
54
+ # gzip/deflate bodies for requests that opted in (the default for e.g.
55
+ # Net::HTTP::Get), leaving the already-forwarded Content-Length and
56
+ # Content-Encoding describing bytes the client never receives. The old
57
+ # patched read path never decoded; keep that contract.
58
+ if request.instance_variable_defined?(:@decode_content)
59
+ request.instance_variable_set(:@decode_content, false)
60
+ end
17
61
  end
18
62
 
19
63
  def body
@@ -36,7 +80,28 @@ module Rack
36
80
  def each(&block)
37
81
  return if connection_closed
38
82
 
39
- response.read_body(&block)
83
+ response # make sure the request has started and the headers are in
84
+
85
+ bytes = 0
86
+ while @fiber.alive?
87
+ chunk = @fiber.resume
88
+ # The last resume returns the Fiber's terminal value, not a body chunk.
89
+ next unless chunk.is_a?(String)
90
+
91
+ if max_response_length
92
+ bytes += chunk.bytesize
93
+ if bytes > max_response_length
94
+ raise ResponseTooLarge, "backend response exceeded max_response_length=#{max_response_length}"
95
+ end
96
+ end
97
+ block.call(chunk)
98
+ end
99
+ rescue => e
100
+ # The status/headers are already on the wire, so we can't turn a mid-stream
101
+ # backend failure into a 502. Log it and re-raise so the server aborts the
102
+ # transfer (the client sees a truncated response, not a false "complete").
103
+ logger << "rack-proxy: streaming backend read failed: #{e.class}: #{e.message}\n" if logger.respond_to?(:<<)
104
+ raise
40
105
  ensure
41
106
  close_connection
42
107
  end
@@ -45,23 +110,58 @@ module Rack
45
110
  @to_s ||= StringIO.new.tap { |io| each { |line| io << line } }.string
46
111
  end
47
112
 
113
+ # Rack calls #close on the response body when it is done with it, including
114
+ # when it bails out early (HEAD, 304, a client disconnect). Without this, a
115
+ # body that is never iterated leaks the backend TCP/TLS connection until GC.
116
+ def close
117
+ close_connection
118
+ end
119
+
48
120
  protected
49
121
 
50
- # Net::HTTPResponse
122
+ # Net::HTTPResponse. Fetching it lazily dials the backend, sends the request
123
+ # and reads the response head; the body stays unread on the socket until
124
+ # #each resumes the Fiber.
51
125
  def response
52
- @response ||= session.begin_request_hacked(request)
126
+ return @response if @response
127
+
128
+ # Starting a request on a closed body would dial a connection that
129
+ # nothing can ever release (close_connection has already latched). Check
130
+ # AFTER the memo: #headers is legitimately read after #code auto-closed a
131
+ # 204/304, which must keep working.
132
+ raise IOError, "rack-proxy: backend response was closed before it was read" if connection_closed
133
+
134
+ @response = begin
135
+ @fiber = Fiber.new do
136
+ session.request(request) do |res|
137
+ Fiber.yield res
138
+ res.read_body { |chunk| Fiber.yield chunk }
139
+ end
140
+ :done
141
+ end
142
+ @fiber.resume
143
+ end
53
144
  end
54
145
 
55
146
  # Net::HTTP
56
147
  def session
57
148
  @session ||= Net::HTTP.new(host, port).tap do |http|
58
- http.use_ssl = use_ssl
59
- http.verify_mode = verify_mode
60
- http.read_timeout = read_timeout
61
- http.ssl_version = ssl_version if ssl_version
62
- http.cert = cert if cert
63
- http.key = key if key
64
- http.set_debug_output(logger) if logger
149
+ if @configure
150
+ @configure.call(http)
151
+ else
152
+ http.use_ssl = use_ssl
153
+ http.verify_mode = verify_mode
154
+ http.read_timeout = read_timeout
155
+ http.ssl_version = ssl_version if ssl_version
156
+ http.cert = cert if cert
157
+ http.key = key if key
158
+ http.set_debug_output(logger) if logger
159
+ end
160
+ # Net::HTTP retries idempotent requests once on transport errors — but a
161
+ # retry after the response was yielded would silently replay the request
162
+ # and restart the body mid-stream. The old patched path never retried;
163
+ # streaming must not either. (Set after the configure block on purpose.)
164
+ http.max_retries = 0
65
165
  http.start
66
166
  end
67
167
  end
@@ -72,12 +172,32 @@ module Rack
72
172
 
73
173
  attr_accessor :connection_closed
74
174
 
175
+ # Idempotent, best-effort teardown. Uses @session/@fiber directly (never the
176
+ # lazy accessors) so closing a response that was never read does not dial
177
+ # the backend just to tear it down. A still-suspended request Fiber is
178
+ # unwound first (running net/http's own ensure blocks), then the connection
179
+ # is closed for real. Swallows teardown errors: a half-read or already-reset
180
+ # backend must not crash the app or mask the original error.
75
181
  def close_connection
76
182
  return if connection_closed
77
183
 
78
- session.end_request_hacked
79
- session.finish
80
184
  self.connection_closed = true
185
+
186
+ if @fiber&.alive?
187
+ begin
188
+ @fiber.raise(StreamAborted, "backend stream closed before the response was fully read")
189
+ rescue
190
+ # Expected: StreamAborted itself (or whatever the unwind trips over)
191
+ # propagates back out of Fiber#raise; FiberError if another thread
192
+ # owns the Fiber. Either way we fall through to closing the socket.
193
+ end
194
+ end
195
+
196
+ begin
197
+ @session.finish if @session&.started?
198
+ rescue
199
+ # best-effort: the connection may already be gone
200
+ end
81
201
  end
82
202
  end
83
203
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class Proxy
5
+ VERSION = "1.0.0"
6
+ end
7
+ end
data/lib/rack/proxy.rb CHANGED
@@ -1,41 +1,71 @@
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.8.3".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
+
21
40
  class << self
22
41
  def extract_http_request_headers(env)
23
42
  headers = env.reject do |k, v|
24
- !(/^HTTP_[A-Z0-9_\.]+$/ === k) || v.nil?
43
+ !(/^HTTP_[A-Z0-9_.]+$/ === k) || v.nil?
25
44
  end.map do |k, v|
26
45
  [reconstruct_header_name(k), v]
27
46
  end.then { |pairs| build_header_hash(pairs) }
28
47
 
29
- x_forwarded_for = (headers['X-Forwarded-For'].to_s.split(/, +/) << env['REMOTE_ADDR']).join(', ')
48
+ # Strip hop-by-hop headers before forwarding. Relaying the client's
49
+ # Connection / TE / Transfer-Encoding (etc.) enables request smuggling
50
+ # and confuses the backend — these are connection-scoped, not end-to-end.
51
+ # Per RFC 7230 §6.1 any field named in the inbound Connection header is
52
+ # itself hop-by-hop for this hop. Use #delete (not #reject!) so the
53
+ # returned HeaderHash's case-insensitive index stays consistent on Rack 2.
54
+ connection_named = headers["Connection"].to_s.downcase.split(/,\s*/).map(&:strip)
55
+ headers.keys.each do |key|
56
+ headers.delete(key) if HOP_BY_HOP_HEADERS[key.downcase] || connection_named.include?(key.downcase)
57
+ end
58
+
59
+ x_forwarded_for = (headers["X-Forwarded-For"].to_s.split(/, +/) << env["REMOTE_ADDR"]).join(", ")
30
60
 
31
- headers.merge!('X-Forwarded-For' => x_forwarded_for)
61
+ headers.merge!("X-Forwarded-For" => x_forwarded_for)
32
62
  end
33
63
 
34
64
  def normalize_headers(headers)
35
65
  mapped = headers.map do |k, v|
36
- [titleize(k), if v.is_a? Array then v.join("\n") else v end]
66
+ [titleize(k), v.is_a?(Array) ? v.join("\n") : v]
37
67
  end
38
- build_header_hash Hash[mapped]
68
+ build_header_hash mapped.to_h
39
69
  end
40
70
 
41
71
  def build_header_hash(pairs)
@@ -53,7 +83,7 @@ module Rack
53
83
  protected
54
84
 
55
85
  def reconstruct_header_name(name)
56
- titleize(name.sub(/^HTTP_/, "").gsub("_", "-"))
86
+ titleize(name.sub(/^HTTP_/, "").tr("_", "-"))
57
87
  end
58
88
 
59
89
  def titleize(str)
@@ -62,7 +92,7 @@ module Rack
62
92
  end
63
93
 
64
94
  # @option opts [String, URI::HTTP] :backend Backend host to proxy requests to
65
- def initialize(app = nil, opts= {})
95
+ def initialize(app = nil, opts = {})
66
96
  if app.is_a?(Hash)
67
97
  opts = app
68
98
  @app = nil
@@ -72,10 +102,34 @@ module Rack
72
102
 
73
103
  @streaming = opts.fetch(:streaming, true)
74
104
  @backend = opts[:backend] ? URI(opts[:backend]) : nil
105
+ # With no :backend (and no env["rack.backend"]), the destination is
106
+ # derived from the client-controlled Host header. Since 1.0 that dynamic
107
+ # mode is refused (502) unless explicitly opted into, because a bare
108
+ # proxy would otherwise be an open proxy / SSRF pivot (cloud metadata
109
+ # endpoints, loopback, RFC1918). Combine the opt-in with a
110
+ # #backend_allowed? allowlist — see the README "Security considerations".
111
+ @allow_dynamic_backend = opts.fetch(:allow_dynamic_backend, false)
75
112
  @read_timeout = opts.fetch(:read_timeout, 60)
113
+ # Connect and per-write deadlines. Without these a slow/hostile backend can
114
+ # stall a thread for Net::HTTP's 60s defaults even with a small read_timeout.
115
+ @open_timeout = opts[:open_timeout]
116
+ @write_timeout = opts[:write_timeout]
117
+ # Optional cap (in bytes) on the backend response size, to bound memory
118
+ # against a hostile/huge backend. Enforced incrementally while streaming and
119
+ # via the declared Content-Length / buffered size otherwise. Default: no cap.
120
+ @max_response_length = opts[:max_response_length]
121
+ # :ssl_version pins an exact protocol and is deprecated (it forbids TLS 1.3);
122
+ # prefer :min_version / :max_version, which map to Net::HTTP#min_version=/#max_version=.
76
123
  @ssl_version = opts[:ssl_version]
124
+ @min_version = opts[:min_version]
125
+ @max_version = opts[:max_version]
77
126
  @cert = opts[:cert]
78
127
  @key = opts[:key]
128
+ # Trust anchors for VERIFY_PEER: :ca_file is a PEM bundle path, :cert_store
129
+ # an OpenSSL::X509::Store. Use these for private-CA backends instead of
130
+ # disabling verification with ssl_verify_none.
131
+ @ca_file = opts[:ca_file]
132
+ @cert_store = opts[:cert_store]
79
133
  # SSL verification: defaults to VERIFY_PEER (Ruby's Net::HTTP default).
80
134
  # Pass ssl_verify_none: true to explicitly disable cert verification, or
81
135
  # pass verify_mode: <OpenSSL::SSL::VERIFY_*> for full control.
@@ -85,6 +139,13 @@ module Rack
85
139
  @username = opts[:username]
86
140
  @password = opts[:password]
87
141
 
142
+ # Opt-in request hardening (see README "Security considerations"):
143
+ # :strip_credentials drops Cookie/Authorization from the forwarded
144
+ # request; :replace_x_forwarded_for discards the client-supplied
145
+ # X-Forwarded-For chain and forwards only this hop's REMOTE_ADDR.
146
+ @strip_credentials = opts[:strip_credentials]
147
+ @replace_x_forwarded_for = opts[:replace_x_forwarded_for]
148
+
88
149
  # Optional logger for Net::HTTP debug output. Accepts anything with a #<< method
89
150
  # (e.g. $stdout, a StringIO, or a Ruby Logger instance).
90
151
  @logger = opts[:logger]
@@ -106,81 +167,196 @@ module Rack
106
167
  triplet
107
168
  end
108
169
 
170
+ # SSRF guardrail, consulted for EVERY request with the resolved backend
171
+ # (`backend` responds to #host, #port and #scheme). Return false to refuse,
172
+ # which makes the proxy respond 502. The default allows the backend, because
173
+ # by the time this hook runs the destination is either app-configured
174
+ # (:backend / env["rack.backend"]) or the deployment has explicitly passed
175
+ # allow_dynamic_backend: true — override it to pin an allowlist on top:
176
+ #
177
+ # def backend_allowed?(backend)
178
+ # %w[api.internal.example.com].include?(backend.host)
179
+ # end
180
+ def backend_allowed?(backend)
181
+ true
182
+ end
183
+
109
184
  protected
110
185
 
111
186
  def perform_request(env)
112
187
  source_request = Rack::Request.new(env)
113
188
 
114
- # Initialize request
115
- if source_request.fullpath == ""
116
- full_path = URI.parse(env['REQUEST_URI']).request_uri
117
- else
118
- full_path = source_request.fullpath
119
- end
189
+ # Everything that can fail on a hostile/unreachable request or backend is
190
+ # mapped to a status code here so the proxy never surfaces a raw 500:
191
+ # 400 malformed request URI, 501 unknown method, 502 backend failure.
192
+ begin
193
+ # Initialize request
194
+ full_path = if source_request.fullpath == ""
195
+ URI.parse(env["REQUEST_URI"]).request_uri
196
+ else
197
+ source_request.fullpath
198
+ end
120
199
 
121
- target_request = Net::HTTP.const_get(source_request.request_method.capitalize, false).new(full_path)
200
+ request_class = net_http_request_class(source_request.request_method)
201
+ return [501, {}, []] if request_class.nil?
122
202
 
123
- # Setup headers
124
- target_request.initialize_http_header(self.class.extract_http_request_headers(source_request.env))
203
+ target_request = request_class.new(full_path)
125
204
 
126
- # Setup body
127
- if target_request.request_body_permitted? && source_request.body
128
- target_request.body_stream = source_request.body
129
- target_request.content_length = source_request.content_length.to_i
130
- target_request.content_type = source_request.content_type if source_request.content_type
131
- target_request.body_stream.rewind if target_request.body_stream.respond_to?(:rewind)
132
- end
205
+ # Setup headers
206
+ request_headers = self.class.extract_http_request_headers(source_request.env)
207
+ if @strip_credentials
208
+ request_headers.delete("Cookie")
209
+ request_headers.delete("Authorization")
210
+ end
211
+ if @replace_x_forwarded_for
212
+ if (remote_addr = env["REMOTE_ADDR"])
213
+ request_headers["X-Forwarded-For"] = remote_addr
214
+ else
215
+ request_headers.delete("X-Forwarded-For")
216
+ end
217
+ end
218
+ target_request.initialize_http_header(request_headers)
219
+
220
+ # Forward the backend response verbatim: don't let Net::HTTP transparently
221
+ # gzip-decode it, which would leave the forwarded Content-Length describing
222
+ # the compressed size (a body/Content-Length desync). The client decodes
223
+ # its own content-encoding.
224
+ target_request.instance_variable_set(:@decode_content, false) if target_request.instance_variable_defined?(:@decode_content)
225
+
226
+ # Setup body
227
+ if target_request.request_body_permitted? && source_request.body
228
+ target_request.body_stream = source_request.body
229
+ target_request.content_length = source_request.content_length.to_i
230
+ target_request.content_type = source_request.content_type if source_request.content_type
231
+ target_request.body_stream.rewind if target_request.body_stream.respond_to?(:rewind)
232
+ end
133
233
 
134
- # Use basic auth if we have to
135
- target_request.basic_auth(@username, @password) if @username && @password
234
+ # Use basic auth if we have to
235
+ target_request.basic_auth(@username, @password) if @username && @password
236
+
237
+ backend = env.delete("rack.backend") || @backend
238
+ # env["rack.backend"] is documented as a URI, but accept a URI-parseable
239
+ # string too (symmetric with the :backend option) rather than crash on
240
+ # #scheme later; a malformed string surfaces as 400 via the rescue.
241
+ backend = URI(backend) if backend.is_a?(String)
242
+ if backend.nil?
243
+ # Dynamic mode: the destination would come from the client-controlled
244
+ # Host header. Refused unless the deployment opted in (SSRF guard).
245
+ unless @allow_dynamic_backend
246
+ if @logger.respond_to?(:<<)
247
+ @logger << "rack-proxy: refusing Host-derived backend #{source_request.host.inspect} " \
248
+ "(no :backend configured; pass allow_dynamic_backend: true to opt in)\n"
249
+ end
250
+ return [502, {}, []]
251
+ end
252
+ backend = source_request
253
+ end
254
+ unless backend_allowed?(backend)
255
+ if @logger.respond_to?(:<<)
256
+ @logger << "rack-proxy: backend #{backend.host.inspect} refused by backend_allowed?\n"
257
+ end
258
+ return [502, {}, []]
259
+ end
136
260
 
137
- backend = env.delete('rack.backend') || @backend || source_request
138
- use_ssl = backend.scheme == "https" || @cert
139
- read_timeout = env.delete('http.read_timeout') || @read_timeout
261
+ use_ssl = backend.scheme == "https" || @cert
262
+ read_timeout = env.delete("http.read_timeout") || @read_timeout
140
263
 
141
- # Create the response
142
- begin
143
264
  if @streaming
144
265
  # streaming response (the actual network communication is deferred, a.k.a. streamed)
145
- target_response = HttpStreamingResponse.new(target_request, backend.host, backend.port)
146
- target_response.use_ssl = use_ssl
147
- target_response.read_timeout = read_timeout
148
- target_response.ssl_version = @ssl_version if @ssl_version
149
- target_response.verify_mode = (@verify_mode || OpenSSL::SSL::VERIFY_PEER) if use_ssl
150
- target_response.cert = @cert if @cert
151
- target_response.key = @key if @key
266
+ target_response = HttpStreamingResponse.new(target_request, backend.host, backend.port) do |http|
267
+ configure_backend_connection(http, use_ssl: use_ssl, read_timeout: read_timeout)
268
+ end
152
269
  target_response.logger = @logger if @logger
153
270
  else
154
271
  http = Net::HTTP.new(backend.host, backend.port)
155
- http.use_ssl = use_ssl if use_ssl
156
- http.read_timeout = read_timeout
157
- http.ssl_version = @ssl_version if @ssl_version
158
- http.verify_mode = @verify_mode || OpenSSL::SSL::VERIFY_PEER if use_ssl
159
- http.cert = @cert if @cert
160
- http.key = @key if @key
161
- http.set_debug_output(@logger) if @logger
272
+ configure_backend_connection(http, use_ssl: use_ssl, read_timeout: read_timeout)
162
273
 
163
274
  target_response = http.start do
164
275
  http.request(target_request)
165
276
  end
166
277
  end
167
278
 
168
- code = target_response.code
279
+ code = target_response.code
169
280
  headers = self.class.normalize_headers(target_response.respond_to?(:headers) ? target_response.headers : target_response.to_hash)
170
- body = target_response.body || []
171
- body = [body] unless body.respond_to?(:each)
172
- rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT, Net::OpenTimeout, SocketError
281
+ body = target_response.body || []
282
+ body = [body] unless body.respond_to?(:each)
283
+ rescue URI::InvalidURIError
284
+ return [400, {}, []]
285
+ rescue *BACKEND_ERRORS => e
286
+ @logger << "rack-proxy: backend request failed: #{e.class}: #{e.message}\n" if @logger.respond_to?(:<<)
173
287
  return [502, {}, []]
174
288
  end
175
289
 
176
290
  # No entity body for status codes that don't allow one (1xx, 204, 304)
177
291
  body = [] if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY[code.to_i]
178
292
 
179
- # According to https://tools.ietf.org/html/draft-ietf-httpbis-p1-messaging-14#section-7.1.3.1Acc
180
- # should remove hop-by-hop header fields
181
- headers.reject! { |k| HOP_BY_HOP_HEADERS[k.downcase] }
293
+ # Remove hop-by-hop header fields from the response. Use #delete (not
294
+ # #reject!) so the returned HeaderHash's case-insensitive index stays
295
+ # consistent on Rack 2 for any downstream middleware.
296
+ headers.keys.each { |k| headers.delete(k) if HOP_BY_HOP_HEADERS[k.downcase] }
297
+
298
+ return [502, {}, []] if response_too_large?(target_response, headers, body)
182
299
 
183
300
  [code, headers, body]
184
301
  end
302
+
303
+ private
304
+
305
+ # Enforce :max_response_length. Returns true (→ 502) when the response is
306
+ # already known to be too large. For streaming, a declared oversize is
307
+ # rejected up-front (the connection is closed) and the incremental limit is
308
+ # armed on the body for chunked/unknown-length responses; for non-streaming,
309
+ # the body is already buffered so we check its actual size. Returns false
310
+ # (allow) when no cap is set.
311
+ def response_too_large?(target_response, headers, body)
312
+ return false unless @max_response_length
313
+
314
+ declared = headers["Content-Length"]
315
+ declared_oversize = declared && declared.to_i > @max_response_length
316
+
317
+ if target_response.respond_to?(:max_response_length=)
318
+ if declared_oversize
319
+ target_response.close
320
+ return true
321
+ end
322
+ target_response.max_response_length = @max_response_length
323
+ false
324
+ else
325
+ buffered = body.sum { |part| part.to_s.bytesize }
326
+ declared_oversize || buffered > @max_response_length
327
+ end
328
+ end
329
+
330
+ # Resolve the Net::HTTP request class for an HTTP method, or nil if there is
331
+ # no matching Net::HTTP::<Verb> (unknown/unsupported method -> 501).
332
+ def net_http_request_class(method)
333
+ Net::HTTP.const_get(method.capitalize, false)
334
+ rescue NameError
335
+ nil
336
+ end
337
+
338
+ # Single source of truth for TLS/timeout setup, applied to the (real
339
+ # Net::HTTP) connection on both the streaming and non-streaming paths so a
340
+ # TLS option — notably the VERIFY_PEER default — can never land on only one.
341
+ def configure_backend_connection(conn, use_ssl:, read_timeout:)
342
+ conn.use_ssl = use_ssl
343
+ conn.read_timeout = read_timeout
344
+ conn.open_timeout = @open_timeout if @open_timeout
345
+ conn.write_timeout = @write_timeout if @write_timeout
346
+
347
+ if use_ssl
348
+ conn.verify_mode = @verify_mode || OpenSSL::SSL::VERIFY_PEER
349
+ conn.ca_file = @ca_file if @ca_file
350
+ conn.cert_store = @cert_store if @cert_store
351
+ conn.min_version = @min_version if @min_version
352
+ conn.max_version = @max_version if @max_version
353
+ conn.ssl_version = @ssl_version if @ssl_version # deprecated; prefer min/max_version
354
+ conn.cert = @cert if @cert
355
+ conn.key = @key if @key
356
+ end
357
+
358
+ conn.set_debug_output(@logger) if @logger
359
+ conn
360
+ end
185
361
  end
186
362
  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,29 @@
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")
25
29
  s.add_development_dependency("webrick")