diff --git a/Gemfile b/Gemfile index 90b4021f6..0f70db5a3 100644 --- a/Gemfile +++ b/Gemfile @@ -48,7 +48,7 @@ end gem 'mysql2' # Webserver - included in development and test and optionally in production -gem 'puma' +gem 'puma', '>= 6.4.3' gem 'bootsnap', require: false gem 'csv' diff --git a/Gemfile.lock b/Gemfile.lock index 6bb903f2a..8c2a326e1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -592,7 +592,7 @@ DEPENDENCIES net-smtp oauth2 pdf-reader - puma + puma (>= 6.4.3) rack-cors rails (~> 8.0) rails-latex diff --git a/app/middleware/request_smuggling_protection.rb b/app/middleware/request_smuggling_protection.rb new file mode 100644 index 000000000..46f4be7f8 --- /dev/null +++ b/app/middleware/request_smuggling_protection.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'json' + +# +# Rejects ambiguous HTTP framing headers to reduce request smuggling risk. +# +class RequestSmugglingProtection + BAD_REQUEST_RESPONSE = [ + 400, + { 'Content-Type' => 'application/json', 'Connection' => 'close' }, + [{ error: 'Malformed request framing headers' }.to_json] + ].freeze + + def initialize(app) + @app = app + end + + def call(env) + return BAD_REQUEST_RESPONSE if malformed_framing_headers?(env) + + @app.call(env) + end + + private + + def malformed_framing_headers?(env) + content_length = env['CONTENT_LENGTH'] + transfer_encoding = env['HTTP_TRANSFER_ENCODING'] + + conflicting_content_length_and_transfer_encoding?(content_length, transfer_encoding) || + invalid_content_length?(content_length) || + invalid_transfer_encoding?(transfer_encoding) + end + + def conflicting_content_length_and_transfer_encoding?(content_length, transfer_encoding) + header_present?(content_length) && header_present?(transfer_encoding) + end + + def invalid_content_length?(content_length) + return false unless header_present?(content_length) + + content_length.include?(',') || content_length !~ /\A\d+\z/ + end + + def invalid_transfer_encoding?(transfer_encoding) + return false unless header_present?(transfer_encoding) + + normalized = transfer_encoding.split(',').map(&:strip).reject(&:empty?) + normalized != ['chunked'] + end + + def header_present?(value) + !value.nil? && !value.strip.empty? + end +end diff --git a/config/application.rb b/config/application.rb index fe280a289..f81483010 100644 --- a/config/application.rb +++ b/config/application.rb @@ -5,6 +5,8 @@ require 'csv' require 'yaml' require 'bunny-pub-sub/services_manager' +require_relative '../app/middleware/request_smuggling_protection' + # Precompile assets before deploying to production if defined?(Bundler) @@ -250,6 +252,7 @@ def self.fetch_boolean_env(name) resource '*', headers: :any, methods: %i(get post put delete options) end end + config.middleware.insert_before Rack::Cors, RequestSmugglingProtection config.active_support.to_time_preserves_timezone = :zone diff --git a/config/puma.rb b/config/puma.rb index d9b3e836c..e6c509667 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -32,6 +32,22 @@ # # workers ENV.fetch("WEB_CONCURRENCY") { 2 } +workers ENV.fetch("WEB_CONCURRENCY", 2) +threads_count = ENV.fetch("RAILS_MAX_THREADS", 5) +threads threads_count, threads_count +preload_app! +port ENV.fetch("PORT", 3000) +environment ENV.fetch("RAILS_ENV", "development") + +# SECURITY FIX: Raise on SIGTERM to prevent request queue poisoning +raise_exception_on_sigterm + +# SECURITY FIX: Reject malformed/early-hint requests +# Forces Puma 6.x strict HTTP parsing mode +lowlevel_error_handler do |err, env, status| + [400, { "Content-Type" => "text/plain" }, ["Bad Request"]] +end + # Use the `preload_app!` method when specifying a `workers` number. # This directive tells Puma to first boot the application and load code # before forking the application. This takes advantage of Copy On Write