rack-proxy 1.0.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c85038309db55ace1d7ad2de733dc4a539859cf585c2ffb69b73c13a914f1d47
4
- data.tar.gz: e0328851b0d2a9698b05f356bbb0322c6f3cf4c760b10d93a91194df0849ce44
3
+ metadata.gz: 0b7446fb448e8e91e1fbc8a052fc170901649bcb3df26fbabf7741ca5b331449
4
+ data.tar.gz: c87d52a7ebe5e144e9254e84ebae969f3b8f4fed74c79483ce89ae9075e2b547
5
5
  SHA512:
6
- metadata.gz: 7bba2a24c71a1521a3d9f9d42d7bffd56b8545f05a8f060fb7236c76d0d5dccba4099015eb25f69e5d98d79b937a4e6d8b26fac5e69345a814222bc65cf3f3d9
7
- data.tar.gz: 34502c66d9801a04d95849773d3dc8c43aa24629db6cb780bb06834054ffc41588ddd1610ec39e33f6b7c64b163311633a47bc988b48eacd7d3e0578bda2568f
6
+ metadata.gz: 1b0cbe8b0bb1e23edba280b7be30259fb347f51f4c415b1f33b3052fafbd9ceeb886ee3b60a1ac2bedd4bee98730693e77ada746894dd427ed967d1aa62bae8f
7
+ data.tar.gz: ff6513f7e571fc101ace25a01fc81e14251e23b5fc6c11b44089c754384b0462d620f95b38324cfe9e888fd86390fd85beb3a416f85644cf5cc5f08d0011c292
data/CHANGELOG.md CHANGED
@@ -8,6 +8,49 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [2.0.0] - 2026-09-05
12
+
13
+ ### Breaking changes
14
+
15
+ - Non-streaming requests no longer automatically retry transport failures.
16
+ This prevents replay with consumed request input; applications that require
17
+ retries must implement them with a fresh body and an explicit retry policy.
18
+ - Rack 3 response hooks receive arrays for repeated header values, including
19
+ `Set-Cookie`, and non-streaming statuses are integers. Update hooks that
20
+ assumed newline-separated headers or string statuses. Rack 2 retains its
21
+ newline-separated header representation.
22
+ - Invalid or ambiguous HTTP framing is now rejected, incomplete responses fail,
23
+ and uploads without a known length use fresh chunked framing. Backends must
24
+ accept properly framed HTTP/1.1 requests and send valid, complete responses.
25
+
26
+ ### Security
27
+
28
+ - Reframe decoded request bodies without a `CONTENT_LENGTH` using chunked
29
+ encoding instead of sending body bytes after `Content-Length: 0`. Bound
30
+ known-length input streams so excess bytes cannot become another backend
31
+ request; malformed lengths and prematurely ended uploads return `400`.
32
+ - Reject ambiguous backend framing (`Transfer-Encoding` with `Content-Length`,
33
+ conflicting or invalid lengths, and unsupported transfer codings) with `502`.
34
+ - Strip response headers named by the backend's `Connection` fields as well as
35
+ the standard hop-by-hop headers.
36
+ - Enforce `max_response_length` before buffering each chunk in non-streaming
37
+ mode, including rejecting declared oversized responses before reading them.
38
+ - Detect premature EOF in fixed-length responses: return `502` before sending
39
+ headers, or raise while streaming so the server aborts the incomplete transfer.
40
+ - Disable Net::HTTP transport retries in non-streaming mode as well as streaming
41
+ mode, preventing replay with an already-consumed request body.
42
+
43
+ ### Fixed
44
+
45
+ - Return integer statuses in non-streaming mode and preserve multiple response
46
+ header values as arrays on Rack 3 (including `Set-Cookie`). Rack 2 keeps its
47
+ newline-separated representation.
48
+ - Remove entity headers forbidden by Rack on 1xx/204/304 responses. HEAD and
49
+ 304 representation sizes no longer incorrectly trigger the response body cap.
50
+ - Replace a remaining live-host test with a local fixture and simulate failed
51
+ DNS resolution without sending external DNS queries. Add offline adversarial
52
+ framing, size-limit, retry, and `Rack::Lint` regression checks.
53
+
11
54
  ## [1.0.2] - 2026-09-01
12
55
 
13
56
  Housekeeping — no library behavior changes. **No action is needed by users:**
@@ -195,7 +238,8 @@ or a compatible fix. See the README's "Upgrading" section for migration steps.
195
238
 
196
239
  Older releases (≤ 0.7.8) predate this changelog; see the git history and tags.
197
240
 
198
- [Unreleased]: https://github.com/ncr/rack-proxy/compare/v1.0.2...HEAD
241
+ [Unreleased]: https://github.com/ncr/rack-proxy/compare/v2.0.0...HEAD
242
+ [2.0.0]: https://github.com/ncr/rack-proxy/compare/v1.0.2...v2.0.0
199
243
  [1.0.2]: https://github.com/ncr/rack-proxy/compare/v1.0.1...v1.0.2
200
244
  [1.0.1]: https://github.com/ncr/rack-proxy/compare/v1.0.0...v1.0.1
201
245
  [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.0"
6
6
  end
7
7
  end
data/lib/rack/proxy.rb CHANGED
@@ -37,6 +37,30 @@ 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, 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
+
40
64
  class << self
41
65
  def extract_http_request_headers(env)
42
66
  headers = env.reject do |k, v|
@@ -63,7 +87,18 @@ module Rack
63
87
 
64
88
  def normalize_headers(headers)
65
89
  mapped = headers.map do |k, v|
66
- [titleize(k), v.is_a?(Array) ? v.join("\n") : v]
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]
67
102
  end
68
103
  build_header_hash mapped.to_h
69
104
  end
@@ -115,8 +150,8 @@ module Rack
115
150
  @open_timeout = opts[:open_timeout]
116
151
  @write_timeout = opts[:write_timeout]
117
152
  # 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.
153
+ # against a hostile/huge backend. Checked before buffering each chunk in
154
+ # either mode, as well as against declared lengths. Default: no cap.
120
155
  @max_response_length = opts[:max_response_length]
121
156
  # :ssl_version pins an exact protocol and is deprecated (it forbids TLS 1.3);
122
157
  # prefer :min_version / :max_version, which map to Net::HTTP#min_version=/#max_version=.
@@ -223,12 +258,22 @@ module Rack
223
258
  # its own content-encoding.
224
259
  target_request.instance_variable_set(:@decode_content, false) if target_request.instance_variable_defined?(:@decode_content)
225
260
 
226
- # Setup body
261
+ # Rack supplies decoded input. Generate framing for this hop instead
262
+ # of forwarding the client's Transfer-Encoding or guessing a zero size.
227
263
  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
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
230
276
  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
277
  end
233
278
 
234
279
  # Use basic auth if we have to
@@ -267,64 +312,93 @@ module Rack
267
312
  configure_backend_connection(http, use_ssl: use_ssl, read_timeout: read_timeout)
268
313
  end
269
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
270
324
  else
271
325
  http = Net::HTTP.new(backend.host, backend.port)
272
326
  configure_backend_connection(http, use_ssl: use_ssl, read_timeout: read_timeout)
273
327
 
274
- target_response = http.start do
275
- http.request(target_request)
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
276
345
  end
277
346
  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
347
+ rescue URI::InvalidURIError, InvalidRequest
348
+ target_response&.close
284
349
  return [400, {}, []]
285
- rescue *BACKEND_ERRORS => e
350
+ rescue *BACKEND_ERRORS, HttpStreamingResponse::ResponseTooLarge => e
351
+ target_response&.close
286
352
  @logger << "rack-proxy: backend request failed: #{e.class}: #{e.message}\n" if @logger.respond_to?(:<<)
287
353
  return [502, {}, []]
288
354
  end
289
355
 
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
356
  [code, headers, body]
301
357
  end
302
358
 
303
359
  private
304
360
 
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
361
+ def response_body_permitted?(request, code)
362
+ request.response_body_permitted? && !Rack::Utils::STATUS_WITH_NO_ENTITY_BODY[code] && code != 205
363
+ end
364
+
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}"
368
+ end
369
+ end
370
+
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"
321
382
  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
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)
328
402
  end
329
403
 
330
404
  # Resolve the Net::HTTP request class for an HTTP method, or nil if there is
@@ -340,6 +414,9 @@ module Rack
340
414
  # TLS option — notably the VERIFY_PEER default — can never land on only one.
341
415
  def configure_backend_connection(conn, use_ssl:, read_timeout:)
342
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
343
420
  conn.read_timeout = read_timeout
344
421
  conn.open_timeout = @open_timeout if @open_timeout
345
422
  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.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: 2026-09-01 00:00:00.000000000 Z
11
+ date: 2026-09-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack