From 5deda5ef3ef8763dfe936a313e0dddaa279a1b04 Mon Sep 17 00:00:00 2001
From: Sam <sam.saffron@gmail.com>
Date: Mon, 12 Apr 2021 13:55:54 +1000
Subject: [PATCH] FIX: automatically timeout long running image magick commands
 (#12670)

Previously certain images may lead to convert / identify to run for unreasonable
amounts of time

This adds a maximum amount of time these commands can run prior to forcing
them to stop
---
 app/models/optimized_image.rb     |  3 ++-
 app/models/upload.rb              |  5 +++--
 lib/discourse.rb                  |  8 +++++++-
 lib/theme_store/git_importer.rb   |  2 +-
 lib/upload_creator.rb             | 22 ++++++++++++++++++----
 spec/components/discourse_spec.rb |  6 ++++++
 6 files changed, 37 insertions(+), 9 deletions(-)

diff --git a/app/models/optimized_image.rb b/app/models/optimized_image.rb
index e32f15a703c..228b3dc54be 100644
--- a/app/models/optimized_image.rb
+++ b/app/models/optimized_image.rb
@@ -306,9 +306,10 @@ class OptimizedImage < ActiveRecord::Base
   end
 
   MAX_PNGQUANT_SIZE = 500_000
+  MAX_CONVERT_SECONDS = 20
 
   def self.convert_with(instructions, to, opts = {})
-    Discourse::Utils.execute_command("nice", "-n", "10", *instructions)
+    Discourse::Utils.execute_command("nice", "-n", "10", *instructions, timeout: MAX_CONVERT_SECONDS)
 
     allow_pngquant = to.downcase.ends_with?(".png") && File.size(to) < MAX_PNGQUANT_SIZE
     FileHelper.optimize_image!(to, allow_pngquant: allow_pngquant)
diff --git a/app/models/upload.rb b/app/models/upload.rb
index bb6ba200174..0f1902c21be 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -13,6 +13,7 @@ class Upload < ActiveRecord::Base
   SHA1_LENGTH = 40
   SEEDED_ID_THRESHOLD = 0
   URL_REGEX ||= /(\/original\/\dX[\/\.\w]*\/(\h+)[\.\w]*)/
+  MAX_IDENTIFY_SECONDS = 5
 
   belongs_to :user
   belongs_to :access_control_post, class_name: 'Post'
@@ -225,7 +226,7 @@ class Upload < ActiveRecord::Base
 
     begin
       if extension == 'svg'
-        w, h = Discourse::Utils.execute_command("identify", "-format", "%w %h", path).split(' ') rescue [0, 0]
+        w, h = Discourse::Utils.execute_command("identify", "-format", "%w %h", path, timeout: MAX_IDENTIFY_SECONDS).split(' ') rescue [0, 0]
       else
         w, h = FastImage.new(path, raise_on_failure: true).size
       end
@@ -274,7 +275,7 @@ class Upload < ActiveRecord::Base
   end
 
   def target_image_quality(local_path, test_quality)
-    @file_quality ||= Discourse::Utils.execute_command("identify", "-format", "%Q", local_path).to_i rescue 0
+    @file_quality ||= Discourse::Utils.execute_command("identify", "-format", "%Q", local_path, timeout: MAX_IDENTIFY_SECONDS).to_i rescue 0
 
     if @file_quality == 0 || @file_quality > test_quality
       test_quality
diff --git a/lib/discourse.rb b/lib/discourse.rb
index 5224b2aac2f..12f89a9c010 100644
--- a/lib/discourse.rb
+++ b/lib/discourse.rb
@@ -95,7 +95,13 @@ module Discourse
 
       private
 
-      def execute_command(*command, failure_message: "", success_status_codes: [0], chdir: ".")
+      def execute_command(*command, timeout: nil, failure_message: "", success_status_codes: [0], chdir: ".")
+        if timeout
+          # will send a TERM after timeout
+          # will send a KILL after timeout * 2
+          command = ["timeout", "-k", "#{timeout.to_f * 2}", timeout.to_s] + command
+        end
+
         stdout, stderr, status = Open3.capture3(*command, chdir: chdir)
 
         if !status.exited? || !success_status_codes.include?(status.exitstatus)
diff --git a/lib/theme_store/git_importer.rb b/lib/theme_store/git_importer.rb
index 37fb49e6cdf..553016b07f3 100644
--- a/lib/theme_store/git_importer.rb
+++ b/lib/theme_store/git_importer.rb
@@ -82,7 +82,7 @@ class ThemeStore::GitImporter
       else
         Discourse::Utils.execute_command("git", "clone", @url, @temp_folder)
       end
-    rescue RuntimeError => err
+    rescue RuntimeError
       raise RemoteTheme::ImportError.new(I18n.t("themes.import_error.git"))
     end
   end
diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb
index bd9cadcee92..63364e7b4f1 100644
--- a/lib/upload_creator.rb
+++ b/lib/upload_creator.rb
@@ -125,7 +125,15 @@ class UploadCreator
 
       if is_image
         if @image_info.type.to_s == 'svg'
-          w, h = Discourse::Utils.execute_command("identify", "-format", "%w %h", @file.path).split(' ') rescue [0, 0]
+          w, h = [0, 0]
+
+          begin
+            w, h = Discourse::Utils
+              .execute_command("identify", "-format", "%w %h", @file.path, timeout: Upload::MAX_IDENTIFY_SECONDS)
+              .split(' ')
+          rescue
+            # use default 0, 0
+          end
         else
           w, h = @image_info.size
         end
@@ -264,6 +272,7 @@ class UploadCreator
     extract_image_info!
   end
 
+  MAX_CONVERT_FORMAT_SECONDS = 20
   def execute_convert(from, to, opts = {})
     command = [
       "convert",
@@ -277,7 +286,11 @@ class UploadCreator
     command << "-quality" << opts[:quality].to_s if opts[:quality]
     command << to
 
-    Discourse::Utils.execute_command(*command, failure_message: I18n.t("upload.png_to_jpg_conversion_failure_message"))
+    Discourse::Utils.execute_command(
+      *command,
+      failure_message: I18n.t("upload.png_to_jpg_conversion_failure_message"),
+      timeout: MAX_CONVERT_FORMAT_SECONDS
+    )
   end
 
   def should_alter_quality?
@@ -354,13 +367,14 @@ class UploadCreator
     @image_info.orientation.to_i > 1
   end
 
+  MAX_FIX_ORIENTATION_TIME = 5
   def fix_orientation!
     path = @file.path
 
     OptimizedImage.ensure_safe_paths!(path)
     path = OptimizedImage.prepend_decoder!(path, nil, filename: "image.#{@image_info.type}")
 
-    Discourse::Utils.execute_command('convert', path, '-auto-orient', path)
+    Discourse::Utils.execute_command('convert', path, '-auto-orient', path, timeout: MAX_FIX_ORIENTATION_TIME)
 
     extract_image_info!
   end
@@ -458,7 +472,7 @@ class UploadCreator
         OptimizedImage.ensure_safe_paths!(@file.path)
 
         command = ["identify", "-format", "%n\\n", @file.path]
-        frames = Discourse::Utils.execute_command(*command).to_i rescue 1
+        frames = Discourse::Utils.execute_command(*command, timeout: Upload::MAX_IDENTIFY_SECONDS).to_i rescue 1
 
         frames > 1
       else
diff --git a/spec/components/discourse_spec.rb b/spec/components/discourse_spec.rb
index fe8db27fa19..6f1b294733c 100644
--- a/spec/components/discourse_spec.rb
+++ b/spec/components/discourse_spec.rb
@@ -398,6 +398,12 @@ describe Discourse do
       expect(Discourse::Utils.execute_command("pwd", chdir: "plugins").strip).to eq("#{Rails.root.to_s}/plugins")
     end
 
+    it "supports timeouts" do
+      expect do
+        Discourse::Utils.execute_command("sleep", "999999999999", timeout: 0.001)
+      end.to raise_error(RuntimeError)
+    end
+
     it "works with a block" do
       Discourse::Utils.execute_command do |runner|
         expect(runner.exec("pwd").strip).to eq(Rails.root.to_s)