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.
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
- require "net_http_hacked"
1
+ # frozen_string_literal: true
2
+
3
+ require "net/https"
2
4
  require "stringio"
3
5
 
4
6
  module Rack
5
- # Wraps the hacked net/http in a Rack way.
7
+ # A lazy Rack body that streams a backend Net::HTTP response without
8
+ # buffering it.
9
+ #
10
+ # The request runs inside a Fiber, using only the public block form of
11
+ # Net::HTTP#request: the Fiber pauses (`Fiber.yield res`) the moment the
12
+ # status and headers are available, and #each resumes it to pull body chunks
13
+ # as the server consumes them. This replaced the 2010-era monkey-patch of
14
+ # private net/http internals (`net_http_hacked.rb`, deleted in 1.0) and
15
+ # inherits upstream's handling of 1xx interim responses, keep-alive
16
+ # negotiation, and transport errors.
17
+ #
18
+ # A Fiber can only be resumed from the thread that created it. The request
19
+ # Fiber is created lazily on first use (#code/#headers/#each), so the normal
20
+ # Rack flow — one thread calls the app and then iterates the body — is fine.
21
+ # If #close is called from a different thread (some servers do this on client
22
+ # abort), the Fiber unwind is skipped and the connection is hard-closed
23
+ # instead, which releases the socket either way.
6
24
  class HttpStreamingResponse
25
+ # Raised while streaming when the backend body exceeds max_response_length.
26
+ # The status/headers are already sent, so the transfer is aborted mid-stream.
27
+ class ResponseTooLarge < StandardError; end
28
+
29
+ # Raised INTO the request Fiber to unwind it on early termination (client
30
+ # abort, HEAD, 204/304, oversize response). Deliberately a direct
31
+ # StandardError subclass — it must never match the network-error classes
32
+ # Net::HTTP#request retries on, or aborting a stream could replay the
33
+ # request against the backend.
34
+ class StreamAborted < StandardError; end
35
+
7
36
  STATUSES_WITH_NO_ENTITY_BODY = {
8
37
  204 => true,
9
38
  205 => true,
10
39
  304 => true
11
40
  }.freeze
12
41
 
13
- attr_accessor :use_ssl, :verify_mode, :read_timeout, :ssl_version, :cert, :key
14
-
15
- def initialize(request, host, port = nil)
16
- @request, @host, @port = request, host, port
42
+ attr_accessor :use_ssl, :verify_mode, :read_timeout, :ssl_version, :cert, :key, :logger
43
+ attr_accessor :max_response_length
44
+
45
+ # An optional block receives the Net::HTTP instance for configuration before
46
+ # it connects — this is the single source of truth used by Rack::Proxy (see
47
+ # Rack::Proxy#configure_backend_connection). When no block is given, the
48
+ # public accessors above are applied instead (backward-compatible path for
49
+ # direct users of this class).
50
+ def initialize(request, host, port = nil, &configure)
51
+ @request, @host, @port, @configure = request, host, port, configure
52
+
53
+ # Forward the backend body verbatim. Without this, Net::HTTP inflates
54
+ # gzip/deflate bodies for requests that opted in (the default for e.g.
55
+ # Net::HTTP::Get), leaving the already-forwarded Content-Length and
56
+ # Content-Encoding describing bytes the client never receives. The old
57
+ # patched read path never decoded; keep that contract.
58
+ if request.instance_variable_defined?(:@decode_content)
59
+ request.instance_variable_set(:@decode_content, false)
60
+ end
17
61
  end
18
62
 
19
63
  def body
@@ -36,7 +80,28 @@ module Rack
36
80
  def each(&block)
37
81
  return if connection_closed
38
82
 
39
- response.read_body(&block)
83
+ response # make sure the request has started and the headers are in
84
+
85
+ bytes = 0
86
+ while @fiber.alive?
87
+ chunk = @fiber.resume
88
+ # The last resume returns the Fiber's terminal value, not a body chunk.
89
+ next unless chunk.is_a?(String)
90
+
91
+ if max_response_length
92
+ bytes += chunk.bytesize
93
+ if bytes > max_response_length
94
+ raise ResponseTooLarge, "backend response exceeded max_response_length=#{max_response_length}"
95
+ end
96
+ end
97
+ block.call(chunk)
98
+ end
99
+ rescue => e
100
+ # The status/headers are already on the wire, so we can't turn a mid-stream
101
+ # backend failure into a 502. Log it and re-raise so the server aborts the
102
+ # transfer (the client sees a truncated response, not a false "complete").
103
+ logger << "rack-proxy: streaming backend read failed: #{e.class}: #{e.message}\n" if logger.respond_to?(:<<)
104
+ raise
40
105
  ensure
41
106
  close_connection
42
107
  end
@@ -45,22 +110,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 ||= session.begin_request_hacked(request)
126
+ return @response if @response
127
+
128
+ # Starting a request on a closed body would dial a connection that
129
+ # nothing can ever release (close_connection has already latched). Check
130
+ # AFTER the memo: #headers is legitimately read after #code auto-closed a
131
+ # 204/304, which must keep working.
132
+ raise IOError, "rack-proxy: backend response was closed before it was read" if connection_closed
133
+
134
+ @response = begin
135
+ @fiber = Fiber.new do
136
+ session.request(request) do |res|
137
+ Fiber.yield res
138
+ 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
- http.use_ssl = use_ssl
59
- http.verify_mode = verify_mode
60
- http.read_timeout = read_timeout
61
- http.ssl_version = ssl_version if ssl_version
62
- http.cert = cert if cert
63
- http.key = key if key
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ class Proxy
5
+ VERSION = "2.0.0"
6
+ end
7
+ end