diff --git a/lib/discourse_chatbot/safe_ruby/lib/constant_whitelist.rb b/lib/discourse_chatbot/safe_ruby/lib/constant_whitelist.rb new file mode 100644 index 0000000..23b6b51 --- /dev/null +++ b/lib/discourse_chatbot/safe_ruby/lib/constant_whitelist.rb @@ -0,0 +1,13 @@ +ALLOWED_CONSTANTS= [ + :Object, :Module, :Class, :BasicObject, :Kernel, :NilClass, :NIL, :Data, :TrueClass, :TRUE, :FalseClass, :FALSE, :Encoding, + :Comparable, :Enumerable, :String, :Symbol, :Exception, :SystemExit, :SignalException, :Interrupt, :StandardError, :TypeError, + :ArgumentError, :IndexError, :KeyError, :RangeError, :ScriptError, :SyntaxError, :LoadError, :NotImplementedError, :NameError, + :NoMethodError, :RuntimeError, :SecurityError, :NoMemoryError, :EncodingError, :SystemCallError, :Errno, :ZeroDivisionError, + :FloatDomainError, :Numeric, :Integer, :Fixnum, :Float, :Bignum, :Array, :Hash, :Struct, :RegexpError, :Regexp, + :MatchData, :Marshal, :Range, :IOError, :EOFError, :IO, :STDIN, :STDOUT, :STDERR, :Time, :Random, + :Signal, :Proc, :LocalJumpError, :SystemStackError, :Method, :UnboundMethod, :Binding, :Math, :Enumerator, + :StopIteration, :RubyVM, :Thread, :TOPLEVEL_BINDING, :ThreadGroup, :Mutex, :ThreadError, :Fiber, :FiberError, :Rational, :Complex, + :RUBY_VERSION, :RUBY_RELEASE_DATE, :RUBY_PLATFORM, :RUBY_PATCHLEVEL, :RUBY_REVISION, :RUBY_DESCRIPTION, :RUBY_COPYRIGHT, :RUBY_ENGINE, + :TracePoint, :ARGV, :Gem, :RbConfig, :Config, :CROSS_COMPILING, :Date, :ConditionVariable, :Queue, :SizedQueue, :MonitorMixin, :Monitor, + :Exception2MessageMapper, :IRB, :RubyToken, :RubyLex, :Readline, :RUBYGEMS_ACTIVATION_MONITOR +] diff --git a/lib/discourse_chatbot/safe_ruby/lib/make_safe_code.rb b/lib/discourse_chatbot/safe_ruby/lib/make_safe_code.rb new file mode 100644 index 0000000..188a3a9 --- /dev/null +++ b/lib/discourse_chatbot/safe_ruby/lib/make_safe_code.rb @@ -0,0 +1,51 @@ +MAKE_SAFE_CODE = <<-STRING +def keep_singleton_methods(klass, singleton_methods) + klass = Object.const_get(klass) + singleton_methods = singleton_methods.map(&:to_sym) + undef_methods = (klass.singleton_methods - singleton_methods) + + undef_methods.each do |method| + klass.singleton_class.send(:undef_method, method) + end + +end + +def keep_methods(klass, methods) + klass = Object.const_get(klass) + methods = methods.map(&:to_sym) + undef_methods = (klass.methods(false) - methods) + undef_methods.each do |method| + klass.send(:undef_method, method) + end +end + +def clean_constants + (Object.constants - #{ALLOWED_CONSTANTS}).each do |const| + Object.send(:remove_const, const) if defined?(const) + end +end + +keep_singleton_methods(:Kernel, #{KERNEL_S_METHODS}) +keep_singleton_methods(:Symbol, #{SYMBOL_S_METHODS}) +keep_singleton_methods(:String, #{STRING_S_METHODS}) +keep_singleton_methods(:IO, #{IO_S_METHODS}) + +keep_methods(:Kernel, #{KERNEL_METHODS}) +keep_methods(:NilClass, #{NILCLASS_METHODS}) +keep_methods(:TrueClass, #{TRUECLASS_METHODS}) +keep_methods(:FalseClass, #{FALSECLASS_METHODS}) +keep_methods(:Enumerable, #{ENUMERABLE_METHODS}) +keep_methods(:String, #{STRING_METHODS}) +Kernel.class_eval do + def `(*args) + raise NoMethodError, "` is unavailable" + end + + def system(*args) + raise NoMethodError, "system is unavailable" + end +end + +clean_constants + +STRING diff --git a/lib/discourse_chatbot/safe_ruby/lib/method_whitelist.rb b/lib/discourse_chatbot/safe_ruby/lib/method_whitelist.rb new file mode 100644 index 0000000..7b7b385 --- /dev/null +++ b/lib/discourse_chatbot/safe_ruby/lib/method_whitelist.rb @@ -0,0 +1,270 @@ +IO_S_METHODS = %w[ + new + foreach + open +] + +KERNEL_S_METHODS = %w[ + Array + binding + block_given? + catch + chomp + chomp! + chop + chop! + eval + fail + Float + format + global_variables + gsub + gsub! + Integer + iterator? + lambda + local_variables + loop + method_missing + proc + raise + scan + split + sprintf + String + sub + sub! + throw + ].freeze + +SYMBOL_S_METHODS = %w[ +all_symbols +].freeze + +STRING_S_METHODS = %w[ +].freeze + +KERNEL_METHODS = %w[ +== + +ray +nding +ock_given? +tch +omp +omp! +op +op! +ass +clone +dup +eql? +equal? +eval +fail +Float +format +freeze +frozen? +global_variables +gsub +gsub! +hash +id +initialize_copy +inspect +instance_eval +instance_of? +instance_variables +instance_variable_get +instance_variable_set +instance_variable_defined? +Integer +is_a? +iterator? +kind_of? +lambda +local_variables +loop +methods +method_missing +nil? +private_methods +print +proc +protected_methods +public_methods +raise +remove_instance_variable +respond_to? +respond_to_missing? +scan +send +singleton_methods +singleton_method_added +singleton_method_removed +singleton_method_undefined +split +sprintf +String +sub +sub! +taint +tainted? +throw +to_a +to_s +type +untaint +__send__ +].freeze + +NILCLASS_METHODS = %w[ +& +inspect +nil? +to_a +to_f +to_i +to_s +^ +| +].freeze + +SYMBOL_METHODS = %w[ +=== +id2name +inspect +to_i +to_int +to_s +to_sym +].freeze + +TRUECLASS_METHODS = %w[ +& +to_s +^ +| +].freeze + +FALSECLASS_METHODS = %w[ +& +to_s +^ +| +].freeze + +ENUMERABLE_METHODS = %w[ +all? +any? +collect +detect +each_with_index +entries +find +find_all +grep +include? +inject +map +max +member? +min +partition +reject +select +sort +sort_by +to_a +zip +].freeze + +STRING_METHODS = %w[ +% +* ++ +<< +<=> +== +=~ +capitalize +capitalize! +casecmp +center +chomp +chomp! +chop +chop! +concat +count +crypt +delete +delete! +downcase +downcase! +dump +each +each_byte +each_line +empty? +eql? +gsub +gsub! +hash +hex +include? +index +initialize +initialize_copy +insert +inspect +intern +length +ljust +lines +lstrip +lstrip! +match +next +next! +oct +replace +reverse +reverse! +rindex +rjust +rstrip +rstrip! +scan +size +slice +slice! +split +squeeze +squeeze! +strip +strip! +start_with? +sub +sub! +succ +succ! +sum +swapcase +swapcase! +to_f +to_i +to_s +to_str +to_sym +tr +tr! +tr_s +tr_s! +upcase +upcase! +upto +[] +[]= +].freeze diff --git a/lib/discourse_chatbot/safe_ruby/lib/safe_ruby.rb b/lib/discourse_chatbot/safe_ruby/lib/safe_ruby.rb new file mode 100644 index 0000000..ca3fab3 --- /dev/null +++ b/lib/discourse_chatbot/safe_ruby/lib/safe_ruby.rb @@ -0,0 +1,9 @@ +require 'childprocess' +require_relative 'method_whitelist' +require_relative 'constant_whitelist' +require_relative 'make_safe_code' +require_relative 'safe_ruby/runner' +require_relative 'safe_ruby/version' + +class SafeRuby +end diff --git a/lib/discourse_chatbot/safe_ruby/lib/safe_ruby/runner.rb b/lib/discourse_chatbot/safe_ruby/lib/safe_ruby/runner.rb new file mode 100644 index 0000000..bd531d8 --- /dev/null +++ b/lib/discourse_chatbot/safe_ruby/lib/safe_ruby/runner.rb @@ -0,0 +1,69 @@ +require 'tempfile' + +class EvalError < StandardError + def initialize(msg); super; end +end + +class SafeRuby + DEFAULTS = { timeout: 5, + raise_errors: true } + + def initialize(code, options={}) + options = DEFAULTS.merge(options) + + @code = code + @raise_errors = options[:raise_errors] + @timeout = options[:timeout] + end + + def self.eval(code, options={}) + new(code, options).eval + end + + def eval + temp = build_tempfile + read, write = IO.pipe + ChildProcess.build("ruby", temp.path).tap do |process| + process.io.stdout = write + process.io.stderr = write + process.start + begin + process.poll_for_exit(@timeout) + rescue ChildProcess::TimeoutError => e + process.stop # tries increasingly harsher methods to kill the process. + return e.message + end + write.close + temp.unlink + end + + data = read.read + begin + Marshal.load(data) + rescue => e + if @raise_errors + raise data + else + return data + end + end + end + + def self.check(code, expected) + eval(code) == eval(expected) + end + + + private + + def build_tempfile + file = Tempfile.new('saferuby') + file.write(MAKE_SAFE_CODE) + file.write <<-STRING + result = eval(%q(#{@code})) + print Marshal.dump(result) + STRING + file.rewind + file + end +end diff --git a/lib/discourse_chatbot/safe_ruby/lib/safe_ruby/version.rb b/lib/discourse_chatbot/safe_ruby/lib/safe_ruby/version.rb new file mode 100644 index 0000000..3bfb6e9 --- /dev/null +++ b/lib/discourse_chatbot/safe_ruby/lib/safe_ruby/version.rb @@ -0,0 +1,7 @@ +class SafeRuby + MAJOR_VERSION = 1 + MINOR_VERSION = 0 + RELEASE_VERSION = 3 + + VERSION = [MAJOR_VERSION, MINOR_VERSION, RELEASE_VERSION].join('.') +end diff --git a/plugin.rb b/plugin.rb index a1a18e3..1ae32a8 100644 --- a/plugin.rb +++ b/plugin.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # name: discourse-chatbot # about: a plugin that allows you to have a conversation with a configurable chatbot in Discourse Chat, Topics and Private Messages -# version: 0.9.21 +# version: 0.9.22 # authors: merefield # url: https://github.com/merefield/discourse-chatbot @@ -15,7 +15,8 @@ gem "wikipedia-client", '1.17.0' # safe ruby for calculations and date functions gem "childprocess", "5.0.0" -gem "safe_ruby", "1.0.4" +# gem "safe_ruby", "1.0.4" TODO add this back in if gem returns to being maintained + module ::DiscourseChatbot PLUGIN_NAME = "discourse-chatbot" @@ -79,6 +80,7 @@ def progress_debug_message(message) ../lib/discourse_chatbot/bots/open_ai_bot_base.rb ../lib/discourse_chatbot/bots/open_ai_bot_basic.rb ../lib/discourse_chatbot/bots/open_ai_bot_rag.rb + ../lib/discourse_chatbot/safe_ruby/lib/safe_ruby.rb ../lib/discourse_chatbot/function.rb ../lib/discourse_chatbot/functions/calculator_function.rb ../lib/discourse_chatbot/functions/escalate_to_staff_function.rb diff --git a/spec/lib/safe_ruby/safe_ruby_spec.rb b/spec/lib/safe_ruby/safe_ruby_spec.rb new file mode 100644 index 0000000..55287cb --- /dev/null +++ b/spec/lib/safe_ruby/safe_ruby_spec.rb @@ -0,0 +1,82 @@ +# require 'spec_helper' + +require 'benchmark' + +RSpec.configure do |config| + config.run_all_when_everything_filtered = true + config.filter_run :focus + + config.order = 'random' +end + +describe SafeRuby do + describe '#eval' do + it 'allows basic operations' do + expect(SafeRuby.eval('4 + 5')).to eq 9 + expect(SafeRuby.eval('[4, 5].map{|n| n+1}')).to eq [5 ,6] + end + + it 'returns correct object' do + expect(SafeRuby.eval('[1,2,3]')).to eq [1,2,3] + end + + MALICIOUS_OPERATIONS = [ + "system('rm *')", + "`rm *`", + "Kernel.abort", + "cat spec/spec_helper.rb", + "File.class_eval { `echo Hello` }", + "FileUtils.class_eval { `echo Hello` }", + "Dir.class_eval { `echo Hello` }", + "FileTest.class_eval { `echo Hello` }", + "File.eval \"`echo Hello`\"", + "FileUtils.eval \"`echo Hello`\"", + "Dir.eval \"`echo Hello`\"", + "FileTest.eval \"`echo Hello`\"", + "File.instance_eval { `echo Hello` }", + "FileUtils.instance_eval { `echo Hello` }", + "Dir.instance_eval { `echo Hello` }", + "FileTest.instance_eval { `echo Hello` }", + "f=IO.popen('uname'); f.readlines; f.close", + "IO.binread('/etc/passwd')", + "IO.read('/etc/passwd')", + ] + + MALICIOUS_OPERATIONS.each do |op| + it "protects from malicious operations like (#{op})" do + expect{ + SafeRuby.eval(op) + }.to raise_error RuntimeError + end + end + + describe "options" do + describe "timeout" do + it 'defaults to a 5 second timeout' do + time = Benchmark.realtime do + SafeRuby.eval('(1..100000).map {|n| n**100}') + end + expect(time).to be_within(0.5).of(5) + end + + it 'allows custom timeout' do + time = Benchmark.realtime do + SafeRuby.eval('(1..100000).map {|n| n**100}', timeout: 1) + end + expect(time).to be_within(0.5).of(1) + end + end + + describe "raising errors" do + it "defaults to raising errors" do + expect{ SafeRuby.eval("asdasdasd") }.to raise_error RuntimeError + end + + it "allows not raising errors" do + expect {SafeRuby.eval("asdasd", raise_errors: false)}.to_not raise_error + end + end + end + + end +end