From 0a72f7d670e9aa77a0bb8584cb1411ddabb7546e Mon Sep 17 00:00:00 2001
From: Zack <zack.ref@gmail.com>
Date: Tue, 15 Feb 2022 13:41:40 -0500
Subject: [PATCH] Added image trasnformation validation via configurable
 allow-list

ImageProcessingTransformer now offers a configurable allow-list for
transformation methods in addition to a configurable deny-list for arguments.

[CVE-2022-21831]
---
 activestorage/lib/active_storage.rb           |   3 +
 activestorage/lib/active_storage/engine.rb    |  17 +
 .../image_processing_transformer.rb           | 352 ++++++++++++++++++
 activestorage/test/models/variant_test.rb     |  62 +++
 .../test/application/configuration_test.rb    |  29 ++
 5 files changed, 463 insertions(+)

diff --git a/activestorage/lib/active_storage.rb b/activestorage/lib/active_storage.rb
index 1450fcaef0..d12d007532 100644
--- a/activestorage/lib/active_storage.rb
+++ b/activestorage/lib/active_storage.rb
@@ -59,6 +59,9 @@ module ActiveStorage
   mattr_accessor :content_types_to_serve_as_binary, default: []
   mattr_accessor :content_types_allowed_inline,     default: []
 
+  mattr_accessor :supported_image_processing_methods, default: []
+  mattr_accessor :unsupported_image_processing_arguments
+
   mattr_accessor :service_urls_expire_in, default: 5.minutes
   mattr_accessor :urls_expire_in
 
diff --git a/activestorage/lib/active_storage/engine.rb b/activestorage/lib/active_storage/engine.rb
index 53495011ab..aad5d9f976 100644
--- a/activestorage/lib/active_storage/engine.rb
+++ b/activestorage/lib/active_storage/engine.rb
@@ -80,6 +80,20 @@ class Engine < Rails::Engine # :nodoc:
       application/pdf
     )
 
+    default_unsupported_image_processing_arguments = %w(
+      -debug
+      -display
+      -distribute-cache
+      -help
+      -path
+      -print
+      -set
+      -verbose
+      -version
+      -write
+      -write-mask
+    )
+
     config.eager_load_namespaces << ActiveStorage
 
     initializer "active_storage.configs" do
@@ -93,6 +107,9 @@ class Engine < Rails::Engine # :nodoc:
         ActiveStorage.draw_routes       = app.config.active_storage.draw_routes != false
         ActiveStorage.resolve_model_to_route = app.config.active_storage.resolve_model_to_route || :rails_storage_redirect
 
+        ActiveStorage.supported_image_processing_methods = app.config.active_storage.supported_image_processing_methods || []
+        ActiveStorage.unsupported_image_processing_arguments = app.config.active_storage.unsupported_image_processing_arguments || default_unsupported_image_processing_arguments
+
         ActiveStorage.variable_content_types = app.config.active_storage.variable_content_types || []
         ActiveStorage.web_image_content_types = app.config.active_storage.web_image_content_types || []
         ActiveStorage.content_types_to_serve_as_binary = app.config.active_storage.content_types_to_serve_as_binary || []
diff --git a/activestorage/lib/active_storage/transformers/image_processing_transformer.rb b/activestorage/lib/active_storage/transformers/image_processing_transformer.rb
index 20940bb20a..a5182aefe2 100644
--- a/activestorage/lib/active_storage/transformers/image_processing_transformer.rb
+++ b/activestorage/lib/active_storage/transformers/image_processing_transformer.rb
@@ -13,6 +13,300 @@ module ActiveStorage
   module Transformers
     class ImageProcessingTransformer < Transformer
       private
+        class UnsupportedImageProcessingMethod < StandardError; end
+        class UnsupportedImageProcessingArgument < StandardError; end
+        SUPPORTED_IMAGE_PROCESSING_METHODS = [
+          "adaptive_blur",
+          "adaptive_resize",
+          "adaptive_sharpen",
+          "adjoin",
+          "affine",
+          "alpha",
+          "annotate",
+          "antialias",
+          "append",
+          "apply",
+          "attenuate",
+          "authenticate",
+          "auto_gamma",
+          "auto_level",
+          "auto_orient",
+          "auto_threshold",
+          "backdrop",
+          "background",
+          "bench",
+          "bias",
+          "bilateral_blur",
+          "black_point_compensation",
+          "black_threshold",
+          "blend",
+          "blue_primary",
+          "blue_shift",
+          "blur",
+          "border",
+          "bordercolor",
+          "borderwidth",
+          "brightness_contrast",
+          "cache",
+          "canny",
+          "caption",
+          "channel",
+          "channel_fx",
+          "charcoal",
+          "chop",
+          "clahe",
+          "clamp",
+          "clip",
+          "clip_path",
+          "clone",
+          "clut",
+          "coalesce",
+          "colorize",
+          "colormap",
+          "color_matrix",
+          "colors",
+          "colorspace",
+          "colourspace",
+          "color_threshold",
+          "combine",
+          "combine_options",
+          "comment",
+          "compare",
+          "complex",
+          "compose",
+          "composite",
+          "compress",
+          "connected_components",
+          "contrast",
+          "contrast_stretch",
+          "convert",
+          "convolve",
+          "copy",
+          "crop",
+          "cycle",
+          "deconstruct",
+          "define",
+          "delay",
+          "delete",
+          "density",
+          "depth",
+          "descend",
+          "deskew",
+          "despeckle",
+          "direction",
+          "displace",
+          "dispose",
+          "dissimilarity_threshold",
+          "dissolve",
+          "distort",
+          "dither",
+          "draw",
+          "duplicate",
+          "edge",
+          "emboss",
+          "encoding",
+          "endian",
+          "enhance",
+          "equalize",
+          "evaluate",
+          "evaluate_sequence",
+          "extent",
+          "extract",
+          "family",
+          "features",
+          "fft",
+          "fill",
+          "filter",
+          "flatten",
+          "flip",
+          "floodfill",
+          "flop",
+          "font",
+          "foreground",
+          "format",
+          "frame",
+          "function",
+          "fuzz",
+          "fx",
+          "gamma",
+          "gaussian_blur",
+          "geometry",
+          "gravity",
+          "grayscale",
+          "green_primary",
+          "hald_clut",
+          "highlight_color",
+          "hough_lines",
+          "iconGeometry",
+          "iconic",
+          "identify",
+          "ift",
+          "illuminant",
+          "immutable",
+          "implode",
+          "insert",
+          "intensity",
+          "intent",
+          "interlace",
+          "interline_spacing",
+          "interpolate",
+          "interpolative_resize",
+          "interword_spacing",
+          "kerning",
+          "kmeans",
+          "kuwahara",
+          "label",
+          "lat",
+          "layers",
+          "level",
+          "level_colors",
+          "limit",
+          "limits",
+          "linear_stretch",
+          "linewidth",
+          "liquid_rescale",
+          "list",
+          "loader",
+          "log",
+          "loop",
+          "lowlight_color",
+          "magnify",
+          "map",
+          "mattecolor",
+          "median",
+          "mean_shift",
+          "metric",
+          "mode",
+          "modulate",
+          "moments",
+          "monitor",
+          "monochrome",
+          "morph",
+          "morphology",
+          "mosaic",
+          "motion_blur",
+          "name",
+          "negate",
+          "noise",
+          "normalize",
+          "opaque",
+          "ordered_dither",
+          "orient",
+          "page",
+          "paint",
+          "pause",
+          "perceptible",
+          "ping",
+          "pointsize",
+          "polaroid",
+          "poly",
+          "posterize",
+          "precision",
+          "preview",
+          "process",
+          "quality",
+          "quantize",
+          "quiet",
+          "radial_blur",
+          "raise",
+          "random_threshold",
+          "range_threshold",
+          "red_primary",
+          "regard_warnings",
+          "region",
+          "remote",
+          "render",
+          "repage",
+          "resample",
+          "resize",
+          "resize_to_fill",
+          "resize_to_fit",
+          "resize_to_limit",
+          "resize_and_pad",
+          "respect_parentheses",
+          "reverse",
+          "roll",
+          "rotate",
+          "sample",
+          "sampling_factor",
+          "saver",
+          "scale",
+          "scene",
+          "screen",
+          "seed",
+          "segment",
+          "selective_blur",
+          "separate",
+          "sepia_tone",
+          "shade",
+          "shadow",
+          "shared_memory",
+          "sharpen",
+          "shave",
+          "shear",
+          "sigmoidal_contrast",
+          "silent",
+          "similarity_threshold",
+          "size",
+          "sketch",
+          "smush",
+          "snaps",
+          "solarize",
+          "sort_pixels",
+          "sparse_color",
+          "splice",
+          "spread",
+          "statistic",
+          "stegano",
+          "stereo",
+          "storage_type",
+          "stretch",
+          "strip",
+          "stroke",
+          "strokewidth",
+          "style",
+          "subimage_search",
+          "swap",
+          "swirl",
+          "synchronize",
+          "taint",
+          "text_font",
+          "threshold",
+          "thumbnail",
+          "tile_offset",
+          "tint",
+          "title",
+          "transform",
+          "transparent",
+          "transparent_color",
+          "transpose",
+          "transverse",
+          "treedepth",
+          "trim",
+          "type",
+          "undercolor",
+          "unique_colors",
+          "units",
+          "unsharp",
+          "update",
+          "valid_image",
+          "view",
+          "vignette",
+          "virtual_pixel",
+          "visual",
+          "watermark",
+          "wave",
+          "wavelet_denoise",
+          "weight",
+          "white_balance",
+          "white_point",
+          "white_threshold",
+          "window",
+          "window_group"
+        ].concat(ActiveStorage.supported_image_processing_methods)
+
+        UNSUPPORTED_IMAGE_PROCESSING_ARGUMENTS = ActiveStorage.unsupported_image_processing_arguments
+
         def process(file, format:)
           processor.
             source(file).
@@ -28,6 +322,10 @@ def processor
 
         def operations
           transformations.each_with_object([]) do |(name, argument), list|
+            if ActiveStorage.variant_processor == :mini_magick
+              validate_transformation(name, argument)
+            end
+
             if name.to_s == "combine_options"
               raise ArgumentError, <<~ERROR.squish
                 Active Storage's ImageProcessing transformer doesn't support :combine_options,
@@ -40,6 +338,60 @@ def operations
             end
           end
         end
+
+        def validate_transformation(name, argument)
+          method_name = name.to_s.gsub("-","_")
+
+          unless SUPPORTED_IMAGE_PROCESSING_METHODS.any? { |method| method_name == method }
+            raise UnsupportedImageProcessingMethod, <<~ERROR.squish
+              One or more of the provided transformation methods is not supported.
+            ERROR
+          end
+
+          if argument.present?
+            if argument.is_a?(String) || argument.is_a?(Symbol)
+              validate_arg_string(argument)
+            elsif argument.is_a?(Array)
+              validate_arg_array(argument)
+            elsif argument.is_a?(Hash)
+              validate_arg_hash(argument)
+            end
+          end
+        end
+
+        def validate_arg_string(argument)
+          if UNSUPPORTED_IMAGE_PROCESSING_ARGUMENTS.any? { |bad_arg| argument.to_s.downcase.include?(bad_arg) }; raise UnsupportedImageProcessingArgument end
+        end
+
+        def validate_arg_array(argument)
+          argument.each do |arg|
+            if arg.is_a?(Integer) || arg.is_a?(Float)
+              next
+            elsif arg.is_a?(String) || arg.is_a?(Symbol)
+              validate_arg_string(arg)
+            elsif arg.is_a?(Array)
+              validate_arg_array(arg)
+            elsif arg.is_a?(Hash)
+              validate_arg_hash(arg)
+            end
+          end
+        end
+
+        def validate_arg_hash(argument)
+          argument.each do |key, value|
+            validate_arg_string(key)
+
+            if value.is_a?(Integer) || value.is_a?(Float)
+              next
+            elsif value.is_a?(String) || value.is_a?(Symbol)
+              validate_arg_string(value)
+            elsif value.is_a?(Array)
+              validate_arg_array(value)
+            elsif value.is_a?(Hash)
+              validate_arg_hash(value)
+            end
+          end
+        end
     end
   end
 end
diff --git a/activestorage/test/models/variant_test.rb b/activestorage/test/models/variant_test.rb
index d030e68497..338c209e91 100644
--- a/activestorage/test/models/variant_test.rb
+++ b/activestorage/test/models/variant_test.rb
@@ -211,6 +211,68 @@ class ActiveStorage::VariantTest < ActiveSupport::TestCase
     assert_equal :png, blob.send(:default_variant_format)
   end
 
+  test "variations with dangerous argument string raise UnsupportedImageProcessingArgument" do
+    process_variants_with :mini_magick do
+      blob = create_file_blob(filename: "racecar.jpg")
+      assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do
+        blob.variant(resize: "-PaTh /tmp/file.erb").processed
+      end
+    end
+  end
+
+  test "variations with dangerous argument array raise UnsupportedImageProcessingArgument" do
+    process_variants_with :mini_magick do
+      blob = create_file_blob(filename: "racecar.jpg")
+      assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do
+        blob.variant(resize: [123, "-write", "/tmp/file.erb"]).processed
+      end
+    end
+  end
+
+  test "variations with dangerous argument in a nested array raise UnsupportedImageProcessingArgument" do
+    process_variants_with :mini_magick do
+      blob = create_file_blob(filename: "racecar.jpg")
+      assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do
+        blob.variant(resize: [123, ["-write", "/tmp/file.erb"], "/tmp/file.erb"]).processed
+      end
+
+      assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do
+        blob.variant(resize: [123, {"-write /tmp/file.erb": "something"}, "/tmp/file.erb"]).processed
+      end
+    end
+  end
+
+  test "variations with dangerous argument hash raise UnsupportedImageProcessingArgument" do
+    process_variants_with :mini_magick do
+      blob = create_file_blob(filename: "racecar.jpg")
+      assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do
+        blob.variant(saver: {"-write": "/tmp/file.erb"}).processed
+      end
+    end
+  end
+
+  test "variations with dangerous argument in a nested hash raise UnsupportedImageProcessingArgument" do
+    process_variants_with :mini_magick do
+      blob = create_file_blob(filename: "racecar.jpg")
+      assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do
+        blob.variant(saver: {"something": {"-write": "/tmp/file.erb"}}).processed
+      end
+
+      assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do
+        blob.variant(saver: {"something": ["-write", "/tmp/file.erb"]}).processed
+      end
+    end
+  end
+
+  test "variations with unsupported methods raise UnsupportedImageProcessingMethod" do
+    process_variants_with :mini_magick do
+      blob = create_file_blob(filename: "racecar.jpg")
+      assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingMethod) do
+        blob.variant(system: "touch /tmp/dangerous").processed
+      end
+    end
+  end
+
   private
     def process_variants_with(processor)
       previous_processor, ActiveStorage.variant_processor = ActiveStorage.variant_processor, processor
diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb
index ce1adc69ea..84f595b8ea 100644
--- a/railties/test/application/configuration_test.rb
+++ b/railties/test/application/configuration_test.rb
@@ -3098,6 +3098,35 @@ class MyLogger < ::Logger
       assert_equal :vips, ActiveStorage.variant_processor
     end
 
+    test "ActiveStorage.supported_image_processing_methods can be configured via config.active_storage.supported_image_processing_methods" do
+      remove_from_config '.*config\.load_defaults.*\n'
+
+      app_file "config/initializers/add_image_processing_methods.rb", <<-RUBY
+        Rails.application.config.active_storage.supported_image_processing_methods = ["write", "set"]
+      RUBY
+
+      app "development"
+
+      assert ActiveStorage.supported_image_processing_methods.include?("write")
+      assert ActiveStorage.supported_image_processing_methods.include?("set")
+    end
+
+    test "ActiveStorage.unsupported_image_processing_arguments can be configured via config.active_storage.unsupported_image_processing_arguments" do
+      remove_from_config '.*config\.load_defaults.*\n'
+
+      app_file "config/initializers/add_image_processing_arguments.rb", <<-RUBY
+      Rails.application.config.active_storage.unsupported_image_processing_arguments = %w(
+        -write
+        -danger
+      )
+      RUBY
+
+      app "development"
+
+      assert ActiveStorage.unsupported_image_processing_arguments.include?("-danger")
+      refute ActiveStorage.unsupported_image_processing_arguments.include?("-set")
+    end
+
     test "hosts include .localhost in development" do
       app "development"
       assert_includes Rails.application.config.hosts, ".localhost"
-- 
2.35.0

