# typed: strict # frozen_string_literal: true module PlugApp module Middleware # There is no valid reason for a request to contain a malformed string # so just return HTTP 400 (Bad Request) if we receive one class MalformedRequest extend T::Sig include ActionController::HttpAuthentication::Basic NULL_BYTE_REGEX = T.let(Regexp.new(Regexp.escape("\u0000")).freeze, Regexp) sig { returns(T.untyped) } attr_reader :app sig { params(app: T.untyped).void } def initialize(app) @app = T.let(app, T.untyped) end sig { params(env: T.untyped).returns(T.untyped) } def call(env) return [PlugApp::HTTP::BAD_REQUEST_I, { "Content-Type" => "text/plain" }, [PlugApp::HTTP::BAD_REQUEST]] if request_contains_malformed_string?(env) app.call(env) end private sig { params(env: T.untyped).returns(T::Boolean) } def request_contains_malformed_string?(env) # Duplicate the env, so it is not modified when accessing the parameters # https://github.com/rails/rails/blob/34991a6ae2fc68347c01ea7382fa89004159e019/actionpack/lib/action_dispatch/http/parameters.rb#L59 request = ActionDispatch::Request.new(env.dup) return true if malformed_path?(request.path) return true if credentials_malformed?(request) request.params.values.any? do |value| param_has_null_byte?(value) end rescue ActionController::BadRequest # If we can't build an ActionDispatch::Request something's wrong # This would also happen if `#params` contains invalid UTF-8 # in this case we'll return a 400 true end sig { params(path: String).returns(T::Boolean) } def malformed_path?(path) string_malformed?(Rack::Utils.unescape(path)) rescue ArgumentError # Rack::Utils.unescape raised this, path is malformed. true end sig { params(request: T.untyped).returns(T::Boolean) } def credentials_malformed?(request) credentials = if has_basic_credentials?(request) decode_credentials(request).presence else request.authorization.presence end return false unless credentials string_malformed?(credentials) end sig { params(value: T.untyped, depth: Integer).returns(T::Boolean) } def param_has_null_byte?(value, depth = 0) # Guard against possible attack sending large amounts of nested params # Should be safe as deeply nested params are highly uncommon. return false if depth > 2 depth += 1 if value.respond_to?(:match) string_malformed?(value) elsif value.respond_to?(:values) value.values.any? do |hash_value| param_has_null_byte?(hash_value, depth) end elsif value.is_a?(Array) value.any? do |array_value| param_has_null_byte?(array_value, depth) end else false end end sig { params(string: String).returns(T::Boolean) } def string_malformed?(string) # We're using match instead of include because that raises an ArgumentError # when the string contains invalid UTF-8 # # We try to encode the string from ASCII-8BIT to UTF8. If we failed to do # so for certain characters in the string, those chars are probably incomplete # multibyte characters. string.dup.force_encoding(Encoding::UTF_8).match?(NULL_BYTE_REGEX) rescue ArgumentError, Encoding::UndefinedConversionError # If we're here, we caught a malformed string. Return true true end end end end