Rails Active Storage has a possible DoS vulnerability when in proxy mode via Range requests
Description
Active Storage allows users to attach cloud and local files in Rails applications. Prior to versions 8.1.2.1, 8.0.4.1, and 7.2.3.1, when serving files through Active Storage's proxy delivery mode, the proxy controller loads the entire requested byte range into memory before sending it. A request with a large or unbounded Range header (e.g. bytes=0-) could cause the server to allocate memory proportional to the file size, possibly resulting in a DoS vulnerability through memory exhaustion. Versions 8.1.2.1, 8.0.4.1, and 7.2.3.1 contain a patch.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
In Active Storage proxy delivery mode, an unbounded Range header can cause server memory exhaustion via large byte-range loads, enabling DoS.
Vulnerability
Overview
CVE-2026-33174 is a denial-of-service (DoS) vulnerability in Ruby on Rails' Active Storage component, affecting versions prior to 8.1.2.1, 8.0.4.1, and 7.2.3.1. The flaw resides in the proxy delivery mode's controller, which loads the entire requested byte range into memory before serving the file [1]. If an attacker sends an HTTP request with a large or unbounded Range header (e.g., bytes=0-), the server may allocate memory proportional to the file size, potentially leading to rapid memory exhaustion [2].
Exploitation
Prerequisites
The attack requires network access to a Rails application using Active Storage's proxy mode. No authentication is needed if the file endpoint is publicly accessible. The attacker crafts a request with an oversized byte range, causing the Rails server to read and buffer the full (or a very large) portion of the requested blob into RAM [2][3]. Because the range is not validated before processing, even a single request can trigger excessive allocation.
Impact
Successful exploitation results in a denial of service due to memory exhaustion. The Rails server process may crash or become unresponsive, affecting all users of the application. The vulnerability can be triggered repeatedly, prolonging service disruption. The official advisory notes that the attack complexity is low, and no special privileges are required [2].
Mitigation
The Rails team has patched the issue in versions 8.1.2.1, 8.0.4.1, and 7.2.3.1. The fix introduces a configurable streaming_chunk_max_size (default 100 MB) and a ranges_valid? method that rejects byte ranges exceeding this limit [3][4]. Administrators are strongly advised to upgrade to one of the patched versions immediately. No workaround is available for unpatched installations.
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
activestorageRubyGems | >= 8.1.0.beta1, < 8.1.2.1 | 8.1.2.1 |
activestorageRubyGems | >= 8.0.0.beta1, < 8.0.4.1 | 8.0.4.1 |
activestorageRubyGems | < 7.2.3.1 | 7.2.3.1 |
Affected products
2- Range: < 8.1.2.1, < 8.0.4.1, < 7.2.3.1
- rails/activestoragev5Range: >= 8.1.0.beta1, < 8.1.2.1
Patches
342012eaaa88dConfigurable maxmimum streaming chunk size
5 files changed · +41 −1
activestorage/app/controllers/concerns/active_storage/streaming.rb+7 −1 modified@@ -14,7 +14,7 @@ module ActiveStorage::Streaming def send_blob_byte_range_data(blob, range_header, disposition: nil) ranges = Rack::Utils.get_byte_ranges(range_header, blob.byte_size) - return head(:range_not_satisfiable) if ranges.blank? || ranges.all?(&:blank?) + return head(:range_not_satisfiable) unless ranges_valid?(ranges) if ranges.length == 1 range = ranges.first @@ -51,6 +51,12 @@ def send_blob_byte_range_data(blob, range_header, disposition: nil) ) end + def ranges_valid?(ranges) + return false if ranges.blank? || ranges.all?(&:blank?) + + ranges.sum { |range| range.end - range.begin } < ActiveStorage.streaming_chunk_max_size + end + # Stream the blob from storage directly to the response. The disposition can be controlled by setting +disposition+. # The content type and filename is set directly from the +blob+. def send_blob_stream(blob, disposition: nil) # :doc:
activestorage/CHANGELOG.md+8 −0 modified@@ -1,3 +1,11 @@ +* Configurable maxmimum streaming chunk size + + Makes sure that byte ranges for blobs don't exceed 100mb by default. + Content ranges that are too big can result in denial of service. + + *Gannon McGibbon* + + ## Rails 8.1.2 (January 08, 2026) ## * Restore ADC when signing URLs with IAM for GCS
activestorage/lib/active_storage/engine.rb+1 −0 modified@@ -149,6 +149,7 @@ class Engine < Rails::Engine # :nodoc: ActiveStorage.binary_content_type = app.config.active_storage.binary_content_type || "application/octet-stream" ActiveStorage.video_preview_arguments = app.config.active_storage.video_preview_arguments || "-y -vframes 1 -f image2" ActiveStorage.track_variants = app.config.active_storage.track_variants || false + ActiveStorage.streaming_chunk_max_size = app.config.active_storage.streaming_chunk_max_size || 100.megabytes end end
activestorage/lib/active_storage.rb+2 −0 modified@@ -27,6 +27,7 @@ require "active_support" require "active_support/rails" require "active_support/core_ext/numeric/time" +require "active_support/core_ext/numeric/bytes" require "active_storage/version" require "active_storage/deprecator" @@ -352,6 +353,7 @@ module ActiveStorage ] mattr_accessor :unsupported_image_processing_arguments + mattr_accessor :streaming_chunk_max_size, default: 100.megabytes mattr_accessor :service_urls_expire_in, default: 5.minutes mattr_accessor :touch_attachment_records, default: true mattr_accessor :urls_expire_in
activestorage/test/controllers/blobs/proxy_controller_test.rb+23 −0 modified@@ -96,6 +96,20 @@ class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTes assert_response :range_not_satisfiable end + test "Byte Range is too big" do + with_streaming_chunk_max_size(1.kilobyte) do + get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=0-" } + assert_response :range_not_satisfiable + end + end + + test "Byte Range is too big overall" do + with_streaming_chunk_max_size(8.bytes) do + get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=0-5,6-12" } + assert_response :range_not_satisfiable + end + end + test "multiple Byte Ranges" do boundary = SecureRandom.hex SecureRandom.stub :hex, boundary do @@ -148,6 +162,15 @@ class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTes assert_not_nil response.headers["Content-Length"], "Content-Length header should be included in proxy mode" assert_equal blob.byte_size.to_s, response.headers["Content-Length"], "Content-Length header should match blob size" end + + private + def with_streaming_chunk_max_size(size) + old_size = ActiveStorage.streaming_chunk_max_size + ActiveStorage.streaming_chunk_max_size = size + yield + ensure + ActiveStorage.streaming_chunk_max_size = old_size + end end class ActiveStorage::Blobs::ExpiringProxyControllerTest < ActionDispatch::IntegrationTest
8159a9c3de3fConfigurable maxmimum streaming chunk size
5 files changed · +41 −1
activestorage/app/controllers/concerns/active_storage/streaming.rb+7 −1 modified@@ -14,7 +14,7 @@ module ActiveStorage::Streaming def send_blob_byte_range_data(blob, range_header, disposition: nil) ranges = Rack::Utils.get_byte_ranges(range_header, blob.byte_size) - return head(:range_not_satisfiable) if ranges.blank? || ranges.all?(&:blank?) + return head(:range_not_satisfiable) unless ranges_valid?(ranges) if ranges.length == 1 range = ranges.first @@ -51,6 +51,12 @@ def send_blob_byte_range_data(blob, range_header, disposition: nil) ) end + def ranges_valid?(ranges) + return false if ranges.blank? || ranges.all?(&:blank?) + + ranges.sum { |range| range.end - range.begin } < ActiveStorage.streaming_chunk_max_size + end + # Stream the blob from storage directly to the response. The disposition can be controlled by setting +disposition+. # The content type and filename is set directly from the +blob+. def send_blob_stream(blob, disposition: nil) # :doc:
activestorage/CHANGELOG.md+8 −0 modified@@ -1,3 +1,11 @@ +* Configurable maxmimum streaming chunk size + + Makes sure that byte ranges for blobs don't exceed 100mb by default. + Content ranges that are too big can result in denial of service. + + *Gannon McGibbon* + + ## Rails 7.2.3 (October 28, 2025) ## * Fix `config.active_storage.touch_attachment_records` to work with eager loading.
activestorage/lib/active_storage/engine.rb+1 −0 modified@@ -122,6 +122,7 @@ class Engine < Rails::Engine # :nodoc: ActiveStorage.binary_content_type = app.config.active_storage.binary_content_type || "application/octet-stream" ActiveStorage.video_preview_arguments = app.config.active_storage.video_preview_arguments || "-y -vframes 1 -f image2" ActiveStorage.track_variants = app.config.active_storage.track_variants || false + ActiveStorage.streaming_chunk_max_size = app.config.active_storage.streaming_chunk_max_size || 100.megabytes end end
activestorage/lib/active_storage.rb+2 −0 modified@@ -27,6 +27,7 @@ require "active_support" require "active_support/rails" require "active_support/core_ext/numeric/time" +require "active_support/core_ext/numeric/bytes" require "active_storage/version" require "active_storage/deprecator" @@ -350,6 +351,7 @@ module ActiveStorage ] mattr_accessor :unsupported_image_processing_arguments + mattr_accessor :streaming_chunk_max_size, default: 100.megabytes mattr_accessor :service_urls_expire_in, default: 5.minutes mattr_accessor :touch_attachment_records, default: true mattr_accessor :urls_expire_in
activestorage/test/controllers/blobs/proxy_controller_test.rb+23 −0 modified@@ -70,6 +70,20 @@ class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTes assert_response :range_not_satisfiable end + test "Byte Range is too big" do + with_streaming_chunk_max_size(1.kilobyte) do + get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=0-" } + assert_response :range_not_satisfiable + end + end + + test "Byte Range is too big overall" do + with_streaming_chunk_max_size(8.bytes) do + get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=0-5,6-12" } + assert_response :range_not_satisfiable + end + end + test "multiple Byte Ranges" do boundary = SecureRandom.hex SecureRandom.stub :hex, boundary do @@ -105,6 +119,15 @@ class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTes request = ActionController::TestRequest.create({}) assert_instance_of ActionController::Live::Response, ActiveStorage::Blobs::ProxyController.make_response!(request) end + + private + def with_streaming_chunk_max_size(size) + old_size = ActiveStorage.streaming_chunk_max_size + ActiveStorage.streaming_chunk_max_size = size + yield + ensure + ActiveStorage.streaming_chunk_max_size = old_size + end end class ActiveStorage::Blobs::ExpiringProxyControllerTest < ActionDispatch::IntegrationTest
2cd933c366b7Configurable maxmimum streaming chunk size
5 files changed · +41 −1
activestorage/app/controllers/concerns/active_storage/streaming.rb+7 −1 modified@@ -14,7 +14,7 @@ module ActiveStorage::Streaming def send_blob_byte_range_data(blob, range_header, disposition: nil) ranges = Rack::Utils.get_byte_ranges(range_header, blob.byte_size) - return head(:range_not_satisfiable) if ranges.blank? || ranges.all?(&:blank?) + return head(:range_not_satisfiable) unless ranges_valid?(ranges) if ranges.length == 1 range = ranges.first @@ -51,6 +51,12 @@ def send_blob_byte_range_data(blob, range_header, disposition: nil) ) end + def ranges_valid?(ranges) + return false if ranges.blank? || ranges.all?(&:blank?) + + ranges.sum { |range| range.end - range.begin } < ActiveStorage.streaming_chunk_max_size + end + # Stream the blob from storage directly to the response. The disposition can be controlled by setting +disposition+. # The content type and filename is set directly from the +blob+. def send_blob_stream(blob, disposition: nil) # :doc:
activestorage/CHANGELOG.md+8 −0 modified@@ -1,3 +1,11 @@ +* Configurable maxmimum streaming chunk size + + Makes sure that byte ranges for blobs don't exceed 100mb by default. + Content ranges that are too big can result in denial of service. + + *Gannon McGibbon* + + ## Rails 8.0.4 (October 28, 2025) ## * No changes.
activestorage/lib/active_storage/engine.rb+1 −0 modified@@ -122,6 +122,7 @@ class Engine < Rails::Engine # :nodoc: ActiveStorage.binary_content_type = app.config.active_storage.binary_content_type || "application/octet-stream" ActiveStorage.video_preview_arguments = app.config.active_storage.video_preview_arguments || "-y -vframes 1 -f image2" ActiveStorage.track_variants = app.config.active_storage.track_variants || false + ActiveStorage.streaming_chunk_max_size = app.config.active_storage.streaming_chunk_max_size || 100.megabytes end end
activestorage/lib/active_storage.rb+2 −0 modified@@ -27,6 +27,7 @@ require "active_support" require "active_support/rails" require "active_support/core_ext/numeric/time" +require "active_support/core_ext/numeric/bytes" require "active_storage/version" require "active_storage/deprecator" @@ -350,6 +351,7 @@ module ActiveStorage ] mattr_accessor :unsupported_image_processing_arguments + mattr_accessor :streaming_chunk_max_size, default: 100.megabytes mattr_accessor :service_urls_expire_in, default: 5.minutes mattr_accessor :touch_attachment_records, default: true mattr_accessor :urls_expire_in
activestorage/test/controllers/blobs/proxy_controller_test.rb+23 −0 modified@@ -96,6 +96,20 @@ class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTes assert_response :range_not_satisfiable end + test "Byte Range is too big" do + with_streaming_chunk_max_size(1.kilobyte) do + get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=0-" } + assert_response :range_not_satisfiable + end + end + + test "Byte Range is too big overall" do + with_streaming_chunk_max_size(8.bytes) do + get rails_storage_proxy_url(create_file_blob(filename: "racecar.jpg")), headers: { "Range" => "bytes=0-5,6-12" } + assert_response :range_not_satisfiable + end + end + test "multiple Byte Ranges" do boundary = SecureRandom.hex SecureRandom.stub :hex, boundary do @@ -131,6 +145,15 @@ class ActiveStorage::Blobs::ProxyControllerTest < ActionDispatch::IntegrationTes request = ActionController::TestRequest.create({}) assert_instance_of ActionController::Live::Response, ActiveStorage::Blobs::ProxyController.make_response!(request) end + + private + def with_streaming_chunk_max_size(size) + old_size = ActiveStorage.streaming_chunk_max_size + ActiveStorage.streaming_chunk_max_size = size + yield + ensure + ActiveStorage.streaming_chunk_max_size = old_size + end end class ActiveStorage::Blobs::ExpiringProxyControllerTest < ActionDispatch::IntegrationTest
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
10- github.com/advisories/GHSA-r46p-8f7g-vvvgghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33174ghsaADVISORY
- github.com/rails/rails/commit/2cd933c366b777f873d4d590127da2f4a25e4ba5ghsax_refsource_MISCWEB
- github.com/rails/rails/commit/42012eaaa88dfc7d0030161b2bc8074a7bbce92aghsax_refsource_MISCWEB
- github.com/rails/rails/commit/8159a9c3de3f27a2bcf2866b8bf9ceb9075e229bghsax_refsource_MISCWEB
- github.com/rails/rails/releases/tag/v7.2.3.1ghsax_refsource_MISCWEB
- github.com/rails/rails/releases/tag/v8.0.4.1ghsax_refsource_MISCWEB
- github.com/rails/rails/releases/tag/v8.1.2.1ghsax_refsource_MISCWEB
- github.com/rails/rails/security/advisories/GHSA-r46p-8f7g-vvvgghsax_refsource_CONFIRMWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/activestorage/CVE-2026-33174.ymlghsaWEB
News mentions
0No linked articles in our index yet.