Version in base suite: 2.2.13-1~deb12u1 Base version: ruby-rack_2.2.13-1~deb12u1 Target version: ruby-rack_2.2.20-0+deb12u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/r/ruby-rack/ruby-rack_2.2.13-1~deb12u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/r/ruby-rack/ruby-rack_2.2.20-0+deb12u1.dsc CHANGELOG.md | 50 ++ README.rdoc | 35 + debian/changelog | 22 + debian/patches/0002-Make-tests-pass-on-hosts-that-have-no-ipv4-connectiv.patch | 2 lib/rack/handler/thin.rb | 2 lib/rack/media_type.rb | 11 lib/rack/mock.rb | 35 + lib/rack/multipart/parser.rb | 62 +++ lib/rack/query_parser.rb | 65 ++- lib/rack/request.rb | 5 lib/rack/sendfile.rb | 50 ++ lib/rack/session/pool.rb | 7 lib/rack/version.rb | 2 test/spec_media_type.rb | 24 + test/spec_mock.rb | 1 test/spec_multipart.rb | 185 +++++++++ test/spec_query_parser.rb | 40 ++ test/spec_request.rb | 97 +++++ test/spec_sendfile.rb | 188 ++++++++-- test/spec_session_cookie.rb | 52 +- test/spec_session_pool.rb | 84 +++- test/spec_thin.rb | 2 22 files changed, 903 insertions(+), 118 deletions(-) diff -Nru ruby-rack-2.2.13/CHANGELOG.md ruby-rack-2.2.20/CHANGELOG.md --- ruby-rack-2.2.13/CHANGELOG.md 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/CHANGELOG.md 2025-10-10 00:36:11.000000000 +0000 @@ -2,23 +2,63 @@ 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/). +## [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]) + +## [2.2.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]) + +## [2.2.14] - 2025-05-06 + +### Security + +- [CVE-2025-32441](https://github.com/advisories/GHSA-vpfw-47h7-xj4g) Rack session can be restored after deletion. +- [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 @@ -770,3 +810,7 @@ - Removed Rails adapter, was too alpha. ## [0.1] 2007-03-03 + +[@ioquatix]: https://github.com/ioquatix "Samuel Williams" +[@jeremyevans]: https://github.com/jeremyevans "Jeremy Evans" +[@earlopain]: https://github.com/earlopain "Earlopain" diff -Nru ruby-rack-2.2.13/README.rdoc ruby-rack-2.2.20/README.rdoc --- ruby-rack-2.2.13/README.rdoc 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/README.rdoc 2025-10-10 00:36:11.000000000 +0000 @@ -179,6 +179,41 @@ Rack::Utils.key_space_limit = 128 +=== `RACK_QUERY_PARSER_BYTESIZE_LIMIT` + +This environment variable sets the default for the maximum query string bytesize +that `Rack::QueryParser` will attempt to parse. Attempts to use a query string +that exceeds this number of bytes will result in a +`Rack::QueryParser::QueryLimitError` exception. If this enviroment variable is +provided, it must be an integer, or `Rack::QueryParser` will raise an exception. + +The default limit can be overridden on a per-`Rack::QueryParser` basis using +the `bytesize_limit` keyword argument when creating the `Rack::QueryParser`. + +=== `RACK_QUERY_PARSER_PARAMS_LIMIT` + +This environment variable sets the default for the maximum number of query +parameters that `Rack::QueryParser` will attempt to parse. Attempts to use a +query string with more than this many query parameters will result in a +`Rack::QueryParser::QueryLimitError` exception. If this enviroment variable is +provided, it must be an integer, or `Rack::QueryParser` will raise an exception. + +The default limit can be overridden on a per-`Rack::QueryParser` basis using +the `params_limit` keyword argument when creating the `Rack::QueryParser`. + +This is implemented by counting the number of parameter separators in the +query string, before attempting parsing, so if the same parameter key is +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. + === key_space_limit The default number of bytes to allow all parameters keys in a given parameter hash to take up. diff -Nru ruby-rack-2.2.13/debian/changelog ruby-rack-2.2.20/debian/changelog --- ruby-rack-2.2.13/debian/changelog 2025-03-20 03:57:37.000000000 +0000 +++ ruby-rack-2.2.20/debian/changelog 2025-10-23 08:54:27.000000000 +0000 @@ -1,3 +1,25 @@ +ruby-rack (2.2.20-0+deb12u1) bookworm-security; urgency=medium + + * New upstream version 2.2.20. + - CVE-2025-32441: Rack session can be restored after deletion. + - CVE-2025-46727: Unbounded parameter parsing in Rack::QueryParser + can lead to memory exhaustion. + - CVE-2025-59830: Unbounded parameter parsing in Rack::QueryParser + can lead to memory exhaustion via semicolon-separated parameters. + - CVE-2025-61770: Unbounded multipart preamble buffering enables DoS + (memory exhaustion). + - CVE-2025-61771: Multipart parser buffers large non‑file fields + entirely in memory, enabling DoS (memory exhaustion). + - CVE-2025-61772: Multipart parser buffers unbounded per-part headers, + enabling DoS (memory exhaustion). + - CVE-2025-61919 Unbounded read in Rack::Request form parsing can lead + to memory exhaustion. + - CVE-2025-61780 Improper handling of headers in Rack::Sendfile may + allow proxy bypass. + - Closes: #1104927, #1116431, #1117855, #1117856, #1117627, #1117628 + + -- Utkarsh Gupta Thu, 23 Oct 2025 09:54:27 +0100 + ruby-rack (2.2.13-1~deb12u1) bookworm-security; urgency=medium * New upstream version 2.2.13. diff -Nru ruby-rack-2.2.13/debian/patches/0002-Make-tests-pass-on-hosts-that-have-no-ipv4-connectiv.patch ruby-rack-2.2.20/debian/patches/0002-Make-tests-pass-on-hosts-that-have-no-ipv4-connectiv.patch --- ruby-rack-2.2.13/debian/patches/0002-Make-tests-pass-on-hosts-that-have-no-ipv4-connectiv.patch 2025-03-20 03:57:37.000000000 +0000 +++ ruby-rack-2.2.20/debian/patches/0002-Make-tests-pass-on-hosts-that-have-no-ipv4-connectiv.patch 2025-10-23 08:54:27.000000000 +0000 @@ -48,7 +48,7 @@ end end @@ -43,7 +43,7 @@ - response["HTTP_VERSION"].must_equal "HTTP/1.1" + # response["HTTP_VERSION"].must_equal "HTTP/1.1" response["SERVER_PROTOCOL"].must_equal "HTTP/1.1" response["SERVER_PORT"].must_equal "9204" - response["SERVER_NAME"].must_equal "127.0.0.1" diff -Nru ruby-rack-2.2.13/lib/rack/handler/thin.rb ruby-rack-2.2.20/lib/rack/handler/thin.rb --- ruby-rack-2.2.13/lib/rack/handler/thin.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/lib/rack/handler/thin.rb 2025-10-10 00:36:11.000000000 +0000 @@ -15,8 +15,6 @@ host = options.delete(:Host) || default_host port = options.delete(:Port) || 8080 args = [host, port, app, options] - # Thin versions below 0.8.0 do not support additional options - args.pop if ::Thin::VERSION::MAJOR < 1 && ::Thin::VERSION::MINOR < 8 server = ::Thin::Server.new(*args) yield server if block_given? server.start diff -Nru ruby-rack-2.2.13/lib/rack/media_type.rb ruby-rack-2.2.20/lib/rack/media_type.rb --- ruby-rack-2.2.13/lib/rack/media_type.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/lib/rack/media_type.rb 2025-10-10 00:36:11.000000000 +0000 @@ -27,6 +27,11 @@ # provided. e.g., when the CONTENT_TYPE is "text/plain;charset=utf-8", # this method responds with the following Hash: # { 'charset' => 'utf-8' } + # + # This will pass back parameters with empty strings in the hash if they + # lack a value (e.g., "text/plain;charset=" will return { 'charset' => '' }, + # and "text/plain;charset" will return { 'charset' => '' }, similarly to + # the query params parser (barring the latter case, which returns nil instead)). def params(content_type) return {} if content_type.nil? @@ -40,9 +45,9 @@ private - def strip_doublequotes(str) - (str.start_with?('"') && str.end_with?('"')) ? str[1..-2] : str - end + def strip_doublequotes(str) + (str && str.start_with?('"') && str.end_with?('"')) ? str[1..-2] : str || '' + end end end end diff -Nru ruby-rack-2.2.13/lib/rack/mock.rb ruby-rack-2.2.20/lib/rack/mock.rb --- ruby-rack-2.2.13/lib/rack/mock.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/lib/rack/mock.rb 2025-10-10 00:36:11.000000000 +0000 @@ -3,7 +3,6 @@ require 'uri' require 'stringio' require_relative '../rack' -require 'cgi/cookie' module Rack # Rack::MockRequest helps testing your Rack application without @@ -171,6 +170,36 @@ # MockRequest. class MockResponse < Rack::Response + begin + # Recent versions of the CGI gem may not provide `CGI::Cookie`. + require 'cgi/cookie' + Cookie = CGI::Cookie + rescue LoadError + class Cookie + attr_reader :name, :value, :path, :domain, :expires, :secure + + def initialize(args) + @name = args["name"] + @value = args["value"] + @path = args["path"] + @domain = args["domain"] + @expires = args["expires"] + @secure = args["secure"] + end + + def method_missing(method_name, *args, &block) + @value.send(method_name, *args, &block) + end + # :nocov: + ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true) + # :nocov: + + def respond_to_missing?(method_name, include_all = false) + @value.respond_to?(method_name, include_all) || super + end + end + end + class << self alias [] new end @@ -236,7 +265,7 @@ set_cookie_header.split("\n").each do |cookie| cookie_name, cookie_filling = cookie.split('=', 2) cookie_attributes = identify_cookie_attributes cookie_filling - parsed_cookie = CGI::Cookie.new( + parsed_cookie = Cookie.new( 'name' => cookie_name.strip, 'value' => cookie_attributes.fetch('value'), 'path' => cookie_attributes.fetch('path', nil), @@ -253,7 +282,7 @@ def identify_cookie_attributes(cookie_filling) cookie_bits = cookie_filling.split(';') cookie_attributes = Hash.new - cookie_attributes.store('value', cookie_bits[0].strip) + cookie_attributes.store('value', Array(cookie_bits[0].strip)) cookie_bits.each do |bit| if bit.include? '=' cookie_attribute, attribute_value = bit.split('=') diff -Nru ruby-rack-2.2.13/lib/rack/multipart/parser.rb ruby-rack-2.2.20/lib/rack/multipart/parser.rb --- ruby-rack-2.2.13/lib/rack/multipart/parser.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/lib/rack/multipart/parser.rb 2025-10-10 00:36:11.000000000 +0000 @@ -20,6 +20,27 @@ BOUNDARY_REGEX = /\A([^\n]*(?:\n|\Z))/ + 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 @@ -187,6 +208,8 @@ @end_boundary = @boundary + '--' @state = :FAST_FORWARD @mime_index = 0 + @body_retained = nil + @retained_size = 0 @collector = Collector.new tempfile @sbuf = StringScanner.new("".dup) @@ -241,7 +264,13 @@ @state = :MIME_HEAD else raise EOFError, "bad content body" if @sbuf.rest_size >= @bufsize - :want_read + + # 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 EOFError, "multipart boundary not found within limit" if @sbuf.string.bytesize > BOUNDARY_START_LIMIT + + # no boundary found, keep reading data + return :want_read end end @@ -271,16 +300,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 EOFError, "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}\z/m, '') # 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 @@ -289,7 +332,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 @@ -299,6 +344,17 @@ def full_boundary; @full_boundary; end + def update_retained_size(size) + @retained_size += size + if @retained_size > BUFFERED_UPLOAD_BYTESIZE_LIMIT + raise EOFError, "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 + # boundary, clear the buffer and return nil. def consume_boundary while read_buffer = @sbuf.scan_until(BOUNDARY_REGEX) case read_buffer.strip diff -Nru ruby-rack-2.2.13/lib/rack/query_parser.rb ruby-rack-2.2.20/lib/rack/query_parser.rb --- ruby-rack-2.2.13/lib/rack/query_parser.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/lib/rack/query_parser.rb 2025-10-10 00:36:11.000000000 +0000 @@ -16,20 +16,49 @@ # sequence. class InvalidParameterError < ArgumentError; end - # ParamsTooDeepError is the error that is raised when params are recursively - # nested over the specified limit. - class ParamsTooDeepError < RangeError; end + # QueryLimitError is for errors raised when the query provided exceeds one + # of the query parser limits. + class QueryLimitError < RangeError + end + + # ParamsTooDeepError is the old name for the error that is raised when params + # are recursively nested over the specified limit. Make it the same as + # as QueryLimitError, so that code that rescues ParamsTooDeepError error + # to handle bad query strings also now handles other limits. + ParamsTooDeepError = QueryLimitError - def self.make_default(key_space_limit, param_depth_limit) - new Params, key_space_limit, param_depth_limit + def self.make_default(key_space_limit, param_depth_limit, **options) + new(Params, key_space_limit, param_depth_limit, **options) end attr_reader :key_space_limit, :param_depth_limit - def initialize(params_class, key_space_limit, param_depth_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 + + BYTESIZE_LIMIT = env_int.call("RACK_QUERY_PARSER_BYTESIZE_LIMIT", 4194304) + private_constant :BYTESIZE_LIMIT + + PARAMS_LIMIT = env_int.call("RACK_QUERY_PARSER_PARAMS_LIMIT", 4096) + private_constant :PARAMS_LIMIT + + attr_reader :bytesize_limit + + def initialize(params_class, key_space_limit, param_depth_limit, bytesize_limit: BYTESIZE_LIMIT, params_limit: PARAMS_LIMIT) @params_class = params_class @key_space_limit = key_space_limit @param_depth_limit = param_depth_limit + @bytesize_limit = bytesize_limit + @params_limit = params_limit end # Stolen from Mongrel, with some small modifications: @@ -42,7 +71,7 @@ params = make_params - (qs || '').split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p| + check_query_string(qs, d).split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p| next if p.empty? k, v = p.split('=', 2).map!(&unescaper) @@ -69,7 +98,7 @@ params = make_params unless qs.nil? || qs.empty? - (qs || '').split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p| + check_query_string(qs, d).split(d ? (COMMON_SEP[d] || /[#{d}] */n) : DEFAULT_SEP).each do |p| k, v = p.split('=', 2).map! { |s| unescape(s) } normalize_params(params, k, v, param_depth_limit) @@ -155,8 +184,24 @@ true end - def unescape(s) - Utils.unescape(s) + def check_query_string(qs, sep) + if qs + if qs.bytesize > @bytesize_limit + raise QueryLimitError, "total query size exceeds limit (#{@bytesize_limit})" + end + + if (param_count = qs.count(sep.is_a?(String) ? sep : '&;')) >= @params_limit + raise QueryLimitError, "total number of query parameters (#{param_count+1}) exceeds limit (#{@params_limit})" + end + + qs + else + '' + end + end + + def unescape(string) + Utils.unescape(string) end class Params diff -Nru ruby-rack-2.2.13/lib/rack/request.rb ruby-rack-2.2.20/lib/rack/request.rb --- ruby-rack-2.2.13/lib/rack/request.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/lib/rack/request.rb 2025-10-10 00:36:11.000000000 +0000 @@ -444,7 +444,10 @@ get_header(RACK_REQUEST_FORM_HASH) elsif form_data? || parseable_data? unless set_header(RACK_REQUEST_FORM_HASH, parse_multipart) - 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-2.2.13/lib/rack/sendfile.rb ruby-rack-2.2.20/lib/rack/sendfile.rb --- ruby-rack-2.2.13/lib/rack/sendfile.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/lib/rack/sendfile.rb 2025-10-10 00:36:11.000000000 +0000 @@ -40,18 +40,23 @@ # 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, # 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 @@ -96,13 +101,25 @@ # 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 @@ -140,22 +157,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-2.2.13/lib/rack/session/pool.rb ruby-rack-2.2.20/lib/rack/session/pool.rb --- ruby-rack-2.2.13/lib/rack/session/pool.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/lib/rack/session/pool.rb 2025-10-10 00:36:11.000000000 +0000 @@ -55,6 +55,7 @@ def write_session(req, session_id, new_session, options) with_lock(req) do + return false unless get_session_with_fallback(session_id) @pool.store session_id.private_id, new_session session_id end @@ -64,7 +65,11 @@ with_lock(req) do @pool.delete(session_id.public_id) @pool.delete(session_id.private_id) - generate_sid unless options[:drop] + unless options[:drop] + sid = generate_sid + @pool.store(sid.private_id, {}) + sid + end end end diff -Nru ruby-rack-2.2.13/lib/rack/version.rb ruby-rack-2.2.20/lib/rack/version.rb --- ruby-rack-2.2.13/lib/rack/version.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/lib/rack/version.rb 2025-10-10 00:36:11.000000000 +0000 @@ -20,7 +20,7 @@ VERSION.join(".") end - RELEASE = "2.2.13" + RELEASE = "2.2.20" # Return the Rack release as a dotted string. def self.release diff -Nru ruby-rack-2.2.13/test/spec_media_type.rb ruby-rack-2.2.20/test/spec_media_type.rb --- ruby-rack-2.2.13/test/spec_media_type.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/test/spec_media_type.rb 2025-10-10 00:36:11.000000000 +0000 @@ -40,4 +40,28 @@ Rack::MediaType.params(@content_type)['charset'].must_equal 'utf-8' end end + + describe 'when content_type contains media_type and incomplete params' do + before { @content_type = 'application/text;CHARSET' } + + it '#type is application/text' do + Rack::MediaType.type(@content_type).must_equal 'application/text' + end + + it '#params has key "charset" with value ""' do + Rack::MediaType.params(@content_type)['charset'].must_equal '' + end + end + + describe 'when content_type contains media_type and empty params' do + before { @content_type = 'application/text;CHARSET=' } + + it '#type is application/text' do + Rack::MediaType.type(@content_type).must_equal 'application/text' + end + + it '#params has key "charset" with value of empty string' do + Rack::MediaType.params(@content_type)['charset'].must_equal '' + end + end end diff -Nru ruby-rack-2.2.13/test/spec_mock.rb ruby-rack-2.2.20/test/spec_mock.rb --- ruby-rack-2.2.13/test/spec_mock.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/test/spec_mock.rb 2025-10-10 00:36:11.000000000 +0000 @@ -292,6 +292,7 @@ it "provide access to session cookies" do res = Rack::MockRequest.new(app).get("") session_cookie = res.cookie("session_test") + session_cookie[0].must_equal "session_test" session_cookie.value[0].must_equal "session_test" session_cookie.domain.must_equal "test.com" session_cookie.path.must_equal "/" diff -Nru ruby-rack-2.2.13/test/spec_multipart.rb ruby-rack-2.2.20/test/spec_multipart.rb --- ruby-rack-2.2.13/test/spec_multipart.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/test/spec_multipart.rb 2025-10-10 00:36:11.000000000 +0000 @@ -159,6 +159,191 @@ 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(EOFError).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(EOFError).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 + 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(EOFError).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 + 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(EOFError).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 "parse strange multipart pdf" do boundary = '---------------------------932620571087722842402766118' diff -Nru ruby-rack-2.2.13/test/spec_query_parser.rb ruby-rack-2.2.20/test/spec_query_parser.rb --- ruby-rack-2.2.13/test/spec_query_parser.rb 1970-01-01 00:00:00.000000000 +0000 +++ ruby-rack-2.2.20/test/spec_query_parser.rb 2025-10-10 00:36:11.000000000 +0000 @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative 'helper' +require_relative '../lib/rack/query_parser' + +describe Rack::QueryParser do + it "can normalize values with missing values" do + query_parser = Rack::QueryParser.make_default(Rack::Utils.key_space_limit, 8) + query_parser.parse_nested_query("a=a").must_equal({"a" => "a"}) + query_parser.parse_nested_query("a=").must_equal({"a" => ""}) + query_parser.parse_nested_query("a").must_equal({"a" => nil}) + end + + it "accepts bytesize_limit to specify maximum size of query string to parse" do + query_parser = Rack::QueryParser.make_default(Rack::Utils.key_space_limit, 32, bytesize_limit: 3) + query_parser.parse_query("a=a").must_equal({"a" => "a"}) + query_parser.parse_nested_query("a=a").must_equal({"a" => "a"}) + query_parser.parse_nested_query("a=a", '&').must_equal({"a" => "a"}) + proc { query_parser.parse_query("a=aa") }.must_raise Rack::QueryParser::QueryLimitError + proc { query_parser.parse_nested_query("a=aa") }.must_raise Rack::QueryParser::QueryLimitError + proc { query_parser.parse_nested_query("a=aa", '&') }.must_raise Rack::QueryParser::QueryLimitError + end + + it "accepts params_limit to specify maximum number of query parameters to parse" do + query_parser = Rack::QueryParser.make_default(Rack::Utils.key_space_limit, 32, params_limit: 2) + query_parser.parse_query("a=a&b=b").must_equal({"a" => "a", "b" => "b"}) + query_parser.parse_nested_query("a=a&b=b").must_equal({"a" => "a", "b" => "b"}) + query_parser.parse_nested_query("a=a&b=b", '&').must_equal({"a" => "a", "b" => "b"}) + proc { query_parser.parse_query("a=a&b=b&c=c") }.must_raise Rack::QueryParser::QueryLimitError + proc { query_parser.parse_nested_query("a=a&b=b&c=c", '&') }.must_raise Rack::QueryParser::QueryLimitError + proc { query_parser.parse_query("b[]=a&b[]=b&b[]=c") }.must_raise Rack::QueryParser::QueryLimitError + + query_parser.parse_query("a=a;b=b").must_equal({"a" => "a", "b" => "b"}) + query_parser.parse_nested_query("a=a;b=b").must_equal({"a" => "a", "b" => "b"}) + query_parser.parse_nested_query("a=a;b=b", ';').must_equal({"a" => "a", "b" => "b"}) + proc { query_parser.parse_query("a=a;b=b;c=c") }.must_raise Rack::QueryParser::QueryLimitError + proc { query_parser.parse_nested_query("a=a;b=b;c=c", ';') }.must_raise Rack::QueryParser::QueryLimitError + proc { query_parser.parse_query("b[]=a;b[]=b;b[]=c") }.must_raise Rack::QueryParser::QueryLimitError + end +end diff -Nru ruby-rack-2.2.13/test/spec_request.rb ruby-rack-2.2.20/test/spec_request.rb --- ruby-rack-2.2.13/test/spec_request.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/test/spec_request.rb 2025-10-10 00:36:11.000000000 +0000 @@ -1,7 +1,7 @@ # frozen_string_literal: true require_relative 'helper' -require 'cgi' +require 'cgi/escape' require 'forwardable' require 'securerandom' @@ -499,6 +499,93 @@ 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 + mock_input.define_singleton_method(:rewind) do + # no-op for compatibility + 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 + mock_input.define_singleton_method(:rewind) do + # no-op for compatibility + 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 + mock_input.define_singleton_method(:rewind) do + # no-op for compatibility + 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 "get value by key from params with #[]" do req = make_request \ Rack::MockRequest.env_for("?foo=quux") @@ -1535,6 +1622,10 @@ class NonDelegate < Rack::Request def delegate?; false; end + + def query_parser + Rack::QueryParser.make_default(Rack::Utils.key_space_limit, Rack::Utils.param_depth_limit, bytesize_limit: 2**30) + end end def make_request(env) @@ -1558,6 +1649,10 @@ def delegate?; true; end def env; @req.env.dup; end + + def query_parser + Rack::QueryParser.make_default(Rack::Utils.key_space_limit, Rack::Utils.param_depth_limit, bytesize_limit: 2**30) + end end def make_request(env) diff -Nru ruby-rack-2.2.13/test/spec_sendfile.rb ruby-rack-2.2.20/test/spec_sendfile.rb --- ruby-rack-2.2.13/test/spec_sendfile.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/test/spec_sendfile.rb 2025-10-10 00:36:11.000000000 +0000 @@ -16,12 +16,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) @@ -42,7 +42,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' @@ -52,8 +53,8 @@ end end - it "sets X-Sendfile response header and discards body" do - request 'HTTP_X_SENDFILE_TYPE' => 'X-Sendfile' do |response| + it "sets x-sendfile response header and discards body" do + request({}, sendfile_body, [], 'X-Sendfile') do |response| response.must_be :ok? response.body.must_be :empty? response.headers['Content-Length'].must_equal '0' @@ -61,21 +62,33 @@ end end - it "sets X-Lighttpd-Send-File response header and discards body" do - request 'HTTP_X_SENDFILE_TYPE' => 'X-Lighttpd-Send-File' do |response| + it "closes body when x-sendfile used" do + body = sendfile_body + closed = false + body.define_singleton_method(:close){closed = true} + request({}, body, [], 'X-Sendfile') do |response| response.must_be :ok? response.body.must_be :empty? - response.headers['Content-Length'].must_equal '0' - response.headers['X-Lighttpd-Send-File'].must_equal File.join(Dir.tmpdir, "rack_sendfile") + response.headers['content-length'].must_equal '0' + response.headers['x-sendfile'].must_equal File.join(Dir.tmpdir, "rack_sendfile") + end + closed.must_equal true + end + + it "sets x-lighttpd-send-file response header and discards body" do + 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' + response.headers['x-lighttpd-send-file'].must_equal File.join(Dir.tmpdir, "rack_sendfile") end end 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' @@ -85,10 +98,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' @@ -96,8 +108,8 @@ end end - it 'writes to rack.error when no X-Accel-Mapping is specified' do - request 'HTTP_X_SENDFILE_TYPE' => 'X-Accel-Redirect' do |response| + it 'writes to rack.error when no x-accel-mapping is specified' do + 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' @@ -106,7 +118,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 @@ -128,14 +140,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' @@ -162,34 +174,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 diff -Nru ruby-rack-2.2.13/test/spec_session_cookie.rb ruby-rack-2.2.20/test/spec_session_cookie.rb --- ruby-rack-2.2.13/test/spec_session_cookie.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/test/spec_session_cookie.rb 2025-10-10 00:36:11.000000000 +0000 @@ -186,14 +186,14 @@ response = response_for(app: [incrementor, { coder: identity }]) response["Set-Cookie"].must_include "rack.session=" - response.body.must_equal '{"counter"=>1}' + response.body.must_equal({ "counter" => 1 }.to_s) identity.calls.must_equal [:decode, :encode] end it "creates a new cookie" do response = response_for(app: incrementor) response["Set-Cookie"].must_include "rack.session=" - response.body.must_equal '{"counter"=>1}' + response.body.must_equal({ "counter" => 1 }.to_s) end it "passes through same_site option to session cookie" do @@ -217,10 +217,10 @@ response = response_for(app: incrementor) response = response_for(app: incrementor, cookie: response) - response.body.must_equal '{"counter"=>2}' + response.body.must_equal({ "counter" => 2 }.to_s) response = response_for(app: incrementor, cookie: response) - response.body.must_equal '{"counter"=>3}' + response.body.must_equal({ "counter"=> 3 }.to_s) end it "renew session id" do @@ -259,13 +259,13 @@ app: incrementor, cookie: "rack.session=blarghfasel" ) - response.body.must_equal '{"counter"=>1}' + response.body.must_equal({ "counter"=> 1 }.to_s) response = response_for( app: [incrementor, { secret: "test" }], cookie: "rack.session=" ) - response.body.must_equal '{"counter"=>1}' + response.body.must_equal({ "counter"=> 1 }.to_s) end it "barks on too big cookies" do @@ -279,15 +279,15 @@ response = response_for(app: app) response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>2}' + response.body.must_equal({ "counter"=> 2 }.to_s) response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>3}' + response.body.must_equal({ "counter"=> 3 }.to_s) app = [incrementor, { secret: "other" }] response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>1}' + response.body.must_equal({ "counter"=> 1 }.to_s) end it "loads from a cookie with accept-only integrity hash for graceful key rotation" do @@ -295,42 +295,42 @@ app = [incrementor, { secret: "test2", old_secret: "test" }] response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>2}' + response.body.must_equal({ "counter"=> 2 }.to_s) app = [incrementor, { secret: "test3", old_secret: "test2" }] response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>3}' + response.body.must_equal({ "counter"=> 3 }.to_s) end it "ignores tampered with session cookies" do app = [incrementor, { secret: "test" }] response = response_for(app: app) - response.body.must_equal '{"counter"=>1}' + response.body.must_equal({ "counter"=> 1 }.to_s) response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>2}' + response.body.must_equal({ "counter"=> 2 }.to_s) _, digest = response["Set-Cookie"].split("--") tampered_with_cookie = "hackerman-was-here" + "--" + digest response = response_for(app: app, cookie: tampered_with_cookie) - response.body.must_equal '{"counter"=>1}' + response.body.must_equal({ "counter"=> 1 }.to_s) end it "supports either of secret or old_secret" do app = [incrementor, { secret: "test" }] response = response_for(app: app) - response.body.must_equal '{"counter"=>1}' + response.body.must_equal({ "counter"=> 1 }.to_s) response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>2}' + response.body.must_equal({ "counter"=> 2 }.to_s) app = [incrementor, { old_secret: "test" }] response = response_for(app: app) - response.body.must_equal '{"counter"=>1}' + response.body.must_equal({ "counter"=> 1 }.to_s) response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>2}' + response.body.must_equal({ "counter"=> 2 }.to_s) end it "supports custom digest class" do @@ -338,15 +338,15 @@ response = response_for(app: app) response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>2}' + response.body.must_equal({ "counter"=> 2 }.to_s) response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>3}' + response.body.must_equal({ "counter"=> 3 }.to_s) app = [incrementor, { secret: "other" }] response = response_for(app: app, cookie: response) - response.body.must_equal '{"counter"=>1}' + response.body.must_equal({ "counter"=> 1 }.to_s) end it "can handle Rack::Lint middleware" do @@ -377,11 +377,11 @@ it "returns the session id in the session hash" do response = response_for(app: incrementor) - response.body.must_equal '{"counter"=>1}' + response.body.must_equal({ "counter"=> 1 }.to_s) response = response_for(app: session_id, cookie: response) - response.body.must_match(/"session_id"=>/) - response.body.must_match(/"counter"=>1/) + response.body.must_match(/"session_id"\s*=>/) + response.body.must_match(/"counter"\s*=>\s*1/) end it "does not return a cookie if set to secure but not using ssl" do @@ -491,8 +491,8 @@ incrementor ], cookie: non_strict_response) - response.body.must_match %Q["value"=>"#{'A' * 256}"] - response.body.must_match '"counter"=>2' + response.body.must_match({ "value"=> 'A' * 256 }.to_s[1..-2]) + response.body.must_match({ "counter" => 2 }.to_s[1..-2]) response.body.must_match(/\A{[^}]+}\z/) end end diff -Nru ruby-rack-2.2.13/test/spec_session_pool.rb ruby-rack-2.2.20/test/spec_session_pool.rb --- ruby-rack-2.2.13/test/spec_session_pool.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/test/spec_session_pool.rb 2025-10-10 00:36:11.000000000 +0000 @@ -41,7 +41,7 @@ pool = Rack::Session::Pool.new(incrementor) res = Rack::MockRequest.new(pool).get("/") res["Set-Cookie"].must_match(session_match) - res.body.must_equal '{"counter"=>1}' + res.body.must_equal({ "counter" => 1 }.to_s) end it "determines session from a cookie" do @@ -49,16 +49,16 @@ req = Rack::MockRequest.new(pool) cookie = req.get("/")["Set-Cookie"] req.get("/", "HTTP_COOKIE" => cookie). - body.must_equal '{"counter"=>2}' + body.must_equal({ "counter" => 2 }.to_s) req.get("/", "HTTP_COOKIE" => cookie). - body.must_equal '{"counter"=>3}' + body.must_equal({ "counter" => 3 }.to_s) end it "survives nonexistent cookies" do pool = Rack::Session::Pool.new(incrementor) res = Rack::MockRequest.new(pool). get("/", "HTTP_COOKIE" => "#{session_key}=blarghfasel") - res.body.must_equal '{"counter"=>1}' + res.body.must_equal({ "counter" => 1 }.to_s) end it "does not send the same session id if it did not change" do @@ -67,17 +67,17 @@ res0 = req.get("/") cookie = res0["Set-Cookie"][session_match] - res0.body.must_equal '{"counter"=>1}' + res0.body.must_equal({ "counter" => 1 }.to_s) pool.pool.size.must_equal 1 res1 = req.get("/", "HTTP_COOKIE" => cookie) res1["Set-Cookie"].must_be_nil - res1.body.must_equal '{"counter"=>2}' + res1.body.must_equal({ "counter" => 2 }.to_s) pool.pool.size.must_equal 1 res2 = req.get("/", "HTTP_COOKIE" => cookie) res2["Set-Cookie"].must_be_nil - res2.body.must_equal '{"counter"=>3}' + res2.body.must_equal({ "counter" => 3 }.to_s) pool.pool.size.must_equal 1 end @@ -89,17 +89,17 @@ res1 = req.get("/") session = (cookie = res1["Set-Cookie"])[session_match] - res1.body.must_equal '{"counter"=>1}' + res1.body.must_equal({ "counter" => 1 }.to_s) pool.pool.size.must_equal 1 res2 = dreq.get("/", "HTTP_COOKIE" => cookie) res2["Set-Cookie"].must_be_nil - res2.body.must_equal '{"counter"=>2}' + res2.body.must_equal({ "counter" => 2 }.to_s) pool.pool.size.must_equal 0 res3 = req.get("/", "HTTP_COOKIE" => cookie) res3["Set-Cookie"][session_match].wont_equal session - res3.body.must_equal '{"counter"=>1}' + res3.body.must_equal({ "counter" => 1 }.to_s) pool.pool.size.must_equal 1 end @@ -111,22 +111,22 @@ res1 = req.get("/") session = (cookie = res1["Set-Cookie"])[session_match] - res1.body.must_equal '{"counter"=>1}' + res1.body.must_equal({ "counter" => 1 }.to_s) pool.pool.size.must_equal 1 res2 = rreq.get("/", "HTTP_COOKIE" => cookie) new_cookie = res2["Set-Cookie"] new_session = new_cookie[session_match] new_session.wont_equal session - res2.body.must_equal '{"counter"=>2}' + res2.body.must_equal({ "counter" => 2 }.to_s) pool.pool.size.must_equal 1 res3 = req.get("/", "HTTP_COOKIE" => new_cookie) - res3.body.must_equal '{"counter"=>3}' + res3.body.must_equal({ "counter" => 3 }.to_s) pool.pool.size.must_equal 1 res4 = req.get("/", "HTTP_COOKIE" => cookie) - res4.body.must_equal '{"counter"=>1}' + res4.body.must_equal({ "counter" => 1 }.to_s) pool.pool.size.must_equal 2 end @@ -137,7 +137,7 @@ res1 = dreq.get("/") res1["Set-Cookie"].must_be_nil - res1.body.must_equal '{"counter"=>1}' + res1.body.must_equal({ "counter" => 1 }.to_s) pool.pool.size.must_equal 1 end @@ -154,7 +154,7 @@ res1 = req.get("/", "HTTP_COOKIE" => cookie) res1["Set-Cookie"].must_be_nil - res1.body.must_equal '{"counter"=>2}' + res1.body.must_equal({ "counter" => 2 }.to_s) pool.pool[session_id.private_id].wont_be_nil end @@ -173,7 +173,7 @@ res2 = dreq.get("/", "HTTP_COOKIE" => cookie) res2["Set-Cookie"].must_be_nil - res2.body.must_equal '{"counter"=>2}' + res2.body.must_equal({ "counter" => 2 }.to_s) pool.pool[session_id.private_id].must_be_nil pool.pool[session_id.public_id].must_be_nil end @@ -209,7 +209,7 @@ req = Rack::MockRequest.new(pool) res = req.get('/') - res.body.must_equal '{"counter"=>1}' + res.body.must_equal({ "counter" => 1 }.to_s) cookie = res["Set-Cookie"] sess_id = cookie[/#{pool.key}=([^,;]+)/, 1] @@ -261,4 +261,52 @@ res = Rack::MockRequest.new(app).get("/") res["Set-Cookie"].must_be_nil end + + user_id_session = Rack::Lint.new(lambda do |env| + session = env["rack.session"] + + case env["PATH_INFO"] + when "/login" + session[:user_id] = 1 + when "/logout" + if session[:user_id].nil? + raise "User not logged in" + end + + session.delete(:user_id) + session.options[:renew] = true + when "/slow" + Fiber.yield + end + + Rack::Response.new(session.inspect).to_a + end) + + it "doesn't allow session id to be reused" do + app = Rack::Session::Pool.new(user_id_session) + + login_response = Rack::MockRequest.new(app).get("/login") + login_cookie = login_response["Set-Cookie"] + + slow_request = Fiber.new do + Rack::MockRequest.new(app).get("/slow", "HTTP_COOKIE" => login_cookie) + end + slow_request.resume + + # Check that the session is valid: + response = Rack::MockRequest.new(app).get("/", "HTTP_COOKIE" => login_cookie) + response.body.must_equal({"user_id" => 1}.to_s) + + logout_response = Rack::MockRequest.new(app).get("/logout", "HTTP_COOKIE" => login_cookie) + logout_cookie = logout_response["Set-Cookie"] + + # Check that the session id is different after logout: + login_cookie[session_match].wont_equal logout_cookie[session_match] + + slow_response = slow_request.resume + + # Check that the cookie can't be reused: + response = Rack::MockRequest.new(app).get("/", "HTTP_COOKIE" => login_cookie) + response.body.must_equal "{}" + end end diff -Nru ruby-rack-2.2.13/test/spec_thin.rb ruby-rack-2.2.20/test/spec_thin.rb --- ruby-rack-2.2.13/test/spec_thin.rb 2025-03-10 21:18:07.000000000 +0000 +++ ruby-rack-2.2.20/test/spec_thin.rb 2025-10-10 00:36:11.000000000 +0000 @@ -38,7 +38,7 @@ GET("/") status.must_equal 200 response["SERVER_SOFTWARE"].must_match(/thin/) - response["HTTP_VERSION"].must_equal "HTTP/1.1" + # response["HTTP_VERSION"].must_equal "HTTP/1.1" response["SERVER_PROTOCOL"].must_equal "HTTP/1.1" response["SERVER_PORT"].must_equal "9204" response["SERVER_NAME"].must_equal "127.0.0.1"