rack-proxy 0.7.7 → 1.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.
@@ -1,19 +1,63 @@
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,
10
39
  304 => true
11
40
  }.freeze
12
41
 
13
- attr_accessor :use_ssl, :verify_mode, :read_timeout, :ssl_version, :cert, :key
14
-
15
- def initialize(request, host, port = nil)
16
- @request, @host, @port = request, host, port
42
+ attr_accessor :use_ssl, :verify_mode, :read_timeout, :ssl_version, :cert, :key, :logger
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,22 +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
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
64
165
  http.start
65
166
  end
66
167
  end
@@ -71,12 +172,32 @@ module Rack
71
172
 
72
173
  attr_accessor :connection_closed
73
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.
74
181
  def close_connection
75
182
  return if connection_closed
76
183
 
77
- session.end_request_hacked
78
- session.finish
79
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
80
201
  end
81
202
  end
82
203
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class Proxy
5
+ VERSION = "1.0.1"
6
+ end
7
+ end
data/lib/rack/proxy.rb CHANGED
@@ -1,45 +1,77 @@
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
+
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)
42
- if Rack.const_defined?(:Headers)
72
+ # Pass inherit: false so we only check Rack's own constants — otherwise
73
+ # a top-level ::Headers defined by the host app would falsely match.
74
+ if Rack.const_defined?(:Headers, false)
43
75
  # Rack::Headers is only available from Rack 3 onward
44
76
  Headers.new.tap { |headers| pairs.each { |k, v| headers[k] = v } }
45
77
  else
@@ -51,7 +83,7 @@ module Rack
51
83
  protected
52
84
 
53
85
  def reconstruct_header_name(name)
54
- titleize(name.sub(/^HTTP_/, "").gsub("_", "-"))
86
+ titleize(name.sub(/^HTTP_/, "").tr("_", "-"))
55
87
  end
56
88
 
57
89
  def titleize(str)
@@ -60,7 +92,7 @@ module Rack
60
92
  end
61
93
 
62
94
  # @option opts [String, URI::HTTP] :backend Backend host to proxy requests to
63
- def initialize(app = nil, opts= {})
95
+ def initialize(app = nil, opts = {})
64
96
  if app.is_a?(Hash)
65
97
  opts = app
66
98
  @app = nil
@@ -69,17 +101,55 @@ module Rack
69
101
  end
70
102
 
71
103
  @streaming = opts.fetch(:streaming, true)
72
- @ssl_verify_none = opts.fetch(:ssl_verify_none, false)
73
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)
74
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=.
75
123
  @ssl_version = opts[:ssl_version]
124
+ @min_version = opts[:min_version]
125
+ @max_version = opts[:max_version]
76
126
  @cert = opts[:cert]
77
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]
133
+ # SSL verification: defaults to VERIFY_PEER (Ruby's Net::HTTP default).
134
+ # Pass ssl_verify_none: true to explicitly disable cert verification, or
135
+ # pass verify_mode: <OpenSSL::SSL::VERIFY_*> for full control.
78
136
  @verify_mode = opts[:verify_mode]
137
+ @verify_mode ||= OpenSSL::SSL::VERIFY_NONE if opts[:ssl_verify_none]
79
138
 
80
139
  @username = opts[:username]
81
140
  @password = opts[:password]
82
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
+
149
+ # Optional logger for Net::HTTP debug output. Accepts anything with a #<< method
150
+ # (e.g. $stdout, a StringIO, or a Ruby Logger instance).
151
+ @logger = opts[:logger]
152
+
83
153
  @opts = opts
84
154
  end
85
155
 
@@ -97,72 +167,196 @@ module Rack
97
167
  triplet
98
168
  end
99
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
+
100
184
  protected
101
185
 
102
186
  def perform_request(env)
103
187
  source_request = Rack::Request.new(env)
104
188
 
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
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
111
199
 
112
- 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?
113
202
 
114
- # Setup headers
115
- target_request.initialize_http_header(self.class.extract_http_request_headers(source_request.env))
203
+ target_request = request_class.new(full_path)
116
204
 
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
123
- 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)
124
219
 
125
- # Use basic auth if we have to
126
- target_request.basic_auth(@username, @password) if @username && @password
127
-
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
131
-
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)
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
233
+
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, {}, []]
153
259
  end
260
+
261
+ use_ssl = backend.scheme == "https" || @cert
262
+ read_timeout = env.delete("http.read_timeout") || @read_timeout
263
+
264
+ if @streaming
265
+ # streaming response (the actual network communication is deferred, a.k.a. streamed)
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
269
+ target_response.logger = @logger if @logger
270
+ else
271
+ http = Net::HTTP.new(backend.host, backend.port)
272
+ configure_backend_connection(http, use_ssl: use_ssl, read_timeout: read_timeout)
273
+
274
+ target_response = http.start do
275
+ http.request(target_request)
276
+ end
277
+ end
278
+
279
+ code = target_response.code
280
+ headers = self.class.normalize_headers(target_response.respond_to?(:headers) ? target_response.headers : target_response.to_hash)
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?(:<<)
287
+ return [502, {}, []]
154
288
  end
155
289
 
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)
290
+ # No entity body for status codes that don't allow one (1xx, 204, 304)
291
+ body = [] if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY[code.to_i]
160
292
 
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] }
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)
164
299
 
165
300
  [code, headers, body]
166
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
167
361
  end
168
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"