rack-proxy 1.0.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c85038309db55ace1d7ad2de733dc4a539859cf585c2ffb69b73c13a914f1d47
4
- data.tar.gz: e0328851b0d2a9698b05f356bbb0322c6f3cf4c760b10d93a91194df0849ce44
3
+ metadata.gz: 12583aa295312c50b4133259be96749c8cb515eca735f3df803008970f5f941a
4
+ data.tar.gz: 765f25cc8fef13acb7b0276ed7d193d35c3ad91acc00074b923b2f1558e384d8
5
5
  SHA512:
6
- metadata.gz: 7bba2a24c71a1521a3d9f9d42d7bffd56b8545f05a8f060fb7236c76d0d5dccba4099015eb25f69e5d98d79b937a4e6d8b26fac5e69345a814222bc65cf3f3d9
7
- data.tar.gz: 34502c66d9801a04d95849773d3dc8c43aa24629db6cb780bb06834054ffc41588ddd1610ec39e33f6b7c64b163311633a47bc988b48eacd7d3e0578bda2568f
6
+ metadata.gz: 2f6467276179125820219ff0f553f216dced29a36d0a14e60d03ab5e86e49b06ab601f47f25bbc5aa180cb11be9358a3689137fbe7ff6c6190626d171b80ab92
7
+ data.tar.gz: e8f825b48982e015dedb923dc6bd21c7d7144ec991b9c8e24a65c2d9755daec0bc86df9519c045c9d8689bb0b1c4693966b609a66d6958d77edfd1b4fe016227
data/CHANGELOG.md CHANGED
@@ -8,6 +8,58 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [2.0.1] - 2026-09-16
12
+
13
+ ### Fixed
14
+
15
+ - The bounded request body stream accepts `IO#read`'s optional `length`
16
+ argument. Net::HTTP instrumentation layers that read the whole request body
17
+ at once (WebMock's adapter, for example) raised `ArgumentError` against
18
+ 2.0.0, failing the test suite of any app that proxies a request body. (#145)
19
+
20
+ ## [2.0.0] - 2026-09-05
21
+
22
+ ### Breaking changes
23
+
24
+ - Non-streaming requests no longer automatically retry transport failures.
25
+ This prevents replay with consumed request input; applications that require
26
+ retries must implement them with a fresh body and an explicit retry policy.
27
+ - Rack 3 response hooks receive arrays for repeated header values, including
28
+ `Set-Cookie`, and non-streaming statuses are integers. Update hooks that
29
+ assumed newline-separated headers or string statuses. Rack 2 retains its
30
+ newline-separated header representation.
31
+ - Invalid or ambiguous HTTP framing is now rejected, incomplete responses fail,
32
+ and uploads without a known length use fresh chunked framing. Backends must
33
+ accept properly framed HTTP/1.1 requests and send valid, complete responses.
34
+
35
+ ### Security
36
+
37
+ - Reframe decoded request bodies without a `CONTENT_LENGTH` using chunked
38
+ encoding instead of sending body bytes after `Content-Length: 0`. Bound
39
+ known-length input streams so excess bytes cannot become another backend
40
+ request; malformed lengths and prematurely ended uploads return `400`.
41
+ - Reject ambiguous backend framing (`Transfer-Encoding` with `Content-Length`,
42
+ conflicting or invalid lengths, and unsupported transfer codings) with `502`.
43
+ - Strip response headers named by the backend's `Connection` fields as well as
44
+ the standard hop-by-hop headers.
45
+ - Enforce `max_response_length` before buffering each chunk in non-streaming
46
+ mode, including rejecting declared oversized responses before reading them.
47
+ - Detect premature EOF in fixed-length responses: return `502` before sending
48
+ headers, or raise while streaming so the server aborts the incomplete transfer.
49
+ - Disable Net::HTTP transport retries in non-streaming mode as well as streaming
50
+ mode, preventing replay with an already-consumed request body.
51
+
52
+ ### Fixed
53
+
54
+ - Return integer statuses in non-streaming mode and preserve multiple response
55
+ header values as arrays on Rack 3 (including `Set-Cookie`). Rack 2 keeps its
56
+ newline-separated representation.
57
+ - Remove entity headers forbidden by Rack on 1xx/204/304 responses. HEAD and
58
+ 304 representation sizes no longer incorrectly trigger the response body cap.
59
+ - Replace a remaining live-host test with a local fixture and simulate failed
60
+ DNS resolution without sending external DNS queries. Add offline adversarial
61
+ framing, size-limit, retry, and `Rack::Lint` regression checks.
62
+
11
63
  ## [1.0.2] - 2026-09-01
12
64
 
13
65
  Housekeeping — no library behavior changes. **No action is needed by users:**
@@ -195,7 +247,9 @@ or a compatible fix. See the README's "Upgrading" section for migration steps.
195
247
 
196
248
  Older releases (≤ 0.7.8) predate this changelog; see the git history and tags.
197
249
 
198
- [Unreleased]: https://github.com/ncr/rack-proxy/compare/v1.0.2...HEAD
250
+ [Unreleased]: https://github.com/ncr/rack-proxy/compare/v2.0.1...HEAD
251
+ [2.0.1]: https://github.com/ncr/rack-proxy/compare/v2.0.0...v2.0.1
252
+ [2.0.0]: https://github.com/ncr/rack-proxy/compare/v1.0.2...v2.0.0
199
253
  [1.0.2]: https://github.com/ncr/rack-proxy/compare/v1.0.1...v1.0.2
200
254
  [1.0.1]: https://github.com/ncr/rack-proxy/compare/v1.0.0...v1.0.1
201
255
  [1.0.0]: https://github.com/ncr/rack-proxy/compare/v0.8.3...v1.0.0
data/README.md CHANGED
@@ -37,7 +37,7 @@ Typical uses:
37
37
  Requires Ruby >= 3.0 and Rack 2.x or 3.x. Add to your `Gemfile`:
38
38
 
39
39
  ```ruby
40
- gem "rack-proxy", "~> 1.0"
40
+ gem "rack-proxy", "~> 2.0"
41
41
  ```
42
42
 
43
43
  ## Quick start
@@ -116,7 +116,7 @@ Pass options when instantiating (`Rack::Proxy.new(backend: ...)`) or mounting mi
116
116
  - `:read_timeout` — per-read timeout in seconds (default `60`).
117
117
  - `:open_timeout` — connection-open timeout in seconds.
118
118
  - `:write_timeout` — per-write timeout in seconds.
119
- - `:max_response_length` — cap (in bytes) on the backend response size; a larger response is refused with `502` (streaming aborts once the cap is passed).
119
+ - `:max_response_length` — cap (in bytes) on the backend response body. Oversized declared lengths are refused before reading the body, and each chunk is checked before buffering or forwarding it. Non-streaming responses return `502` on overflow; streaming responses abort if overflow is discovered after sending the headers. HEAD/304 representation lengths do not count as body bytes.
120
120
 
121
121
  ### Request shaping
122
122
 
@@ -240,7 +240,7 @@ From [`examples/example_service_proxy.rb`](examples/example_service_proxy.rb):
240
240
  # 1. rails new test_app
241
241
  # 2. cd test_app
242
242
  # 3. install Rack-Proxy in `Gemfile`
243
- # a. `gem 'rack-proxy', '~> 1.0'`
243
+ # a. `gem 'rack-proxy', '~> 2.0'`
244
244
  # 4. install gem: `bundle install`
245
245
  # 5. copy the class into your app and mount it from `config/initializers/proxy.rb`
246
246
  # 6. run: `SERVICE_URL=http://guides.rubyonrails.org rails server`
@@ -382,6 +382,17 @@ end
382
382
 
383
383
  ## Upgrading
384
384
 
385
+ ### 1.x → 2.0.0
386
+
387
+ 2.0.0 hardens HTTP framing and response limits. Ruby and Rack requirements are unchanged.
388
+
389
+ - **Automatic transport retries are disabled in both modes.** Non-streaming requests previously inherited Net::HTTP's retry behavior. If your application needs retries, use an explicit policy that considers whether the operation is safe to replay and provides a fresh request body for each attempt.
390
+ - **Response hooks follow Rack's types.** Statuses are integers in both modes. On Rack 3, repeated headers such as `Set-Cookie` are arrays of strings; update hooks that split these values on newlines. Rack 2 still uses newline-separated strings.
391
+ - **Framing errors fail explicitly.** Malformed request lengths and incomplete uploads return `400`. Ambiguous backend framing returns `502`. A truncated backend body returns `502` before headers are sent, or raises during streaming so the server aborts the transfer. Uploads without `CONTENT_LENGTH` are forwarded with chunked encoding; backends must support HTTP/1.1 chunked requests.
392
+ - **Response limits apply before buffering.** `max_response_length` now bounds non-streaming accumulation. HEAD and 304 representation lengths do not count toward the body cap.
393
+
394
+ Update your Gemfile to `gem "rack-proxy", "~> 2.0"` and run `bundle update rack-proxy`. Existing explicit backend and TLS options continue to work.
395
+
385
396
  ### 0.8.x → 1.0.0
386
397
 
387
398
  1.0.0 is a breaking release; the full list is in [CHANGELOG.md](CHANGELOG.md). The changes most likely to need action:
data/SECURITY.md CHANGED
@@ -7,13 +7,14 @@ threat model below alongside the "Security considerations" section of the README
7
7
 
8
8
  ## Supported versions
9
9
 
10
- Security fixes are released for the latest major series. The last `0.x` series
11
- receives fixes for critical issues only, for a transition period — please
12
- upgrade to `1.x`.
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
13
 
14
14
  | Version | Supported |
15
15
  | ------- | --------- |
16
- | 1.0.x | ✅ |
16
+ | 2.0.x | ✅ |
17
+ | 1.0.x | critical fixes only |
17
18
  | 0.8.x | critical fixes only |
18
19
  | < 0.8 | ❌ |
19
20
 
@@ -135,7 +135,16 @@ module Rack
135
135
  @fiber = Fiber.new do
136
136
  session.request(request) do |res|
137
137
  Fiber.yield res
138
- res.read_body { |chunk| Fiber.yield chunk }
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
139
148
  end
140
149
  :done
141
150
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Rack
4
4
  class Proxy
5
- VERSION = "1.0.2"
5
+ VERSION = "2.0.1"
6
6
  end
7
7
  end
data/lib/rack/proxy.rb CHANGED
@@ -37,6 +37,45 @@ module Rack
37
37
  Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError
38
38
  ].freeze
39
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
+
40
79
  class << self
41
80
  def extract_http_request_headers(env)
42
81
  headers = env.reject do |k, v|
@@ -63,7 +102,18 @@ module Rack
63
102
 
64
103
  def normalize_headers(headers)
65
104
  mapped = headers.map do |k, v|
66
- [titleize(k), v.is_a?(Array) ? v.join("\n") : v]
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]
67
117
  end
68
118
  build_header_hash mapped.to_h
69
119
  end
@@ -115,8 +165,8 @@ module Rack
115
165
  @open_timeout = opts[:open_timeout]
116
166
  @write_timeout = opts[:write_timeout]
117
167
  # 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.
168
+ # against a hostile/huge backend. Checked before buffering each chunk in
169
+ # either mode, as well as against declared lengths. Default: no cap.
120
170
  @max_response_length = opts[:max_response_length]
121
171
  # :ssl_version pins an exact protocol and is deprecated (it forbids TLS 1.3);
122
172
  # prefer :min_version / :max_version, which map to Net::HTTP#min_version=/#max_version=.
@@ -223,12 +273,22 @@ module Rack
223
273
  # its own content-encoding.
224
274
  target_request.instance_variable_set(:@decode_content, false) if target_request.instance_variable_defined?(:@decode_content)
225
275
 
226
- # Setup body
276
+ # Rack supplies decoded input. Generate framing for this hop instead
277
+ # of forwarding the client's Transfer-Encoding or guessing a zero size.
227
278
  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
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
230
291
  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
292
  end
233
293
 
234
294
  # Use basic auth if we have to
@@ -267,64 +327,93 @@ module Rack
267
327
  configure_backend_connection(http, use_ssl: use_ssl, read_timeout: read_timeout)
268
328
  end
269
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
270
339
  else
271
340
  http = Net::HTTP.new(backend.host, backend.port)
272
341
  configure_backend_connection(http, use_ssl: use_ssl, read_timeout: read_timeout)
273
342
 
274
- target_response = http.start do
275
- http.request(target_request)
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
276
360
  end
277
361
  end
278
-
279
- code = target_response.code
280
- headers = self.class.normalize_headers(target_response.respond_to?(:headers) ? target_response.headers : target_response.to_hash)
281
- body = target_response.body || []
282
- body = [body] unless body.respond_to?(:each)
283
- rescue URI::InvalidURIError
362
+ rescue URI::InvalidURIError, InvalidRequest
363
+ target_response&.close
284
364
  return [400, {}, []]
285
- rescue *BACKEND_ERRORS => e
365
+ rescue *BACKEND_ERRORS, HttpStreamingResponse::ResponseTooLarge => e
366
+ target_response&.close
286
367
  @logger << "rack-proxy: backend request failed: #{e.class}: #{e.message}\n" if @logger.respond_to?(:<<)
287
368
  return [502, {}, []]
288
369
  end
289
370
 
290
- # No entity body for status codes that don't allow one (1xx, 204, 304)
291
- body = [] if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY[code.to_i]
292
-
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)
299
-
300
371
  [code, headers, body]
301
372
  end
302
373
 
303
374
  private
304
375
 
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
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"
321
397
  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
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"
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)
406
+ end
407
+
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)
328
417
  end
329
418
 
330
419
  # Resolve the Net::HTTP request class for an HTTP method, or nil if there is
@@ -340,6 +429,9 @@ module Rack
340
429
  # TLS option — notably the VERIFY_PEER default — can never land on only one.
341
430
  def configure_backend_connection(conn, use_ssl:, read_timeout:)
342
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
343
435
  conn.read_timeout = read_timeout
344
436
  conn.open_timeout = @open_timeout if @open_timeout
345
437
  conn.write_timeout = @write_timeout if @write_timeout
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: 1.0.2
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jacek Becela
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-01 00:00:00.000000000 Z
11
+ date: 2026-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack