rack-proxy 0.8.3 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +184 -0
- data/LICENSE +1 -1
- data/README.md +254 -166
- data/SECURITY.md +52 -0
- data/lib/rack/http_streaming_response.rb +137 -17
- data/lib/rack/proxy/version.rb +7 -0
- data/lib/rack/proxy.rb +239 -63
- data/lib/rack-proxy.rb +3 -1
- data/rack-proxy.gemspec +20 -16
- metadata +24 -27
- data/.github/FUNDING.yml +0 -3
- data/.gitignore +0 -3
- data/.travis.yml +0 -18
- data/Gemfile +0 -6
- data/Gemfile.lock +0 -31
- 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 -25
- data/test/http_streaming_response_test.rb +0 -50
- data/test/net_http_hacked_test.rb +0 -36
- data/test/rack_proxy_test.rb +0 -358
- data/test/test_helper.rb +0 -11
|
@@ -1,9 +1,38 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/https"
|
|
2
4
|
require "stringio"
|
|
3
5
|
|
|
4
6
|
module Rack
|
|
5
|
-
#
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
data/lib/rack/proxy.rb
CHANGED
|
@@ -1,41 +1,71 @@
|
|
|
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.8.3".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
|
+
|
|
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_
|
|
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
|
-
|
|
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!(
|
|
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),
|
|
66
|
+
[titleize(k), v.is_a?(Array) ? v.join("\n") : v]
|
|
37
67
|
end
|
|
38
|
-
build_header_hash
|
|
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_/, "").
|
|
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
|
-
#
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
200
|
+
request_class = net_http_request_class(source_request.request_method)
|
|
201
|
+
return [501, {}, []] if request_class.nil?
|
|
122
202
|
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
|
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
|
|
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
|
|
171
|
-
body
|
|
172
|
-
rescue
|
|
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
|
-
#
|
|
180
|
-
#
|
|
181
|
-
|
|
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
data/rack-proxy.gemspec
CHANGED
|
@@ -1,25 +1,29 @@
|
|
|
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")
|
|
25
29
|
s.add_development_dependency("webrick")
|