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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +259 -0
- data/LICENSE +1 -1
- data/README.md +281 -163
- data/SECURITY.md +53 -0
- data/lib/rack/http_streaming_response.rb +147 -17
- data/lib/rack/proxy/version.rb +7 -0
- data/lib/rack/proxy.rb +356 -70
- data/lib/rack-proxy.rb +3 -1
- data/rack-proxy.gemspec +21 -16
- metadata +35 -27
- data/.github/FUNDING.yml +0 -3
- data/.gitignore +0 -3
- data/.travis.yml +0 -18
- data/Gemfile +0 -6
- data/Gemfile.lock +0 -28
- data/Rakefile +0 -14
- data/lib/net_http_hacked.rb +0 -90
- data/lib/rack_proxy_examples/example_service_proxy.rb +0 -40
- data/lib/rack_proxy_examples/forward_host.rb +0 -24
- data/lib/rack_proxy_examples/rack_php_proxy.rb +0 -37
- data/lib/rack_proxy_examples/trusting_proxy.rb +0 -24
- data/test/http_streaming_response_test.rb +0 -48
- data/test/net_http_hacked_test.rb +0 -36
- data/test/rack_proxy_test.rb +0 -127
- data/test/test_helper.rb +0 -11
data/lib/rack/proxy.rb
CHANGED
|
@@ -1,45 +1,127 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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_
|
|
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
|
-
|
|
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!(
|
|
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
|
-
|
|
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
|
|
118
|
+
build_header_hash mapped.to_h
|
|
39
119
|
end
|
|
40
120
|
|
|
41
121
|
def build_header_hash(pairs)
|
|
42
|
-
|
|
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_/, "").
|
|
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
|
-
#
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
250
|
+
request_class = net_http_request_class(source_request.request_method)
|
|
251
|
+
return [501, {}, []] if request_class.nil?
|
|
113
252
|
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
target_request.
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
371
|
+
[code, headers, body]
|
|
372
|
+
end
|
|
127
373
|
|
|
128
|
-
|
|
129
|
-
use_ssl = backend.scheme == "https" || @cert
|
|
130
|
-
read_timeout = env.delete('http.read_timeout') || @read_timeout
|
|
374
|
+
private
|
|
131
375
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
157
|
-
headers
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
data/rack-proxy.gemspec
CHANGED
|
@@ -1,25 +1,30 @@
|
|
|
1
|
-
# -*- encoding: utf-8 -*-
|
|
2
1
|
$:.push File.expand_path("../lib", __FILE__)
|
|
3
|
-
require "rack
|
|
2
|
+
require "rack/proxy/version"
|
|
4
3
|
|
|
5
4
|
Gem::Specification.new do |s|
|
|
6
|
-
s.name
|
|
7
|
-
s.version
|
|
8
|
-
s.platform
|
|
9
|
-
s.license
|
|
10
|
-
s.authors
|
|
11
|
-
s.email
|
|
12
|
-
s.homepage
|
|
13
|
-
s.summary
|
|
14
|
-
s.description =
|
|
15
|
-
s.required_ruby_version =
|
|
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.
|
|
18
|
-
|
|
19
|
-
|
|
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
|