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/SECURITY.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
`rack-proxy` is a request/response-rewriting HTTP proxy. Because it forwards
|
|
4
|
+
attacker-influenced requests to a backend and relays the backend's response, how
|
|
5
|
+
you configure and subclass it has direct security consequences. Please read the
|
|
6
|
+
threat model below alongside the "Security considerations" section of the README.
|
|
7
|
+
|
|
8
|
+
## Supported versions
|
|
9
|
+
|
|
10
|
+
Security fixes are released for the latest major series. Older supported
|
|
11
|
+
series receive critical fixes only during their transition period — please
|
|
12
|
+
upgrade to `2.x`.
|
|
13
|
+
|
|
14
|
+
| Version | Supported |
|
|
15
|
+
| ------- | --------- |
|
|
16
|
+
| 2.0.x | ✅ |
|
|
17
|
+
| 1.0.x | critical fixes only |
|
|
18
|
+
| 0.8.x | critical fixes only |
|
|
19
|
+
| < 0.8 | ❌ |
|
|
20
|
+
|
|
21
|
+
## Reporting a vulnerability
|
|
22
|
+
|
|
23
|
+
**Please do not open a public issue for security problems.**
|
|
24
|
+
|
|
25
|
+
Report privately through GitHub's **Report a vulnerability** button under the
|
|
26
|
+
repository's *Security* tab (Private Vulnerability Reporting). If that is
|
|
27
|
+
unavailable to you, email the maintainer at **jacek.becela@gmail.com** with
|
|
28
|
+
`[rack-proxy security]` in the subject.
|
|
29
|
+
|
|
30
|
+
Please include:
|
|
31
|
+
|
|
32
|
+
- the rack-proxy, Rack, and Ruby versions,
|
|
33
|
+
- whether you run in streaming (`streaming: true`, the default) or non-streaming mode,
|
|
34
|
+
- a minimal `config.ru` / subclass that reproduces the issue,
|
|
35
|
+
- the impact you observed.
|
|
36
|
+
|
|
37
|
+
We aim to acknowledge a report within **5 business days** and to agree on a
|
|
38
|
+
disclosure timeline from there. We are grateful for responsible disclosure and
|
|
39
|
+
will credit reporters who want it.
|
|
40
|
+
|
|
41
|
+
## Scope
|
|
42
|
+
|
|
43
|
+
In scope: defects in the library itself — for example, credentials or hop-by-hop
|
|
44
|
+
headers being forwarded when they should not be, request/response smuggling,
|
|
45
|
+
verification defaults that are weaker than documented, or a crash/`500` where a
|
|
46
|
+
`4xx`/`5xx` mapping is expected.
|
|
47
|
+
|
|
48
|
+
Out of scope: insecure **configuration or subclassing** of the library. Since
|
|
49
|
+
1.0, deriving the backend from the client-controlled `Host` header requires an
|
|
50
|
+
explicit `allow_dynamic_backend: true`; opting in without a `backend_allowed?`
|
|
51
|
+
allowlist is an SSRF/open-proxy risk that is the deployer's responsibility —
|
|
52
|
+
see the README "Security considerations". If the documentation is what led you
|
|
53
|
+
astray, that is in scope: tell us and we will fix the docs.
|
|
@@ -1,19 +1,63 @@
|
|
|
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,
|
|
10
39
|
304 => true
|
|
11
40
|
}.freeze
|
|
12
41
|
|
|
13
|
-
attr_accessor :use_ssl, :verify_mode, :read_timeout, :ssl_version, :cert, :key
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
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,67 @@ 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
|
+
bytes = 0
|
|
139
|
+
res.read_body do |chunk|
|
|
140
|
+
bytes += chunk.bytesize
|
|
141
|
+
Fiber.yield chunk
|
|
142
|
+
end
|
|
143
|
+
# Net::HTTP tolerates premature EOF for Content-Length bodies on
|
|
144
|
+
# supported Ruby versions. A proxy must signal an incomplete body.
|
|
145
|
+
if request.response_body_permitted? && res.class.body_permitted? && res.content_length && bytes != res.content_length
|
|
146
|
+
raise EOFError, "backend response is shorter than Content-Length"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
:done
|
|
150
|
+
end
|
|
151
|
+
@fiber.resume
|
|
152
|
+
end
|
|
53
153
|
end
|
|
54
154
|
|
|
55
155
|
# Net::HTTP
|
|
56
156
|
def session
|
|
57
157
|
@session ||= Net::HTTP.new(host, port).tap do |http|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
158
|
+
if @configure
|
|
159
|
+
@configure.call(http)
|
|
160
|
+
else
|
|
161
|
+
http.use_ssl = use_ssl
|
|
162
|
+
http.verify_mode = verify_mode
|
|
163
|
+
http.read_timeout = read_timeout
|
|
164
|
+
http.ssl_version = ssl_version if ssl_version
|
|
165
|
+
http.cert = cert if cert
|
|
166
|
+
http.key = key if key
|
|
167
|
+
http.set_debug_output(logger) if logger
|
|
168
|
+
end
|
|
169
|
+
# Net::HTTP retries idempotent requests once on transport errors — but a
|
|
170
|
+
# retry after the response was yielded would silently replay the request
|
|
171
|
+
# and restart the body mid-stream. The old patched path never retried;
|
|
172
|
+
# streaming must not either. (Set after the configure block on purpose.)
|
|
173
|
+
http.max_retries = 0
|
|
64
174
|
http.start
|
|
65
175
|
end
|
|
66
176
|
end
|
|
@@ -71,12 +181,32 @@ module Rack
|
|
|
71
181
|
|
|
72
182
|
attr_accessor :connection_closed
|
|
73
183
|
|
|
184
|
+
# Idempotent, best-effort teardown. Uses @session/@fiber directly (never the
|
|
185
|
+
# lazy accessors) so closing a response that was never read does not dial
|
|
186
|
+
# the backend just to tear it down. A still-suspended request Fiber is
|
|
187
|
+
# unwound first (running net/http's own ensure blocks), then the connection
|
|
188
|
+
# is closed for real. Swallows teardown errors: a half-read or already-reset
|
|
189
|
+
# backend must not crash the app or mask the original error.
|
|
74
190
|
def close_connection
|
|
75
191
|
return if connection_closed
|
|
76
192
|
|
|
77
|
-
session.end_request_hacked
|
|
78
|
-
session.finish
|
|
79
193
|
self.connection_closed = true
|
|
194
|
+
|
|
195
|
+
if @fiber&.alive?
|
|
196
|
+
begin
|
|
197
|
+
@fiber.raise(StreamAborted, "backend stream closed before the response was fully read")
|
|
198
|
+
rescue
|
|
199
|
+
# Expected: StreamAborted itself (or whatever the unwind trips over)
|
|
200
|
+
# propagates back out of Fiber#raise; FiberError if another thread
|
|
201
|
+
# owns the Fiber. Either way we fall through to closing the socket.
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
begin
|
|
206
|
+
@session.finish if @session&.started?
|
|
207
|
+
rescue
|
|
208
|
+
# best-effort: the connection may already be gone
|
|
209
|
+
end
|
|
80
210
|
end
|
|
81
211
|
end
|
|
82
212
|
end
|