Version in base suite: 3.1.16-0.1 Base version: ruby-rack_3.1.16-0.1 Target version: ruby-rack_3.1.18-1~deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/r/ruby-rack/ruby-rack_3.1.16-0.1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/r/ruby-rack/ruby-rack_3.1.18-1~deb13u1.dsc CHANGELOG.md | 72 +++++++++++++--- README.md | 8 + debian/changelog | 17 +++ debian/gbp.conf | 4 debian/salsa-ci.yml | 3 lib/rack/multipart/parser.rb | 54 +++++++++++- lib/rack/query_parser.rb | 4 lib/rack/request.rb | 5 - lib/rack/sendfile.rb | 70 +++++++++++---- lib/rack/version.rb | 2 test/spec_multipart.rb | 193 ++++++++++++++++++++++++++++++++++++++++++- test/spec_request.rb | 78 +++++++++++++++++ test/spec_sendfile.rb | 167 ++++++++++++++++++++++++++++++------- 13 files changed, 606 insertions(+), 71 deletions(-) diff -Nru ruby-rack-3.1.16/CHANGELOG.md ruby-rack-3.1.18/CHANGELOG.md --- ruby-rack-3.1.16/CHANGELOG.md 2025-06-04 22:27:50.000000000 +0000 +++ ruby-rack-3.1.18/CHANGELOG.md 2025-10-10 00:39:16.000000000 +0000 @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. For info on how to format all future additions to this file please reference [Keep A Changelog](https://keepachangelog.com/en/1.0.0/). +## [3.1.18] - 2025-10-10 + +### Security + +- [CVE-2025-61780](https://github.com/advisories/GHSA-r657-rxjc-j557) Improper handling of headers in `Rack::Sendfile` may allow proxy bypass. +- [CVE-2025-61919](https://github.com/advisories/GHSA-6xw4-3v39-52mm) Unbounded read in `Rack::Request` form parsing can lead to memory exhaustion. + +## [3.1.17] - 2025-10-07 + +### Security + +- [CVE-2025-61772](https://github.com/advisories/GHSA-wpv5-97wm-hp9c) Multipart parser buffers unbounded per-part headers, enabling DoS (memory exhaustion) +- [CVE-2025-61771](https://github.com/advisories/GHSA-w9pc-fmgc-vxvw) Multipart parser buffers large non‑file fields entirely in memory, enabling DoS (memory exhaustion) +- [CVE-2025-61770](https://github.com/advisories/GHSA-p543-xpfm-54cp) Unbounded multipart preamble buffering enables DoS (memory exhaustion) + +## [3.1.16] - 2025-06-04 + +### Security + +- [CVE-2025-49007](https://github.com/advisories/GHSA-47m2-26rw-j2jw) Fix ReDoS in multipart request. + ## [3.1.15] - 2025-05-18 - Optional support for `CGI::Cookie` if not available. ([#2327](https://github.com/rack/rack/pull/2327), [#2333](https://github.com/rack/rack/pull/2333), [@earlopain]) @@ -10,7 +31,7 @@ ### Security -- [CVE-2025-46727](https://github.com/rack/rack/security/advisories/GHSA-gjh7-p2fx-99vx) Unbounded parameter parsing in `Rack::QueryParser` can lead to memory exhaustion. +- [CVE-2025-46727](https://github.com/advisories/GHSA-gjh7-p2fx-99vx) Unbounded parameter parsing in `Rack::QueryParser` can lead to memory exhaustion. ## [3.1.13] - 2025-04-13 @@ -20,19 +41,19 @@ ### Security -- [CVE-2025-27610](https://github.com/rack/rack/security/advisories/GHSA-7wqh-767x-r66v) Local file inclusion in `Rack::Static`. +- [CVE-2025-27610](https://github.com/advisories/GHSA-7wqh-767x-r66v) Local file inclusion in `Rack::Static`. ## [3.1.11] - 2025-03-04 ### Security -- [CVE-2025-27111](https://github.com/rack/rack/security/advisories/GHSA-8cgq-6mh2-7j6v) Possible Log Injection in `Rack::Sendfile`. +- [CVE-2025-27111](https://github.com/advisories/GHSA-8cgq-6mh2-7j6v) Possible Log Injection in `Rack::Sendfile`. ## [3.1.10] - 2025-02-12 ### Security -- [CVE-2025-25184](https://github.com/rack/rack/security/advisories/GHSA-7g2v-jj9q-g3rg) Possible Log Injection in `Rack::CommonLogger`. +- [CVE-2025-25184](https://github.com/advisories/GHSA-7g2v-jj9q-g3rg) Possible Log Injection in `Rack::CommonLogger`. ## [3.1.9] - 2025-01-31 @@ -65,7 +86,7 @@ ### Security -- Fix potential ReDoS attack in `Rack::Request#parse_http_accept_header`. ([GHSA-cj83-2ww7-mvq7](https://github.com/rack/rack/security/advisories/GHSA-cj83-2ww7-mvq7), [@dwisiswant0](https://github.com/dwisiswant0)) +- Fix potential ReDoS attack in `Rack::Request#parse_http_accept_header`. ([GHSA-cj83-2ww7-mvq7](https://github.com/advisories/GHSA-cj83-2ww7-mvq7), [@dwisiswant0](https://github.com/dwisiswant0)) ## [3.1.4] - 2024-06-22 @@ -155,7 +176,7 @@ ### Security -- [CVE-2025-46727](https://github.com/rack/rack/security/advisories/GHSA-gjh7-p2fx-99vx) Unbounded parameter parsing in `Rack::QueryParser` can lead to memory exhaustion. +- [CVE-2025-46727](https://github.com/advisories/GHSA-gjh7-p2fx-99vx) Unbounded parameter parsing in `Rack::QueryParser` can lead to memory exhaustion. ## [3.0.15] - 2025-04-13 @@ -165,13 +186,13 @@ ### Security -- [CVE-2025-27610](https://github.com/rack/rack/security/advisories/GHSA-7wqh-767x-r66v) Local file inclusion in `Rack::Static`. +- [CVE-2025-27610](https://github.com/advisories/GHSA-7wqh-767x-r66v) Local file inclusion in `Rack::Static`. ## [3.0.13] - 2025-03-04 ### Security -- [CVE-2025-27111](https://github.com/rack/rack/security/advisories/GHSA-8cgq-6mh2-7j6v) Possible Log Injection in `Rack::Sendfile`. +- [CVE-2025-27111](https://github.com/advisories/GHSA-8cgq-6mh2-7j6v) Possible Log Injection in `Rack::Sendfile`. ### Fixed @@ -181,7 +202,7 @@ ### Security -- [CVE-2025-25184](https://github.com/rack/rack/security/advisories/GHSA-7g2v-jj9q-g3rg) Possible Log Injection in `Rack::CommonLogger`. +- [CVE-2025-25184](https://github.com/advisories/GHSA-7g2v-jj9q-g3rg) Possible Log Injection in `Rack::CommonLogger`. ## [3.0.11] - 2024-05-10 @@ -359,6 +380,31 @@ - Fix multipart filename generation for filenames that contain spaces. Encode spaces as "%20" instead of "+" which will be decoded properly by the multipart parser. ([#1736](https://github.com/rack/rack/pull/1645), [@muirdm](https://github.com/muirdm)) - `Rack::Request#scheme` returns `ws` or `wss` when one of the `X-Forwarded-Scheme` / `X-Forwarded-Proto` headers is set to `ws` or `wss`, respectively. ([#1730](https://github.com/rack/rack/issues/1730), [@erwanst](https://github.com/erwanst)) +## [2.2.20] - 2025-10-10 + +### Security + +- [CVE-2025-61780](https://github.com/advisories/GHSA-r657-rxjc-j557) Improper handling of headers in `Rack::Sendfile` may allow proxy bypass. +- [CVE-2025-61919](https://github.com/advisories/GHSA-6xw4-3v39-52mm) Unbounded read in `Rack::Request` form parsing can lead to memory exhaustion. + +## [2.2.19] - 2025-10-07 + +### Security + +- [CVE-2025-61772](https://github.com/advisories/GHSA-wpv5-97wm-hp9c) Multipart parser buffers unbounded per-part headers, enabling DoS (memory exhaustion) +- [CVE-2025-61771](https://github.com/advisories/GHSA-w9pc-fmgc-vxvw) Multipart parser buffers large non‑file fields entirely in memory, enabling DoS (memory exhaustion) +- [CVE-2025-61770](https://github.com/advisories/GHSA-p543-xpfm-54cp) Unbounded multipart preamble buffering enables DoS (memory exhaustion) + +## [2.2.18] - 2025-09-25 + +### Security + +- [CVE-2025-59830](https://github.com/advisories/GHSA-625h-95r8-8xpm) Unbounded parameter parsing in `Rack::QueryParser` can lead to memory exhaustion via semicolon-separated parameters. + +## [2.2.17] - 2025-06-03 + +- Backport `Rack::MediaType#params` now handles parameters without values. ([#2263](https://github.com/rack/rack/pull/2263), [@AllyMarthaJ](https://github.com/AllyMarthaJ)) + ## [2.2.16] - 2025-05-22 - Fix incorrect backport of optional `CGI::Cookie` support. ([#2335](https://github.com/rack/rack/pull/2335), [@jeremyevans]) @@ -371,25 +417,25 @@ ### Security -- [CVE-2025-46727](https://github.com/rack/rack/security/advisories/GHSA-gjh7-p2fx-99vx) Unbounded parameter parsing in `Rack::QueryParser` can lead to memory exhaustion. +- [CVE-2025-46727](https://github.com/advisories/GHSA-gjh7-p2fx-99vx) Unbounded parameter parsing in `Rack::QueryParser` can lead to memory exhaustion. ## [2.2.13] - 2025-03-11 ### Security -- [CVE-2025-27610](https://github.com/rack/rack/security/advisories/GHSA-7wqh-767x-r66v) Local file inclusion in `Rack::Static`. +- [CVE-2025-27610](https://github.com/advisories/GHSA-7wqh-767x-r66v) Local file inclusion in `Rack::Static`. ## [2.2.12] - 2025-03-04 ### Security -- [CVE-2025-27111](https://github.com/rack/rack/security/advisories/GHSA-8cgq-6mh2-7j6v) Possible Log Injection in `Rack::Sendfile`. +- [CVE-2025-27111](https://github.com/advisories/GHSA-8cgq-6mh2-7j6v) Possible Log Injection in `Rack::Sendfile`. ## [2.2.11] - 2025-02-12 ### Security -- [CVE-2025-25184](https://github.com/rack/rack/security/advisories/GHSA-7g2v-jj9q-g3rg) Possible Log Injection in `Rack::CommonLogger`. +- [CVE-2025-25184](https://github.com/advisories/GHSA-7g2v-jj9q-g3rg) Possible Log Injection in `Rack::CommonLogger`. ## [2.2.10] - 2024-10-14 diff -Nru ruby-rack-3.1.16/README.md ruby-rack-3.1.18/README.md --- ruby-rack-3.1.16/README.md 2025-06-04 22:27:50.000000000 +0000 +++ ruby-rack-3.1.18/README.md 2025-10-10 00:39:16.000000000 +0000 @@ -210,6 +210,14 @@ used multiple times in the query, each counts as a separate parameter for this check. +### `RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT` + +This environment variable sets the maximum amount of memory Rack will use +to buffer multipart parameters when parsing a request body. This considers +the size of the multipart mime headers and the body part for multipart +parameters that are buffered in memory and do not use tempfiles. This +defaults to 16MB if not provided. + ### `param_depth_limit` ```ruby diff -Nru ruby-rack-3.1.16/debian/changelog ruby-rack-3.1.18/debian/changelog --- ruby-rack-3.1.16/debian/changelog 2025-07-15 16:00:20.000000000 +0000 +++ ruby-rack-3.1.18/debian/changelog 2025-10-22 07:52:58.000000000 +0000 @@ -1,3 +1,20 @@ +ruby-rack (3.1.18-1~deb13u1) trixie-security; urgency=medium + + * New upstream version 3.1.18. + - CVE-2025-61772: Multipart parser buffers unbounded per-part headers, + enabling DoS (memory exhaustion). + - CVE-2025-61771: Multipart parser buffers large non‑file fields + entirely in memory, enabling DoS (memory exhaustion). + - CVE-2025-61770: Unbounded multipart preamble buffering enables DoS + (memory exhaustion). + - CVE-2025-61780 Improper handling of headers in Rack::Sendfile may + allow proxy bypass. + - CVE-2025-61919 Unbounded read in Rack::Request form parsing can lead + to memory exhaustion. + - Closes: #1117855, #1117856, #1117627, #1117628 + + -- Utkarsh Gupta Wed, 22 Oct 2025 08:52:58 +0100 + ruby-rack (3.1.16-0.1) unstable; urgency=medium * Non-maintainer upload diff -Nru ruby-rack-3.1.16/debian/gbp.conf ruby-rack-3.1.18/debian/gbp.conf --- ruby-rack-3.1.16/debian/gbp.conf 1970-01-01 00:00:00.000000000 +0000 +++ ruby-rack-3.1.18/debian/gbp.conf 2025-10-22 07:52:58.000000000 +0000 @@ -0,0 +1,4 @@ +[DEFAULT] +debian-branch = debian/latest +upstream-branch = upstream/latest +pristine-tar = True diff -Nru ruby-rack-3.1.16/debian/salsa-ci.yml ruby-rack-3.1.18/debian/salsa-ci.yml --- ruby-rack-3.1.16/debian/salsa-ci.yml 2025-03-16 17:51:46.000000000 +0000 +++ ruby-rack-3.1.18/debian/salsa-ci.yml 2025-10-22 07:52:58.000000000 +0000 @@ -1,4 +1,3 @@ --- include: - - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/salsa-ci.yml - - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/pipeline-jobs.yml + - https://salsa.debian.org/ruby-team/meta/raw/master/salsa-ci.yml diff -Nru ruby-rack-3.1.16/lib/rack/multipart/parser.rb ruby-rack-3.1.18/lib/rack/multipart/parser.rb --- ruby-rack-3.1.16/lib/rack/multipart/parser.rb 2025-06-04 22:27:50.000000000 +0000 +++ ruby-rack-3.1.18/lib/rack/multipart/parser.rb 2025-10-10 00:39:16.000000000 +0000 @@ -47,6 +47,27 @@ Tempfile.new(["RackMultipart", extension]) } + BOUNDARY_START_LIMIT = 16 * 1024 + private_constant :BOUNDARY_START_LIMIT + + MIME_HEADER_BYTESIZE_LIMIT = 64 * 1024 + private_constant :MIME_HEADER_BYTESIZE_LIMIT + + env_int = lambda do |key, val| + if str_val = ENV[key] + begin + val = Integer(str_val, 10) + rescue ArgumentError + raise ArgumentError, "non-integer value provided for environment variable #{key}" + end + end + + val + end + + BUFFERED_UPLOAD_BYTESIZE_LIMIT = env_int.call("RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT", 16 * 1024 * 1024) + private_constant :BUFFERED_UPLOAD_BYTESIZE_LIMIT + class BoundedIO # :nodoc: def initialize(io, content_length) @io = io @@ -206,6 +227,8 @@ @state = :FAST_FORWARD @mime_index = 0 + @body_retained = nil + @retained_size = 0 @collector = Collector.new tempfile @sbuf = StringScanner.new("".dup) @@ -287,6 +310,10 @@ # retry for opening boundary else + # We raise if we don't find the multipart boundary, to avoid unbounded memory + # buffering. Note that the actual limit is the higher of 16KB and the buffer size (1MB by default) + raise Error, "multipart boundary not found within limit" if @sbuf.string.bytesize > BOUNDARY_START_LIMIT + # no boundary found, keep reading data return :want_read end @@ -403,16 +430,30 @@ name = filename || "#{content_type || TEXT_PLAIN}[]".dup end + # Mime part head data is retained for both TempfilePart and BufferPart + # for the entireity of the parse, even though it isn't used for BufferPart. + update_retained_size(head.bytesize) + + # If a filename is given, a TempfilePart will be used, so the body will + # not be buffered in memory. However, if a filename is not given, a BufferPart + # will be used, and the body will be buffered in memory. + @body_retained = !filename + @collector.on_mime_head @mime_index, head, filename, content_type, name @state = :MIME_BODY else - :want_read + # We raise if the mime part header is too large, to avoid unbounded memory + # buffering. Note that the actual limit is the higher of 64KB and the buffer size (1MB by default) + raise Error, "multipart mime part header too large" if @sbuf.string.bytesize > MIME_HEADER_BYTESIZE_LIMIT + + return :want_read end end def handle_mime_body if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet body = body_with_boundary.sub(@body_regex_at_end, '') # remove the boundary from the string + update_retained_size(body.bytesize) if @body_retained @collector.on_mime_body @mime_index, body @sbuf.pos += body.length + 2 # skip \r\n after the content @state = :CONSUME_TOKEN @@ -421,7 +462,9 @@ # Save what we have so far if @rx_max_size < @sbuf.rest_size delta = @sbuf.rest_size - @rx_max_size - @collector.on_mime_body @mime_index, @sbuf.peek(delta) + body = @sbuf.peek(delta) + update_retained_size(body.bytesize) if @body_retained + @collector.on_mime_body @mime_index, body @sbuf.pos += delta @sbuf.string = @sbuf.rest end @@ -429,6 +472,13 @@ end end + def update_retained_size(size) + @retained_size += size + if @retained_size > BUFFERED_UPLOAD_BYTESIZE_LIMIT + raise Error, "multipart data over retained size limit" + end + end + # Scan until the we find the start or end of the boundary. # If we find it, return the appropriate symbol for the start or # end of the boundary. If we don't find the start or end of the diff -Nru ruby-rack-3.1.16/lib/rack/query_parser.rb ruby-rack-3.1.18/lib/rack/query_parser.rb --- ruby-rack-3.1.16/lib/rack/query_parser.rb 2025-06-04 22:27:50.000000000 +0000 +++ ruby-rack-3.1.18/lib/rack/query_parser.rb 2025-10-10 00:39:16.000000000 +0000 @@ -57,6 +57,8 @@ PARAMS_LIMIT = env_int.call("RACK_QUERY_PARSER_PARAMS_LIMIT", 4096) private_constant :PARAMS_LIMIT + attr_reader :bytesize_limit + def initialize(params_class, param_depth_limit, bytesize_limit: BYTESIZE_LIMIT, params_limit: PARAMS_LIMIT) @params_class = params_class @param_depth_limit = param_depth_limit @@ -218,7 +220,7 @@ def check_query_string(qs, sep) if qs if qs.bytesize > @bytesize_limit - raise QueryLimitError, "total query size (#{qs.bytesize}) exceeds limit (#{@bytesize_limit})" + raise QueryLimitError, "total query size exceeds limit (#{@bytesize_limit})" end if (param_count = qs.count(sep.is_a?(String) ? sep : '&')) >= @params_limit diff -Nru ruby-rack-3.1.16/lib/rack/request.rb ruby-rack-3.1.18/lib/rack/request.rb --- ruby-rack-3.1.16/lib/rack/request.rb 2025-06-04 22:27:50.000000000 +0000 +++ ruby-rack-3.1.18/lib/rack/request.rb 2025-10-10 00:39:16.000000000 +0000 @@ -528,7 +528,10 @@ set_header RACK_REQUEST_FORM_PAIRS, pairs set_header RACK_REQUEST_FORM_HASH, expand_param_pairs(pairs) else - form_vars = get_header(RACK_INPUT).read + # Add 2 bytes. One to check whether it is over the limit, and a second + # in case the slice! call below removes the last byte + # If read returns nil, use the empty string + form_vars = get_header(RACK_INPUT).read(query_parser.bytesize_limit + 2) || '' # Fix for Safari Ajax postings that always append \0 # form_vars.sub!(/\0\z/, '') # performance replacement: diff -Nru ruby-rack-3.1.16/lib/rack/sendfile.rb ruby-rack-3.1.18/lib/rack/sendfile.rb --- ruby-rack-3.1.16/lib/rack/sendfile.rb 2025-06-04 22:27:50.000000000 +0000 +++ ruby-rack-3.1.18/lib/rack/sendfile.rb 2025-10-10 00:39:16.000000000 +0000 @@ -16,21 +16,21 @@ # delivery code. # # In order to take advantage of this middleware, the response body must - # respond to +to_path+ and the request must include an x-sendfile-type + # respond to +to_path+ and the request must include an `x-sendfile-type` # header. Rack::Files and other components implement +to_path+ so there's - # rarely anything you need to do in your application. The x-sendfile-type + # rarely anything you need to do in your application. The `x-sendfile-type` # header is typically set in your web servers configuration. The following # sections attempt to document # # === Nginx # - # Nginx supports the x-accel-redirect header. This is similar to x-sendfile + # Nginx supports the `x-accel-redirect` header. This is similar to `x-sendfile` # but requires parts of the filesystem to be mapped into a private URL # hierarchy. # # The following example shows the Nginx configuration required to create - # a private "/files/" area, enable x-accel-redirect, and pass the special - # x-sendfile-type and x-accel-mapping headers to the backend: + # a private "/files/" area, enable `x-accel-redirect`, and pass the special + # `x-accel-mapping` header to the backend: # # location ~ /files/(.*) { # internal; @@ -44,24 +44,29 @@ # proxy_set_header X-Real-IP $remote_addr; # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # - # proxy_set_header x-sendfile-type x-accel-redirect; # proxy_set_header x-accel-mapping /var/www/=/files/; # # proxy_pass http://127.0.0.1:8080/; # } # - # Note that the x-sendfile-type header must be set exactly as shown above. - # The x-accel-mapping header should specify the location on the file system, + # The `x-accel-mapping` header should specify the location on the file system, # followed by an equals sign (=), followed name of the private URL pattern # that it maps to. The middleware performs a simple substitution on the # resulting path. # + # To enable `x-accel-redirect`, you must configure the middleware explicitly: + # + # use Rack::Sendfile, "x-accel-redirect" + # + # For security reasons, the `x-sendfile-type` header from requests is ignored. + # The sendfile variation must be set via the middleware constructor. + # # See Also: https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile # # === lighttpd # - # Lighttpd has supported some variation of the x-sendfile header for some - # time, although only recent version support x-sendfile in a reverse proxy + # Lighttpd has supported some variation of the `x-sendfile` header for some + # time, although only recent version support `x-sendfile` in a reverse proxy # configuration. # # $HTTP["host"] == "example.com" { @@ -83,7 +88,7 @@ # # === Apache # - # x-sendfile is supported under Apache 2.x using a separate module: + # `x-sendfile` is supported under Apache 2.x using a separate module: # # https://tn123.org/mod_xsendfile/ # @@ -97,16 +102,28 @@ # === Mapping parameter # # The third parameter allows for an overriding extension of the - # x-accel-mapping header. Mappings should be provided in tuples of internal to + # `x-accel-mapping` header. Mappings should be provided in tuples of internal to # external. The internal values may contain regular expression syntax, they # will be matched with case indifference. + # + # When `x-accel-redirect` is explicitly enabled via the variation parameter, + # and no application-level mappings are provided, the middleware will read + # the `x-accel-mapping` header from the proxy. This allows nginx to control + # the path mapping without requiring application-level configuration. + # + # === Security + # + # For security reasons, the `x-sendfile-type` header from HTTP requests is + # ignored. The sendfile variation must be explicitly configured via the + # middleware constructor to prevent information disclosure vulnerabilities + # where attackers could bypass proxy restrictions. class Sendfile def initialize(app, variation = nil, mappings = []) @app = app @variation = variation @mappings = mappings.map do |internal, external| - [/^#{internal}/i, external] + [/\A#{internal}/i, external] end end @@ -145,22 +162,35 @@ end private + def variation(env) - @variation || - env['sendfile.type'] || - env['HTTP_X_SENDFILE_TYPE'] + # Note: HTTP_X_SENDFILE_TYPE is intentionally NOT read for security reasons. + # Attackers could use this header to enable x-accel-redirect and bypass proxy restrictions. + @variation || env['sendfile.type'] + end + + def x_accel_mapping(env) + # Only allow header when: + # 1. `x-accel-redirect` is explicitly enabled via constructor. + # 2. No application-level mappings are configured. + return nil unless @variation =~ /x-accel-redirect/i + return nil if @mappings.any? + + env['HTTP_X_ACCEL_MAPPING'] end def map_accel_path(env, path) if mapping = @mappings.find { |internal, _| internal =~ path } - path.sub(*mapping) - elsif mapping = env['HTTP_X_ACCEL_MAPPING'] + return path.sub(*mapping) + elsif mapping = x_accel_mapping(env) + # Safe to use header: explicit config + no app mappings: mapping.split(',').map(&:strip).each do |m| internal, external = m.split('=', 2).map(&:strip) - new_path = path.sub(/^#{internal}/i, external) + new_path = path.sub(/\A#{internal}/i, external) return new_path unless path == new_path end - path + + return path end end end diff -Nru ruby-rack-3.1.16/lib/rack/version.rb ruby-rack-3.1.18/lib/rack/version.rb --- ruby-rack-3.1.16/lib/rack/version.rb 2025-06-04 22:27:50.000000000 +0000 +++ ruby-rack-3.1.18/lib/rack/version.rb 2025-10-10 00:39:16.000000000 +0000 @@ -12,7 +12,7 @@ # so it should be enough just to require 'rack' in your code. module Rack - RELEASE = "3.1.16" + RELEASE = "3.1.18" # Return the Rack release as a dotted string. def self.release diff -Nru ruby-rack-3.1.16/test/spec_multipart.rb ruby-rack-3.1.18/test/spec_multipart.rb --- ruby-rack-3.1.16/test/spec_multipart.rb 2025-06-04 22:27:50.000000000 +0000 +++ ruby-rack-3.1.18/test/spec_multipart.rb 2025-10-10 00:39:16.000000000 +0000 @@ -197,7 +197,7 @@ env = Rack::MockRequest.env_for '/', fixture lambda { Rack::Multipart.parse_multipart(env) - }.must_raise Rack::Multipart::EmptyContentError + }.must_raise Rack::Multipart::Error rd.close err = thr.value @@ -205,6 +205,197 @@ wr.close end + it "rejects excessive data before boundary" do + rd, wr = IO.pipe + def rd.rewind; end + wr.sync = true + + thr = Thread.new do + begin + longer = "0123456789" * 1024 * 1024 + (1024 * 1024).times do + wr.write(longer) + end + + wr.write("\r\n\r\n--AaB03x") + wr.write("\r\n") + wr.write('content-disposition: form-data; name="a"; filename="a.txt"') + wr.write("\r\n") + wr.write("content-type: text/plain\r\n") + wr.write("\r\na") + wr.write("--AaB03x--\r\n") + wr.close + rescue => err # this is EPIPE if Rack shuts us down + err + end + end + + fixture = { + "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x", + "CONTENT_LENGTH" => (1024 * 1024 * 8).to_s, + :input => rd, + } + + env = Rack::MockRequest.env_for '/', fixture + lambda { + Rack::Multipart.parse_multipart(env) + }.must_raise(Rack::Multipart::Error).message.must_equal "multipart boundary not found within limit" + rd.close + + err = thr.value + err.must_be_instance_of Errno::EPIPE + wr.close + end + + it "rejects excessive mime header size" do + rd, wr = IO.pipe + def rd.rewind; end + wr.sync = true + + thr = Thread.new do + begin + wr.write("\r\n\r\n--AaB03x") + wr.write("\r\n") + wr.write('content-disposition: form-data; name="a"; filename="a.txt"') + wr.write("\r\n") + wr.write("content-type: text/plain\r\n") + longer = "0123456789" * 1024 * 1024 + (1024 * 1024).times do + wr.write(longer) + end + wr.write("\r\na") + wr.write("--AaB03x--\r\n") + wr.close + rescue => err # this is EPIPE if Rack shuts us down + err + end + end + + fixture = { + "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x", + "CONTENT_LENGTH" => (1024 * 1024 * 8).to_s, + :input => rd, + } + + env = Rack::MockRequest.env_for '/', fixture + lambda { + Rack::Multipart.parse_multipart(env) + }.must_raise(Rack::Multipart::Error).message.must_equal "multipart mime part header too large" + rd.close + + err = thr.value + err.must_be_instance_of Errno::EPIPE + wr.close + end + + it "rejects excessive buffered mime data size in a single parameter" do + rd, wr = IO.pipe + def rd.rewind; end + wr.sync = true + + thr = Thread.new do + wr.write("--AaB03x") + wr.write("\r\n") + wr.write('content-disposition: form-data; name="a"') + wr.write("\r\n") + wr.write("content-type: text/plain\r\n") + wr.write("\r\n") + wr.write("0" * 17 * 1024 * 1024) + wr.write("--AaB03x--\r\n") + wr.close + true + rescue Errno::EPIPE + # Expected when the reader closes due to size limit violation + true + end + + fixture = { + "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x", + "CONTENT_LENGTH" => (18 * 1024 * 1024).to_s, + :input => rd, + } + + env = Rack::MockRequest.env_for '/', fixture + lambda { + Rack::Multipart.parse_multipart(env) + }.must_raise(Rack::Multipart::Error).message.must_equal "multipart data over retained size limit" + rd.close + + thr.value.must_equal true + wr.close + end + + it "rejects excessive buffered mime data size when split into multiple parameters" do + rd, wr = IO.pipe + def rd.rewind; end + wr.sync = true + + thr = Thread.new do + 4.times do |i| + wr.write("\r\n--AaB03x") + wr.write("\r\n") + wr.write("content-disposition: form-data; name=\"a#{i}\"") + wr.write("\r\n") + wr.write("content-type: text/plain\r\n") + wr.write("\r\n") + wr.write("0" * 4 * 1024 * 1024) + end + wr.write("\r\n--AaB03x--\r\n") + wr.close + true + rescue Errno::EPIPE + # Expected when the reader closes due to size limit violation + true + end + + fixture = { + "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x", + "CONTENT_LENGTH" => (17 * 1024 * 1024).to_s, + :input => rd, + } + + env = Rack::MockRequest.env_for '/', fixture + lambda { + p Rack::Multipart.parse_multipart(env).keys + }.must_raise(Rack::Multipart::Error).message.must_equal "multipart data over retained size limit" + rd.close + + thr.value.must_equal true + wr.close + end + + it "allows large nonbuffered mime parameters" do + rd, wr = IO.pipe + def rd.rewind; end + wr.sync = true + + thr = Thread.new do + wr.write("\r\n\r\n--AaB03x") + wr.write("\r\n") + wr.write('content-disposition: form-data; name="a"; filename="a.txt"') + wr.write("\r\n") + wr.write("content-type: text/plain\r\n") + wr.write("\r\n") + wr.write("0" * 16 * 1024 * 1024) + wr.write("\r\n--AaB03x--\r\n") + wr.close + true + end + + fixture = { + "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x", + "CONTENT_LENGTH" => (17 * 1024 * 1024).to_s, + :input => rd, + } + + env = Rack::MockRequest.env_for '/', fixture + Rack::Multipart.parse_multipart(env)['a'][:tempfile].read.bytesize.must_equal(16 * 1024 * 1024) + rd.close + + thr.value.must_equal true + wr.close + end + # see https://github.com/rack/rack/pull/1309 it "parses strange multipart pdf" do boundary = '---------------------------932620571087722842402766118' diff -Nru ruby-rack-3.1.16/test/spec_request.rb ruby-rack-3.1.18/test/spec_request.rb --- ruby-rack-3.1.16/test/spec_request.rb 2025-06-04 22:27:50.000000000 +0000 +++ ruby-rack-3.1.18/test/spec_request.rb 2025-10-10 00:39:16.000000000 +0000 @@ -796,6 +796,84 @@ req.POST.must_equal "foo" => "bar", "quux" => "bla" end + it "limit POST body read to bytesize_limit when parsing url-encoded data" do + # Create a mock input that tracks read calls + reads = [] + mock_input = Object.new + mock_input.define_singleton_method(:read) do |len=nil| + reads << len + # Return mutable string + "foo=bar".dup + end + + request = make_request \ + Rack::MockRequest.env_for("/", + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded', + 'rack.input' => mock_input) + + request.POST.must_equal "foo" => "bar" + + # Verify read was called with a limit (bytesize_limit + 2), not nil + reads.size.must_equal 1 + reads.first.wont_be_nil + reads.first.must_equal(request.send(:query_parser).bytesize_limit + 2) + end + + it "handle nil return from rack.input.read when parsing url-encoded data" do + # Simulate an input that returns nil on read + mock_input = Object.new + mock_input.define_singleton_method(:read) do |len=nil| + nil + end + + request = make_request \ + Rack::MockRequest.env_for("/", + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded', + 'rack.input' => mock_input) + + # Should handle nil gracefully and return empty hash + request.POST.must_equal({}) + end + + it "truncate POST body at bytesize_limit when parsing url-encoded data" do + # Create input larger than limit + large_body = "a=1&" * 1000000 # Very large body + + request = make_request \ + Rack::MockRequest.env_for("/", + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded', + :input => large_body) + + # Should parse only up to the limit without reading entire body into memory + # The actual parsing may fail due to size limit, which is expected + proc { request.POST }.must_raise Rack::QueryParser::QueryLimitError + end + + it "clean up Safari's ajax POST body with limited read" do + # Verify Safari null-byte cleanup still works with bounded read + reads = [] + mock_input = Object.new + mock_input.define_singleton_method(:read) do |len=nil| + reads << len + # Return mutable string (dup ensures it's not frozen) + "foo=bar\0".dup + end + + request = make_request \ + Rack::MockRequest.env_for("/", + 'REQUEST_METHOD' => 'POST', + 'CONTENT_TYPE' => 'application/x-www-form-urlencoded', + 'rack.input' => mock_input) + + request.POST.must_equal "foo" => "bar" + + # Verify bounded read was used + reads.first.wont_be_nil + end + it "return values for the keys in the order given from values_at" do req = make_request Rack::MockRequest.env_for("?foo=baz&wun=der&bar=ful") diff -Nru ruby-rack-3.1.16/test/spec_sendfile.rb ruby-rack-3.1.18/test/spec_sendfile.rb --- ruby-rack-3.1.16/test/spec_sendfile.rb 2025-06-04 22:27:50.000000000 +0000 +++ ruby-rack-3.1.18/test/spec_sendfile.rb 2025-10-10 00:39:16.000000000 +0000 @@ -22,12 +22,12 @@ lambda { |env| [200, { 'content-type' => 'text/plain' }, body] } end - def sendfile_app(body, mappings = []) - Rack::Lint.new Rack::Sendfile.new(simple_app(body), nil, mappings) + def sendfile_app(body, mappings = [], variation = nil) + Rack::Lint.new Rack::Sendfile.new(simple_app(body), variation, mappings) end - def request(headers = {}, body = sendfile_body, mappings = []) - yield Rack::MockRequest.new(sendfile_app(body, mappings)).get('/', headers) + def request(headers = {}, body = sendfile_body, mappings = [], variation = nil) + yield Rack::MockRequest.new(sendfile_app(body, mappings, variation)).get('/', headers) end def open_file(path) @@ -48,7 +48,8 @@ it "does nothing and logs to rack.errors when incorrect x-sendfile-type header present" do io = StringIO.new - request 'HTTP_X_SENDFILE_TYPE' => 'X-Banana', 'rack.errors' => io do |response| + # Configure with wrong variation type + request({ 'rack.errors' => io }, sendfile_body, [], 'X-Banana') do |response| response.must_be :ok? response.body.must_equal 'Hello World' response.headers.wont_include 'x-sendfile' @@ -59,7 +60,7 @@ end it "sets x-sendfile response header and discards body" do - request 'HTTP_X_SENDFILE_TYPE' => 'x-sendfile' do |response| + request({}, sendfile_body, [], 'X-Sendfile') do |response| response.must_be :ok? response.body.must_be :empty? response.headers['content-length'].must_equal '0' @@ -71,7 +72,7 @@ body = sendfile_body closed = false body.define_singleton_method(:close){closed = true} - request({'HTTP_X_SENDFILE_TYPE' => 'x-sendfile'}, body) do |response| + request({}, body, [], 'X-Sendfile') do |response| response.must_be :ok? response.body.must_be :empty? response.headers['content-length'].must_equal '0' @@ -81,7 +82,7 @@ end it "sets x-lighttpd-send-file response header and discards body" do - request 'HTTP_X_SENDFILE_TYPE' => 'x-lighttpd-send-file' do |response| + request({}, sendfile_body, [], 'X-Lighttpd-Send-File') do |response| response.must_be :ok? response.body.must_be :empty? response.headers['content-length'].must_equal '0' @@ -91,10 +92,9 @@ it "sets x-accel-redirect response header and discards body" do headers = { - 'HTTP_X_SENDFILE_TYPE' => 'x-accel-redirect', 'HTTP_X_ACCEL_MAPPING' => "#{Dir.tmpdir}/=/foo/bar/" } - request headers do |response| + request(headers, sendfile_body, [], 'X-Accel-Redirect') do |response| response.must_be :ok? response.body.must_be :empty? response.headers['content-length'].must_equal '0' @@ -104,10 +104,9 @@ it "sets x-accel-redirect response header to percent-encoded path" do headers = { - 'HTTP_X_SENDFILE_TYPE' => 'x-accel-redirect', 'HTTP_X_ACCEL_MAPPING' => "#{Dir.tmpdir}/=/foo/bar%/" } - request headers, sendfile_body('file_with_%_?_symbol') do |response| + request(headers, sendfile_body('file_with_%_?_symbol'), [], 'X-Accel-Redirect') do |response| response.must_be :ok? response.body.must_be :empty? response.headers['content-length'].must_equal '0' @@ -116,7 +115,7 @@ end it 'writes to rack.error when no x-accel-mapping is specified' do - request 'HTTP_X_SENDFILE_TYPE' => 'x-accel-redirect' do |response| + request({}, sendfile_body, [], 'X-Accel-Redirect') do |response| response.must_be :ok? response.body.must_equal 'Hello World' response.headers.wont_include 'x-accel-redirect' @@ -125,7 +124,7 @@ end it 'does nothing when body does not respond to #to_path' do - request({ 'HTTP_X_SENDFILE_TYPE' => 'x-sendfile' }, ['Not a file...']) do |response| + request({}, ['Not a file...'], [], 'X-Sendfile') do |response| response.body.must_equal 'Not a file...' response.headers.wont_include 'x-sendfile' end @@ -147,14 +146,14 @@ ["#{dir2}/", '/wibble/'] ] - request({ 'HTTP_X_SENDFILE_TYPE' => 'x-accel-redirect' }, first_body, mappings) do |response| + request({}, first_body, mappings, 'X-Accel-Redirect') do |response| response.must_be :ok? response.body.must_be :empty? response.headers['content-length'].must_equal '0' response.headers['x-accel-redirect'].must_equal '/foo/bar/rack_sendfile' end - request({ 'HTTP_X_SENDFILE_TYPE' => 'x-accel-redirect' }, second_body, mappings) do |response| + request({}, second_body, mappings, 'X-Accel-Redirect') do |response| response.must_be :ok? response.body.must_be :empty? response.headers['content-length'].must_equal '0' @@ -181,34 +180,142 @@ third_body = open_file(File.join(dir3, 'rack_sendfile')) third_body.puts 'hello again world' + # Now we need to explicitly enable x-accel-redirect in the constructor + app = Rack::Lint.new Rack::Sendfile.new(simple_app(first_body), "X-Accel-Redirect", []) + headers = { - 'HTTP_X_SENDFILE_TYPE' => 'x-accel-redirect', 'HTTP_X_ACCEL_MAPPING' => "#{dir1}/=/foo/bar/, #{dir2}/=/wibble/" } - request(headers, first_body) do |response| + response = Rack::MockRequest.new(app).get('/', headers) + response.must_be :ok? + response.body.must_be :empty? + response.headers['content-length'].must_equal '0' + response.headers['x-accel-redirect'].must_equal '/foo/bar/rack_sendfile' + + app = Rack::Lint.new Rack::Sendfile.new(simple_app(second_body), "X-Accel-Redirect", []) + response = Rack::MockRequest.new(app).get('/', headers) + response.must_be :ok? + response.body.must_be :empty? + response.headers['content-length'].must_equal '0' + response.headers['x-accel-redirect'].must_equal '/wibble/rack_sendfile' + + app = Rack::Lint.new Rack::Sendfile.new(simple_app(third_body), "X-Accel-Redirect", []) + response = Rack::MockRequest.new(app).get('/', headers) + response.must_be :ok? + response.body.must_be :empty? + response.headers['content-length'].must_equal '0' + response.headers['x-accel-redirect'].must_equal "#{dir3}/rack_sendfile" + ensure + FileUtils.remove_entry_secure dir1 + FileUtils.remove_entry_secure dir2 + FileUtils.remove_entry_secure dir3 + end + end + + # Security tests for CVE mitigation + describe "security: information disclosure prevention" do + it "ignores HTTP_X_SENDFILE_TYPE header to prevent attacker-controlled sendfile activation" do + # Attacker tries to enable x-sendfile via header + request 'HTTP_X_SENDFILE_TYPE' => 'x-sendfile' do |response| response.must_be :ok? - response.body.must_be :empty? - response.headers['content-length'].must_equal '0' - response.headers['x-accel-redirect'].must_equal '/foo/bar/rack_sendfile' + response.body.must_equal 'Hello World' + response.headers.wont_include 'x-sendfile' end + end - request(headers, second_body) do |response| + it "ignores HTTP_X_SENDFILE_TYPE header attempting to enable x-accel-redirect" do + # Attacker tries to enable x-accel-redirect via header with mapping + headers = { + 'HTTP_X_SENDFILE_TYPE' => 'x-accel-redirect', + 'HTTP_X_ACCEL_MAPPING' => "#{Dir.tmpdir}/=/attacker/path/" + } + request headers do |response| + response.must_be :ok? + response.body.must_equal 'Hello World' + response.headers.wont_include 'x-accel-redirect' + end + end + + it "ignores HTTP_X_ACCEL_MAPPING when x-accel-redirect is not explicitly enabled" do + # Even if attacker sends mapping header, it should be ignored without explicit config + headers = { + 'HTTP_X_ACCEL_MAPPING' => "#{Dir.tmpdir}/=/attacker/path/" + } + request headers do |response| + response.must_be :ok? + response.body.must_equal 'Hello World' + response.headers.wont_include 'x-accel-redirect' + end + end + + it "ignores HTTP_X_ACCEL_MAPPING when application-level mappings are configured" do + # When app provides mappings, header should be ignored for security + begin + dir = Dir.mktmpdir + body = open_file(File.join(dir, 'rack_sendfile')) + body.puts 'test' + + app_mappings = [["#{dir}/", '/app/mapping/']] + app = Rack::Lint.new Rack::Sendfile.new(simple_app(body), "X-Accel-Redirect", app_mappings) + + headers = { + 'HTTP_X_ACCEL_MAPPING' => "#{dir}/=/attacker/path/" + } + + response = Rack::MockRequest.new(app).get('/', headers) response.must_be :ok? response.body.must_be :empty? - response.headers['content-length'].must_equal '0' - response.headers['x-accel-redirect'].must_equal '/wibble/rack_sendfile' + response.headers['x-accel-redirect'].must_equal '/app/mapping/rack_sendfile' + response.headers['x-accel-redirect'].wont_equal '/attacker/path/rack_sendfile' + ensure + FileUtils.remove_entry_secure dir end + end - request(headers, third_body) do |response| + it "allows HTTP_X_ACCEL_MAPPING only when x-accel-redirect explicitly enabled with no app mappings" do + # This is the safe use case: explicit config + no app mappings = allow header + begin + dir = Dir.mktmpdir + body = open_file(File.join(dir, 'rack_sendfile')) + body.puts 'test' + + app = Rack::Lint.new Rack::Sendfile.new(simple_app(body), "X-Accel-Redirect", []) + + headers = { + 'HTTP_X_ACCEL_MAPPING' => "#{dir}/=/safe/nginx/mapping/" + } + + response = Rack::MockRequest.new(app).get('/', headers) response.must_be :ok? response.body.must_be :empty? - response.headers['content-length'].must_equal '0' - response.headers['x-accel-redirect'].must_equal "#{dir3}/rack_sendfile" + response.headers['x-accel-redirect'].must_equal '/safe/nginx/mapping/rack_sendfile' + ensure + FileUtils.remove_entry_secure dir end - ensure - FileUtils.remove_entry_secure dir1 - FileUtils.remove_entry_secure dir2 + end + + it "does not allow x-lighttpd-send-file activation via header" do + # Verify other sendfile types also can't be enabled via headers + request 'HTTP_X_SENDFILE_TYPE' => 'x-lighttpd-send-file' do |response| + response.must_be :ok? + response.body.must_equal 'Hello World' + response.headers.wont_include 'x-lighttpd-send-file' + end + end + + it "requires explicit middleware configuration for any sendfile variation" do + # Test that sendfile.type env var still works (internal, not from HTTP headers) + body = sendfile_body + app = lambda { |env| [200, { 'content-type' => 'text/plain' }, body] } + middleware = Rack::Lint.new Rack::Sendfile.new(app) + + env = Rack::MockRequest.env_for('/', { 'sendfile.type' => 'x-sendfile' }) + status, headers, response_body = middleware.call(env) + + status.must_equal 200 + headers['x-sendfile'].must_equal File.join(Dir.tmpdir, "rack_sendfile") + headers['content-length'].must_equal '0' end end end