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