diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml index ed4957297..2bc471044 100644 --- a/.github/workflows/development.yml +++ b/.github/workflows/development.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - ruby: [2.3, 2.4, 2.5, 2.6, 2.7, '3.0', 3.1, 3.2] + ruby: [2.3, 2.4, 2.5, 2.6, 2.7, '3.0', 3.1, 3.2, 3.3, 3.4] runs-on: ${{matrix.os}} steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d90b920..279060524 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ 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/). +## Unreleased + +## [3.1.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. + +## [2.2.10] - 2024-10-14 + +- Fix compatibility issues with Ruby v3.4.0. ([#2248](https://github.com/rack/rack/pull/2248), [@byroot](https://github.com/byroot)) + +## [2.2.9] - 2023-03-21 + +- Return empty when parsing a multi-part POST with only one end delimiter. ([#2104](https://github.com/rack/rack/pull/2104), [@alpaca-tc]) + +## [2.2.8] - 2023-07-31 + +- Regenerate SPEC ([#2102](https://github.com/rack/rack/pull/2102), [@skipkayhil](https://github.com/skipkayhil)) +- Limit file extension length of multipart tempfiles ([#2015](https://github.com/rack/rack/pull/2015), [@dentarg](https://github.com/dentarg)) +- Fix "undefined method DelegateClass for Rack::Session::Cookie:Class" ([#2092](https://github.com/rack/rack/pull/2092), [@onigra](https://github.com/onigra) [@dchandekstark](https://github.com/dchandekstark)) + ## [2.2.7] - 2023-03-13 - Correct the year number in the changelog ([#2015](https://github.com/rack/rack/pull/2015), [@kimulab](https://github.com/kimulab)) diff --git a/lib/rack/auth/basic.rb b/lib/rack/auth/basic.rb index d5b4ea16d..11dab9797 100644 --- a/lib/rack/auth/basic.rb +++ b/lib/rack/auth/basic.rb @@ -2,7 +2,6 @@ require_relative 'abstract/handler' require_relative 'abstract/request' -require 'base64' module Rack module Auth @@ -48,7 +47,7 @@ def basic? end def credentials - @credentials ||= Base64.decode64(params).split(':', 2) + @credentials ||= params.unpack("m").first.split(':', 2) end def username diff --git a/lib/rack/auth/digest/nonce.rb b/lib/rack/auth/digest/nonce.rb index 3216d973e..13f37c1f3 100644 --- a/lib/rack/auth/digest/nonce.rb +++ b/lib/rack/auth/digest/nonce.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'digest/md5' -require 'base64' module Rack module Auth @@ -21,7 +20,7 @@ class << self end def self.parse(string) - new(*Base64.decode64(string).split(' ', 2)) + new(*string.unpack("m").first.split(' ', 2)) end def initialize(timestamp = Time.now, given_digest = nil) @@ -29,7 +28,7 @@ def initialize(timestamp = Time.now, given_digest = nil) end def to_s - Base64.encode64("#{@timestamp} #{digest}").strip + ["#{@timestamp} #{digest}"].pack("m").strip end def digest diff --git a/lib/rack/common_logger.rb b/lib/rack/common_logger.rb index 9c6f92147..68399c7ed 100644 --- a/lib/rack/common_logger.rb +++ b/lib/rack/common_logger.rb @@ -15,7 +15,7 @@ class CommonLogger # The actual format is slightly different than the above due to the # separation of SCRIPT_NAME and PATH_INFO, and because the elapsed # time in seconds is included at the end. - FORMAT = %{%s - %s [%s] "%s %s%s%s %s" %d %s %0.4f\n} + FORMAT = %{%s - %s [%s] "%s %s%s%s %s" %d %s %0.4f } # +logger+ can be any object that supports the +write+ or +<<+ methods, # which includes the standard library Logger. These methods are called @@ -60,7 +60,8 @@ def log(env, status, header, began_at) length, Utils.clock_time - began_at ] - msg.gsub!(/[^[:print:]\n]/) { |c| "\\x#{c.ord}" } + msg.gsub!(/[^[:print:]]/) { |c| sprintf("\\x%x", c.ord) } + msg[-1] = "\n" logger = @logger || env[RACK_ERRORS] diff --git a/lib/rack/multipart/parser.rb b/lib/rack/multipart/parser.rb index f981a7fa2..3b53832fc 100644 --- a/lib/rack/multipart/parser.rb +++ b/lib/rack/multipart/parser.rb @@ -191,6 +191,7 @@ def initialize(boundary, tempfile, bufsize, query_parser) @sbuf = StringScanner.new("".dup) @body_regex = /(?:#{EOL})?#{Regexp.quote(@boundary)}(?:#{EOL}|--)/m + @end_boundary_size = boundary.bytesize + 6 # (-- at start, -- at finish, EOL at end) @rx_max_size = EOL.size + @boundary.bytesize + [EOL.size, '--'.size].max @head_regex = /(.*?#{EOL})#{EOL}/m end @@ -231,7 +232,12 @@ def run_parser end def handle_fast_forward - if consume_boundary + tok = consume_boundary + + if tok == :END_BOUNDARY && @sbuf.pos == @end_boundary_size && @sbuf.eos? + # stop parsing a buffer if a buffer is only an end boundary. + @state = :DONE + elsif tok @state = :MIME_HEAD else raise EOFError, "bad content body" if @sbuf.rest_size >= @bufsize diff --git a/lib/rack/session/cookie.rb b/lib/rack/session/cookie.rb index 737c0b33b..3f1fc8a7a 100644 --- a/lib/rack/session/cookie.rb +++ b/lib/rack/session/cookie.rb @@ -4,7 +4,6 @@ require 'zlib' require_relative 'abstract/id' require 'json' -require 'base64' require 'delegate' module Rack @@ -51,11 +50,11 @@ class Cookie < Abstract::PersistedSecure # Encode session cookies as Base64 class Base64 def encode(str) - ::Base64.strict_encode64(str) + [str].pack("m0") end def decode(str) - ::Base64.decode64(str) + str.unpack("m").first end # Encode session cookies as Marshaled Base64 data diff --git a/lib/rack/utils.rb b/lib/rack/utils.rb index ccf39e301..3ce58c498 100644 --- a/lib/rack/utils.rb +++ b/lib/rack/utils.rb @@ -24,6 +24,7 @@ module Utils RFC2822_DAY_NAME = [ 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ] RFC2822_MONTH_NAME = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ] + RFC2396_PARSER = defined?(URI::RFC2396_PARSER) ? URI::RFC2396_PARSER : URI::RFC2396_Parser.new class << self attr_accessor :default_query_parser @@ -42,13 +43,13 @@ def escape(s) # Like URI escaping, but with %20 instead of +. Strictly speaking this is # true URI escaping. def escape_path(s) - ::URI::DEFAULT_PARSER.escape s + RFC2396_PARSER.escape s end # Unescapes the **path** component of a URI. See Rack::Utils.unescape for # unescaping query parameters or form components. def unescape_path(s) - ::URI::DEFAULT_PARSER.unescape s + RFC2396_PARSER.unescape s end # Unescapes a URI escaped string with +encoding+. +encoding+ will be the @@ -381,7 +382,7 @@ def get_byte_ranges(http_range, size) ranges << (r0..r1) if r0 <= r1 end - return [] if ranges.map(&:size).sum > size + return [] if ranges.map(&:size).inject(0, :+) > size ranges end diff --git a/lib/rack/version.rb b/lib/rack/version.rb index e0035a937..35d15659d 100644 --- a/lib/rack/version.rb +++ b/lib/rack/version.rb @@ -20,7 +20,7 @@ def self.version VERSION.join(".") end - RELEASE = "2.2.8.1" + RELEASE = "2.2.11" # Return the Rack release as a dotted string. def self.release diff --git a/test/spec_common_logger.rb b/test/spec_common_logger.rb index 4ddb5f03d..aed1429cf 100644 --- a/test/spec_common_logger.rb +++ b/test/spec_common_logger.rb @@ -87,12 +87,17 @@ def with_mock_time(t = 0) (0..1).must_include duration.to_f end - it "escapes non printable characters except newline" do + it "escapes non printable characters including newline" do logdev = StringIO.new log = Logger.new(logdev) Rack::MockRequest.new(Rack::CommonLogger.new(app_without_lint, log)).request("GET\b", "/hello") logdev.string.must_match(/GET\\x8 \/hello/) + + Rack::MockRequest.new(Rack::CommonLogger.new(app, log)).get("/", 'REMOTE_USER' => "foo\nbar", "QUERY_STRING" => "bar\nbaz") + logdev.string[-1].must_equal "\n" + logdev.string.must_include("foo\\xabar") + logdev.string.must_include("bar\\xabaz") end it "log path with PATH_INFO" do diff --git a/test/spec_request.rb b/test/spec_request.rb index db52dea29..a4982009c 100644 --- a/test/spec_request.rb +++ b/test/spec_request.rb @@ -1009,6 +1009,24 @@ def initialize(*) f[:tempfile].size.must_equal 76 end + it "parse multipart delimiter-only boundary" do + input = < "multipart/form-data, boundary=AaB03x", + "CONTENT_LENGTH" => input.size, + :input => input + ) + + req = make_request mr + req.query_string.must_equal "" + req.GET.must_be :empty? + req.POST.must_be :empty? + req.params.must_equal({}) + end + it "MultipartPartLimitError when request has too many multipart file parts if limit set" do begin data = 10000.times.map { "--AaB03x\r\nContent-Type: text/plain\r\nContent-Disposition: attachment; name=#{SecureRandom.hex(10)}; filename=#{SecureRandom.hex(10)}\r\n\r\ncontents\r\n" }.join("\r\n")