├── .ruby-version ├── .ruby-gemset ├── Rakefile ├── .rspec ├── lib ├── trusted_sandbox │ ├── version.rb │ ├── server_images │ │ └── ruby-2.1.2 │ │ │ ├── Gemfile │ │ │ ├── bundle_config │ │ │ ├── entrypoint.sh │ │ │ ├── run.rb │ │ │ └── Dockerfile │ ├── errors.rb │ ├── general_purpose.rb │ ├── defaults.rb │ ├── config │ │ └── trusted_sandbox.yml │ ├── request_serializer.rb │ ├── response.rb │ ├── cli.rb │ ├── uid_pool.rb │ ├── config.rb │ └── host_runner.rb └── trusted_sandbox.rb ├── bin └── trusted_sandbox ├── Gemfile ├── .gitignore ├── Gemfile.lock ├── spec ├── integration │ ├── quota_spec.rb │ └── integration_spec.rb ├── lib │ ├── trusted_sandbox │ │ ├── request_serializer_spec.rb │ │ ├── config_spec.rb │ │ ├── uid_pool_spec.rb │ │ ├── response_spec.rb │ │ └── host_runner_spec.rb │ └── trusted_sandbox_spec.rb └── spec_helper.rb ├── LICENSE ├── trusted-sandbox.gemspec └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-2.1.2 -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | trusted-sandbox -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /lib/trusted_sandbox/version.rb: -------------------------------------------------------------------------------- 1 | module TrustedSandbox 2 | VERSION = '0.1.6' 3 | end 4 | -------------------------------------------------------------------------------- /bin/trusted_sandbox: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'trusted_sandbox/cli' 4 | TrustedSandbox::Cli.start(ARGV) -------------------------------------------------------------------------------- /lib/trusted_sandbox/server_images/ruby-2.1.2/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'activesupport' -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in trusted_sandbox.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /lib/trusted_sandbox/server_images/ruby-2.1.2/bundle_config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: .bundle 3 | BUNDLE_DISABLE_SHARED_GEMS: "1" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | tmp/ 4 | .swp 5 | pkg/ 6 | config/trusted_sandbox.yml 7 | trusted_sandbox.yml 8 | trusted_sandbox_images/ 9 | -------------------------------------------------------------------------------- /lib/trusted_sandbox/errors.rb: -------------------------------------------------------------------------------- 1 | module TrustedSandbox 2 | class InvocationError < StandardError ; end 3 | class PoolTimeoutError < StandardError; end 4 | class ContainerError < StandardError; end 5 | class UserCodeError < StandardError; end 6 | class InternalError < StandardError; end 7 | class ExecutionTimeoutError < StandardError; end 8 | end -------------------------------------------------------------------------------- /lib/trusted_sandbox/server_images/ruby-2.1.2/entrypoint.sh: -------------------------------------------------------------------------------- 1 | uid=$1 2 | 3 | if [ -z $uid ]; then 4 | echo "you must provide a uid" 5 | exit 1 6 | fi 7 | 8 | echo "127.0.0.1 $(hostname)" >> /etc/hosts 9 | 10 | if [ -n "$USE_QUOTAS" -a "$USE_QUOTAS" != "0" -a "$USE_QUOTAS" != "false" ]; then 11 | usermod -u $uid sandbox 12 | chown sandbox /home/sandbox/src 13 | fi 14 | 15 | sudo -u sandbox -i bundle exec ruby run.rb -------------------------------------------------------------------------------- /lib/trusted_sandbox/server_images/ruby-2.1.2/run.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'active_support' 3 | require 'yaml' 4 | 5 | manifest_file_path = '/home/sandbox/src/manifest' 6 | input_file_path = '/home/sandbox/src/input' 7 | output_file_path = '/home/sandbox/src/output' 8 | 9 | manifest = YAML.load_file(manifest_file_path) 10 | manifest.each {|f| require_relative "src/#{f}"} 11 | 12 | data = File.binread(input_file_path) 13 | klass_name, args = Marshal.load(data) 14 | klass = ActiveSupport::Inflector.constantize klass_name 15 | 16 | obj = klass.new(*args) 17 | output = obj.run 18 | 19 | File.binwrite output_file_path, Marshal.dump(status: 'success', output: output) 20 | rescue => e 21 | File.binwrite output_file_path, Marshal.dump(status: 'error', error: e) 22 | end -------------------------------------------------------------------------------- /lib/trusted_sandbox/general_purpose.rb: -------------------------------------------------------------------------------- 1 | module TrustedSandbox 2 | 3 | # This is a general purpose class that can be used to run untrusted code in a container using TrustedSandbox. 4 | # Usage: 5 | # 6 | # TrustedSandbox.run! TrustedSandbox::GeneralPurpose, "1 + 1" 7 | # # => 2 8 | # 9 | # TrustedSandbox.run! TrustedSandbox::GeneralPurpose, "input[:a] + input[:b]", input: {a: 1, b: 2} 10 | # # => 3 11 | # 12 | class GeneralPurpose 13 | def initialize(code, args={}) 14 | @code = code 15 | args.each do |name, value| 16 | singleton_klass = class << self; self; end 17 | singleton_klass.class_eval { attr_reader name } 18 | instance_variable_set "@#{name}", value 19 | end 20 | end 21 | 22 | def run 23 | eval @code 24 | end 25 | end 26 | end -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | trusted-sandbox (0.1.5) 5 | docker-api (~> 1.17) 6 | thor (~> 0.19) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | archive-tar-minitar (0.5.2) 12 | diff-lcs (1.2.5) 13 | docker-api (1.17.0) 14 | archive-tar-minitar 15 | excon (>= 0.38.0) 16 | json 17 | excon (0.42.1) 18 | json (1.8.1) 19 | rake (10.1.0) 20 | rr (1.1.2) 21 | rspec (3.1.0) 22 | rspec-core (~> 3.1.0) 23 | rspec-expectations (~> 3.1.0) 24 | rspec-mocks (~> 3.1.0) 25 | rspec-core (3.1.7) 26 | rspec-support (~> 3.1.0) 27 | rspec-expectations (3.1.2) 28 | diff-lcs (>= 1.2.0, < 2.0) 29 | rspec-support (~> 3.1.0) 30 | rspec-mocks (3.1.3) 31 | rspec-support (~> 3.1.0) 32 | rspec-support (3.1.2) 33 | thor (0.19.1) 34 | 35 | PLATFORMS 36 | ruby 37 | 38 | DEPENDENCIES 39 | bundler (~> 1.3) 40 | rake 41 | rr 42 | rspec 43 | trusted-sandbox! 44 | -------------------------------------------------------------------------------- /lib/trusted_sandbox/server_images/ruby-2.1.2/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:14.04 2 | MAINTAINER Amit Aharoni 3 | 4 | RUN apt-get -y update 5 | RUN apt-get -y install build-essential zlib1g-dev libssl-dev libreadline6-dev libyaml-dev wget 6 | RUN cd /tmp && wget http://ftp.ruby-lang.org/pub/ruby/2.1/ruby-2.1.2.tar.gz && tar -xvzf ruby-2.1.2.tar.gz 7 | RUN cd /tmp/ruby-2.1.2/ && ./configure --prefix=/usr/local && make && make install 8 | 9 | RUN groupadd app && useradd -m -G app -d /home/sandbox sandbox 10 | 11 | RUN gem install bundler 12 | ADD Gemfile /home/sandbox/Gemfile 13 | ADD bundle_config /home/sandbox/.bundle/config 14 | RUN chown sandbox /home/sandbox/Gemfile && \ 15 | chown sandbox /home/sandbox/.bundle && \ 16 | chown sandbox /home/sandbox/.bundle/config && \ 17 | sudo -u sandbox -i bundle install 18 | 19 | ADD entrypoint.sh entrypoint.sh 20 | ADD run.rb /home/sandbox/run.rb 21 | RUN chown sandbox /home/sandbox/run.rb 22 | 23 | ENTRYPOINT ["/bin/bash", "entrypoint.sh"] -------------------------------------------------------------------------------- /spec/integration/quota_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # Note! Will only work on linux machine that is configured appropriately with user quotas of 10 MB. 4 | # This should not be run by CI. 5 | 6 | # Usage from a configured server: 7 | # rspec spec/integration/quota_spec.rb 8 | 9 | describe 'quota limit integration testing' do 10 | it 'works when quotas are unlimited' do 11 | response = TrustedSandbox.with_options(enable_quotas: false) do |s| 12 | s.run_code "File.open('test','w') {|f| f.write '*' * 15_000_000}" 13 | end 14 | response.valid?.should == true 15 | end 16 | 17 | # rspec spec/integration/quota_spec.rb --example "quota limit integration testing does not work when quotas are limited" 18 | it 'does not work when quotas are limited' do 19 | response = TrustedSandbox.with_options(enable_quotas: true) do |s| 20 | s.run_code "File.open('test','w') {|f| f.write '*' * 15_000_000}" 21 | end 22 | response.valid?.should == false 23 | response.stderr.any? {|row| row =~ /disk quota exceeded/i}.should == true 24 | end 25 | end -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Amit Aharoni 4 | 5 | Permission is hereby granted, free of charge, to any person 6 | obtaining a copy of this software and associated documentation 7 | files (the "Software"), to deal in the Software without 8 | restriction, including without limitation the rights to use, 9 | copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the 11 | Software is furnished to do so, subject to the following 12 | conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 19 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 20 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 21 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 22 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 23 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 24 | OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /trusted-sandbox.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'trusted_sandbox/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'trusted-sandbox' 8 | spec.version = TrustedSandbox::VERSION 9 | spec.authors = ['Amit Aharoni'] 10 | spec.email = ['amit.sites@gmail.com'] 11 | spec.description = %q{Trusted Sandbox makes it simple to execute classes that eval untrusted code in a resource-controlled docker container} 12 | spec.summary = %q{Run untrusted code in a contained sandbox using Docker} 13 | spec.homepage = 'https://github.com/vaharoni/trusted-sandbox' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ['lib'] 20 | 21 | spec.add_development_dependency 'bundler', '~> 1.3' 22 | spec.add_development_dependency 'rake' 23 | spec.add_development_dependency 'rspec' 24 | spec.add_development_dependency 'rr' 25 | 26 | spec.add_runtime_dependency 'docker-api', '~> 1.17' 27 | spec.add_runtime_dependency 'thor', '~> 0.19' 28 | end -------------------------------------------------------------------------------- /lib/trusted_sandbox/defaults.rb: -------------------------------------------------------------------------------- 1 | module TrustedSandbox 2 | class Defaults < Config 3 | 4 | def initialize 5 | self.docker_options = {} 6 | self.docker_image_name = 'vaharoni/trusted_sandbox:ruby-2.1.2.v2' 7 | self.memory_limit = 50 * 1024 * 1024 8 | self.memory_swap_limit = 50 * 1024 * 1024 9 | self.cpu_shares = 1 10 | self.execution_timeout = 15 11 | self.network_access = false 12 | self.enable_swap_limit = false 13 | self.enable_quotas = false 14 | self.host_code_root_path = 'tmp/code_dirs' 15 | self.host_uid_pool_lock_path = 'tmp/uid_pool_lock' 16 | 17 | self.docker_url = ENV['DOCKER_HOST'] if ENV['DOCKER_HOST'] 18 | self.docker_cert_path = ENV['DOCKER_CERT_PATH'] if ENV['DOCKER_CERT_PATH'] 19 | 20 | # Note, changing these may require changing Dockerfile and run.rb and rebuilding the docker image 21 | self.container_code_path = '/home/sandbox/src' 22 | self.container_manifest_filename = 'manifest' 23 | self.container_input_filename = 'input' 24 | self.container_output_filename = 'output' 25 | 26 | # Note, changing these requires running `rake trusted_sandbox:set_quotas` 27 | self.pool_min_uid = 20000 28 | self.pool_size = 5000 29 | 30 | self.keep_code_folders = false 31 | self.keep_containers = false 32 | 33 | self.quiet_mode = false 34 | self.shortcut = false 35 | end 36 | 37 | end 38 | end -------------------------------------------------------------------------------- /lib/trusted_sandbox/config/trusted_sandbox.yml: -------------------------------------------------------------------------------- 1 | development: 2 | # # Optional login information for Docker Hub 3 | # docker_login: 4 | # user: my_user 5 | # password: my_password 6 | # email: email@email.com 7 | 8 | # # For a linux host these can typically remain commented 9 | # docker_url: https://192.168.59.103:2376 10 | # docker_cert_path: ~/.boot2docker/certs/boot2docker-vm 11 | 12 | docker_image_name: vaharoni/trusted_sandbox:ruby-2.1.2.v2 13 | 14 | cpu_shares: 1 15 | 16 | memory_limit: 52_428_800 # 50 MB 17 | enable_swap_limit: false 18 | memory_swap_limit: 52_428_800 # 50 MB 19 | 20 | execution_timeout: 15 21 | network_access: false 22 | 23 | enable_quotas: false 24 | 25 | host_code_root_path: tmp/code_dirs 26 | host_uid_pool_lock_path: tmp/uid_pool_lock 27 | 28 | keep_code_folders: false 29 | keep_containers: false 30 | 31 | # When set to true, code will run in the current process instead of 32 | # a docker container. This is useful for testing and dev machines 33 | # that do not have docker installed 34 | shortcut: false 35 | 36 | # When this is set to false and keep_code_folders is true, you'll 37 | # receive helpful messages about how to connect to your containers 38 | quiet_mode: false 39 | 40 | # # It's very unlikely you'll need to change these 41 | # pool_size: 5000 42 | # pool_min_uid: 20000 43 | # pool_timeout: 3 44 | # pool_retries: 5 45 | # pool_delay: 0.5 46 | -------------------------------------------------------------------------------- /spec/lib/trusted_sandbox/request_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TrustedSandbox::RequestSerializer do 4 | before do 5 | @tmp_path = 'tmp/test/request_serializer' 6 | 7 | @manifest_file_name = 'manifest' 8 | @manifest_file_path = File.expand_path File.join(@tmp_path, @manifest_file_name) 9 | 10 | @args_file_name = 'args' 11 | @args_file_path = File.expand_path File.join(@tmp_path, @args_file_name) 12 | FileUtils.rm_rf @tmp_path 13 | FileUtils.mkdir_p @tmp_path 14 | end 15 | 16 | describe '#initialize' do 17 | before do 18 | @subject = TrustedSandbox::RequestSerializer.new(@tmp_path, @manifest_file_name, @args_file_name) 19 | end 20 | 21 | it 'initializes attributes correctly' do 22 | @subject.host_code_dir_path.should == @tmp_path 23 | @subject.input_file_name.should == @args_file_name 24 | end 25 | 26 | end 27 | 28 | describe '#serialize' do 29 | before do 30 | @subject = TrustedSandbox::RequestSerializer.new(@tmp_path, @manifest_file_name, @args_file_name) 31 | @arg1 = { test: 'working' } 32 | @arg2 = { another_test: 'working too' } 33 | @subject.serialize TrustedSandbox::RequestSerializer, @arg1, @arg2 34 | 35 | @source_class_file = File.expand_path('lib/trusted_sandbox/request_serializer.rb') 36 | @target_class_file = File.expand_path File.join(@tmp_path, 'request_serializer.rb') 37 | end 38 | 39 | it 'copies the class file' do 40 | File.exists?(@target_class_file).should == true 41 | File.read(@target_class_file).should == File.read(@source_class_file) 42 | end 43 | 44 | it 'creates a manifest file' do 45 | File.exists?(@manifest_file_path).should == true 46 | YAML.load_file(@manifest_file_path).should == ['request_serializer.rb'] 47 | end 48 | 49 | it 'serializes arguments' do 50 | File.exists?(@args_file_path).should == true 51 | data = File.binread(@args_file_path) 52 | Marshal.load(data).should == ['TrustedSandbox::RequestSerializer', [@arg1, @arg2]] 53 | end 54 | end 55 | 56 | end -------------------------------------------------------------------------------- /lib/trusted_sandbox/request_serializer.rb: -------------------------------------------------------------------------------- 1 | module TrustedSandbox 2 | class RequestSerializer 3 | 4 | attr_reader :host_code_dir_path, :manifest_file_name, :input_file_name 5 | 6 | # @param host_code_dir_path [String] path to the folder where the argument value needs to be stored 7 | # @param manifest_file_name [String] name of manifest file inside the host_code_dir_path 8 | # @param input_file_name [String] name of input file inside the host_code_dir_path 9 | def initialize(host_code_dir_path, manifest_file_name, input_file_name) 10 | @host_code_dir_path = host_code_dir_path 11 | @input_file_name = input_file_name 12 | @manifest_file_name = manifest_file_name 13 | end 14 | 15 | # @param klass [Class] class name to be serialized 16 | # @param args [Array] the array of argument values 17 | # @return [String] full path of the argument that was stored 18 | def serialize(klass, *args) 19 | self.klass = klass 20 | copy_code_file 21 | create_manifest_file 22 | 23 | data = Marshal.dump([klass.name, args]) 24 | File.binwrite input_file_path, data 25 | end 26 | 27 | private 28 | 29 | def input_file_path 30 | File.join host_code_dir_path, input_file_name 31 | end 32 | 33 | def manifest_file_path 34 | File.join host_code_dir_path, manifest_file_name 35 | end 36 | 37 | # = Methods depending on @klass 38 | 39 | attr_accessor :klass 40 | 41 | def source_file_path 42 | file, _line = klass.instance_method(:initialize).source_location 43 | raise InvocationError.new("Cannot find location of class #{klass.name}") unless File.exist?(file.to_s) 44 | file 45 | end 46 | 47 | def dest_file_name 48 | File.basename(source_file_path) 49 | end 50 | 51 | def dest_file_path 52 | File.join host_code_dir_path, dest_file_name 53 | end 54 | 55 | def copy_code_file 56 | FileUtils.cp source_file_path, dest_file_path 57 | end 58 | 59 | def create_manifest_file 60 | File.open(manifest_file_path, 'w') do |f| 61 | # In the near future this will change to a list of files, hence we use array 62 | f.write [dest_file_name].to_yaml 63 | end 64 | end 65 | 66 | end 67 | end -------------------------------------------------------------------------------- /lib/trusted_sandbox.rb: -------------------------------------------------------------------------------- 1 | module TrustedSandbox 2 | 3 | require 'yaml' 4 | require 'docker' 5 | require 'trusted_sandbox/config' 6 | require 'trusted_sandbox/defaults' 7 | require 'trusted_sandbox/errors' 8 | require 'trusted_sandbox/general_purpose' 9 | require 'trusted_sandbox/request_serializer' 10 | require 'trusted_sandbox/response' 11 | require 'trusted_sandbox/host_runner' 12 | require 'trusted_sandbox/uid_pool' 13 | require 'trusted_sandbox/version' 14 | 15 | def self.test_connection 16 | Docker.version 17 | true 18 | end 19 | 20 | # Usage: 21 | # TrustedSandbox.config do |c| 22 | # c.pool_size = 10 23 | # # ... 24 | # end 25 | def self.config 26 | @config ||= Defaults.send(:new).override config_overrides_from_file 27 | yield @config if block_given? 28 | @config.finished_configuring 29 | end 30 | 31 | def self.config_overrides_from_file(env = nil) 32 | yaml_path = %w(trusted_sandbox.yml config/trusted_sandbox.yml).find {|x| File.exist?(x)} 33 | return {} unless yaml_path 34 | 35 | env ||= ENV['TRUSTED_SANDBOX_ENV'] || ENV['RAILS_ENV'] || 'development' 36 | YAML.load_file(yaml_path)[env] || {} 37 | end 38 | 39 | def self.uid_pool 40 | @uid_pool ||= UidPool.new config.host_uid_pool_lock_path, config.pool_min_uid, config.pool_max_uid, 41 | timeout: config.pool_timeout, retries: config.pool_retries, delay: config.pool_delay 42 | end 43 | 44 | # @param config_override [Hash] allows overriding configurations for a specific invocation 45 | def self.with_options(config_override={}) 46 | yield new_runner(config_override) 47 | end 48 | 49 | # @param klass [Class] the class to be instantiated in the safe sandbox 50 | # @param *args [Array] arguments to send to klass#new 51 | def self.run(klass, *args) 52 | new_runner.run(klass, *args) 53 | end 54 | 55 | def self.run!(klass, *args) 56 | new_runner.run!(klass, *args) 57 | end 58 | 59 | def self.run_code(code, args={}) 60 | new_runner.run_code(code, args) 61 | end 62 | 63 | def self.run_code!(code, args={}) 64 | new_runner.run_code!(code, args) 65 | end 66 | 67 | def self.new_runner(config_override = {}) 68 | HostRunner.new(config, uid_pool, config_override) 69 | end 70 | end 71 | 72 | # Run the configuration block 73 | TrustedSandbox.config -------------------------------------------------------------------------------- /spec/lib/trusted_sandbox_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TrustedSandbox do 4 | describe '#config_overrides_from_file' do 5 | context 'when file does not exist' do 6 | before do 7 | stub(File).exist? { false } 8 | end 9 | 10 | it 'returns empty hash' do 11 | TrustedSandbox.config_overrides_from_file.should == {} 12 | end 13 | end 14 | 15 | context 'when file exists' do 16 | before do 17 | stub(File).exist? { true } 18 | end 19 | 20 | context 'no environment' do 21 | before do 22 | @dev_hash = {'dev' => true} 23 | stub(YAML).load_file { {'development' => @dev_hash} } 24 | end 25 | 26 | it 'returns the hash from the development environment' do 27 | TrustedSandbox.config_overrides_from_file.should == @dev_hash 28 | end 29 | end 30 | 31 | context 'test environment' do 32 | before do 33 | @test_hash = {'test' => true} 34 | stub(YAML).load_file { {'test' => @test_hash} } 35 | end 36 | 37 | context 'from TRUSTED_SANDBOX_ENV' do 38 | before do 39 | ENV['TRUSTED_SANDBOX_ENV'] = 'test' 40 | end 41 | 42 | it 'returns the hash from the test environment' do 43 | TrustedSandbox.config_overrides_from_file.should == @test_hash 44 | end 45 | end 46 | 47 | context 'from RAILS_ENV' do 48 | before do 49 | ENV['RAILS_ENV'] = 'test' 50 | end 51 | 52 | it 'returns the hash from the test environment' do 53 | TrustedSandbox.config_overrides_from_file.should == @test_hash 54 | end 55 | end 56 | end 57 | 58 | context 'file does not contain environment key' do 59 | before do 60 | @dev_hash = {'dev' => true} 61 | stub(YAML).load_file { {'development' => @dev_hash} } 62 | ENV['TRUSTED_SANDBOX_ENV'] = 'test' 63 | end 64 | 65 | it 'returns an empty hash' do 66 | TrustedSandbox.config_overrides_from_file.should == {} 67 | end 68 | end 69 | end 70 | 71 | end 72 | 73 | describe '#with_options' do 74 | before do 75 | @default_network_access = TrustedSandbox.config.network_access 76 | end 77 | 78 | it 'overrides configuration' do 79 | TrustedSandbox.with_options(network_access: !@default_network_access) do |runner| 80 | runner.config.network_access.should == !@default_network_access 81 | end 82 | end 83 | end 84 | end -------------------------------------------------------------------------------- /spec/lib/trusted_sandbox/config_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TrustedSandbox::Config do 4 | before do 5 | @defaults = TrustedSandbox::Defaults.send(:new) 6 | end 7 | 8 | describe 'override mechanism' do 9 | before do 10 | @subject = @defaults.override cpu_shares: 2 11 | end 12 | 13 | it 'ensures defaults have what we expect' do 14 | @defaults.cpu_shares.should == 1 15 | @defaults.execution_timeout.should == 15 16 | end 17 | 18 | it 'works' do 19 | @subject.cpu_shares.should == 2 20 | @subject.execution_timeout.should == 15 21 | end 22 | end 23 | 24 | describe '#pool_max_id' do 25 | before do 26 | @subject = @defaults.override pool_min_uid: 100, pool_size: 10 27 | end 28 | it 'works' do 29 | @subject.pool_max_uid.should == 109 30 | end 31 | end 32 | 33 | describe 'docker_url=' do 34 | before do 35 | @url = 'http://localhost' 36 | @subject = @defaults.override docker_url: @url 37 | end 38 | it 'sets up Docker' do 39 | Docker.url.should == @url 40 | end 41 | end 42 | 43 | describe 'host_code_root_path= and host_uid_pool_lock_path=' do 44 | before do 45 | @subject = @defaults.override host_code_root_path: '~/tmp', host_uid_pool_lock_path: '~/tmp2' 46 | end 47 | it 'expands the path' do 48 | @subject.host_code_root_path.should == File.expand_path('~/tmp') 49 | @subject.host_uid_pool_lock_path.should == File.expand_path('~/tmp2') 50 | end 51 | end 52 | 53 | describe 'docker_cert_path and docker_options' do 54 | before do 55 | @subject = @defaults.override docker_cert_path: '~/tmp', docker_options: { ssl_verify_peer: true } 56 | end 57 | 58 | it 'works' do 59 | @subject.finished_configuring 60 | Docker.options = { private_key_path: File.expand_path('~/tmp/key.pem'), 61 | certificate_path: File.expand_path('~/tmp/cert.pem'), 62 | ssl_verify_peer: true } 63 | end 64 | end 65 | 66 | describe 'docker authentication' do 67 | context 'user did not request to authenticate' do 68 | before do 69 | @subject = @defaults.override 70 | dont_allow(Docker).authenticate! 71 | end 72 | it 'does not perform authentication' do 73 | @subject.finished_configuring 74 | end 75 | end 76 | context 'user requested to authenticate' do 77 | before do 78 | @subject = @defaults.override docker_login: {user: 'user', password: 'password', email: 'email'} 79 | mock(Docker).authenticate!(username: 'user', password: 'password', email: 'email').times(1) 80 | end 81 | 82 | it 'call Docker.authenticate!' do 83 | @subject.finished_configuring 84 | end 85 | 86 | it 'does not call Docker.authenticate! twice' do 87 | 2.times { @subject.finished_configuring } 88 | end 89 | end 90 | end 91 | end -------------------------------------------------------------------------------- /spec/lib/trusted_sandbox/uid_pool_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TrustedSandbox::UidPool do 4 | before do 5 | @tmp_dir = 'tmp/test/uid_pool' 6 | FileUtils.rm_rf @tmp_dir 7 | FileUtils.mkdir_p @tmp_dir 8 | @class = TrustedSandbox::UidPool 9 | end 10 | 11 | describe '#initialize' do 12 | context 'with defaults' do 13 | before do 14 | @subject = @class.new(@tmp_dir, 1, 3) 15 | end 16 | 17 | it 'sets up defaults correctly' do 18 | @subject.timeout.should == 3 19 | @subject.retries.should == 5 20 | @subject.delay.should == 0.5 21 | end 22 | 23 | end 24 | 25 | context 'with other values' do 26 | before do 27 | @subject = @class.new(@tmp_dir, 1, 3, 'timeout' => 5, retries: 10, 'delay' => 1) 28 | end 29 | 30 | it 'sets up values correctly' do 31 | @subject.timeout.should == 5 32 | @subject.retries.should == 10 33 | @subject.delay.should == 1 34 | end 35 | end 36 | 37 | end 38 | 39 | describe 'usage' do 40 | before do 41 | @subject = @class.new(@tmp_dir, 1, 3, retries: 1, timeout: 0.1, delay: 0.1) 42 | @subject.release_all 43 | end 44 | 45 | describe '#lock' do 46 | context 'There are still available IDs' do 47 | it 'gives the UIDs' do 48 | [@subject.lock, @subject.lock, @subject.lock].sort.should == [1, 2, 3] 49 | end 50 | end 51 | 52 | context 'There are no available IDs' do 53 | before do 54 | 3.times { @subject.lock } 55 | end 56 | 57 | it 'raises an error' do 58 | expect {@subject.lock}.to raise_error(TrustedSandbox::PoolTimeoutError) 59 | end 60 | end 61 | end 62 | 63 | describe '#available, #used, #release' do 64 | before do 65 | @subject.release_all 66 | @uid = @subject.lock 67 | end 68 | it 'sets the right available and used' do 69 | @subject.available.should == 2 70 | @subject.used.should == 1 71 | @subject.available_uids.sort.should == [2,3] 72 | @subject.used_uids.should == [1] 73 | end 74 | it 'releases the right uid' do 75 | @subject.release @uid 76 | @subject.available.should == 3 77 | @subject.used.should == 0 78 | @subject.available_uids.sort.should == [1,2,3] 79 | @subject.used_uids.should == [] 80 | end 81 | it 'does not release the wrong uid' do 82 | @subject.release @uid + 1 83 | @subject.available.should == 2 84 | @subject.used.should == 1 85 | @subject.available_uids.sort.should == [2,3] 86 | @subject.used_uids.should == [1] 87 | end 88 | end 89 | 90 | describe '#release_all' do 91 | before do 92 | @subject.release_all 93 | 3.times { @subject.lock } 94 | end 95 | it 'passes sanity tests' do 96 | @subject.available.should == 0 97 | @subject.used.should == 3 98 | @subject.available_uids.should == [] 99 | @subject.used_uids.sort.should == [1,2,3] 100 | end 101 | it 'works' do 102 | @subject.release_all 103 | @subject.available.should == 3 104 | @subject.used.should == 0 105 | @subject.available_uids.sort.should == [1,2,3] 106 | @subject.used_uids.should == [] 107 | end 108 | end 109 | end 110 | end -------------------------------------------------------------------------------- /spec/lib/trusted_sandbox/response_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TrustedSandbox::Response do 4 | before do 5 | @tmp_path = 'tmp/test/response' 6 | @file_name = 'hello' 7 | @file_path = File.expand_path File.join(@tmp_path, @file_name) 8 | FileUtils.rm_rf @tmp_path 9 | FileUtils.mkdir_p @tmp_path 10 | end 11 | 12 | context 'no error' do 13 | before do 14 | File.binwrite @file_path, Marshal.dump(status: 'success', output: 'hi') 15 | @subject = TrustedSandbox::Response.new('stdout', 'stderr', @tmp_path, @file_name) 16 | @subject.parse! 17 | end 18 | 19 | it 'instantiates correctly' do 20 | @subject.host_code_dir_path.should == @tmp_path 21 | @subject.output_file_name.should == @file_name 22 | @subject.stdout.should == ['stdout'] 23 | @subject.stderr.should == ['stderr'] 24 | end 25 | 26 | it 'parses the file correctly' do 27 | @subject.raw_response.should == {status: 'success', output: 'hi'} 28 | @subject.status.should == 'success' 29 | @subject.output.should == 'hi' 30 | @subject.error.should be_nil 31 | @subject.error_to_raise.should be_nil 32 | @subject.valid?.should == true 33 | end 34 | end 35 | 36 | context 'user error' do 37 | before do 38 | @err = 1 / 0 rescue $! 39 | File.binwrite @file_path, Marshal.dump(status: 'error', error: @err) 40 | @subject = TrustedSandbox::Response.new(nil, nil, @tmp_path, @file_name) 41 | @subject.parse! 42 | end 43 | 44 | it 'initializes with an error' do 45 | @subject.raw_response.should == {status: 'error', error: @err} 46 | @subject.status.should == 'error' 47 | @subject.output.should == nil 48 | @subject.error.should == @err 49 | @subject.error_to_raise.is_a?(TrustedSandbox::UserCodeError).should == true 50 | expect {@subject.output!}.to raise_error(TrustedSandbox::UserCodeError) 51 | @subject.valid?.should == false 52 | end 53 | end 54 | 55 | context 'unexpected file format' do 56 | before do 57 | @err = 1 / 0 rescue $! 58 | File.binwrite @file_path, Marshal.dump(status: 'unexpected', output: 'hi', error: @err) 59 | @subject = TrustedSandbox::Response.new(nil, nil, @tmp_path, @file_name) 60 | @subject.parse! 61 | end 62 | 63 | it 'initializes with an error' do 64 | @subject.raw_response.should == {status: 'unexpected', output: 'hi', error: @err} 65 | @subject.status.should == 'error' 66 | @subject.output.should == nil 67 | @subject.error.is_a?(TrustedSandbox::InternalError).should == true 68 | @subject.error_to_raise.is_a?(TrustedSandbox::InternalError).should == true 69 | expect {@subject.output!}.to raise_error(TrustedSandbox::InternalError) 70 | @subject.valid?.should == false 71 | end 72 | end 73 | 74 | context 'file is missing' do 75 | before do 76 | @subject = TrustedSandbox::Response.new(nil, nil, @tmp_path, @file_name) 77 | @subject.parse! 78 | end 79 | 80 | it 'initializes with an error' do 81 | @subject.raw_response.should == nil 82 | @subject.status.should == 'error' 83 | @subject.output.should == nil 84 | @subject.error.is_a?(TrustedSandbox::ContainerError).should == true 85 | @subject.error_to_raise.is_a?(TrustedSandbox::ContainerError).should == true 86 | expect {@subject.output!}.to raise_error(TrustedSandbox::ContainerError) 87 | @subject.valid?.should == false 88 | end 89 | end 90 | end -------------------------------------------------------------------------------- /lib/trusted_sandbox/response.rb: -------------------------------------------------------------------------------- 1 | module TrustedSandbox 2 | class Response 3 | 4 | attr_reader :host_code_dir_path, :output_file_name, :stdout, :stderr, 5 | :raw_response, :status, :error, :error_to_raise, :output 6 | 7 | # @param stdout [String, Array] response of stdout from the container 8 | # @param stderr [String, Array] response of stderr from the container 9 | # @param host_code_dir_path [String] path to the folder where the argument value needs to be stored 10 | # @param output_file_name [String] name of output file inside the host_code_dir_path 11 | def initialize(stdout = nil, stderr = nil, host_code_dir_path = nil, output_file_name = nil) 12 | @stdout = [stdout].flatten.compact 13 | @stderr = [stderr].flatten.compact 14 | @host_code_dir_path = host_code_dir_path 15 | @output_file_name = output_file_name 16 | end 17 | 18 | # = Alternative initializers 19 | 20 | # @param error [StandardError] error object that was raised during execution of the code 21 | # @param error_to_raise [Class] an error class in the TrustedSandbox module. 22 | # @param stdout [String] 23 | # @param stderr [String] 24 | # @return [Response] object initialized with error details 25 | def self.error(error, error_to_raise, stdout = nil, stderr = nil) 26 | obj = new(stdout, stderr) 27 | obj.instance_eval do 28 | @status = 'error' 29 | @error = error 30 | @error_to_raise = error_to_raise.new(error) 31 | end 32 | obj 33 | end 34 | 35 | # This is used when user decides not to go through docker 36 | # @param output [Object] the result of the code execution 37 | # @param stdout [String] 38 | # @param stderr [String] 39 | # @return [Response] object initialized with output 40 | def self.shortcut(output, stdout = nil, stderr = nil) 41 | obj = new(stdout, stderr) 42 | obj.instance_eval do 43 | @status = 'success' 44 | @output = output 45 | end 46 | obj 47 | end 48 | 49 | # @return [Boolean] 50 | def valid? 51 | status == 'success' 52 | end 53 | 54 | # @return [Object] the output returned by the container. Raises errors if encountered. 55 | # @raise [ContainerError, UserCodeError, InternalError] if errors were raised by the container, they are bubbled 56 | # as UserCodeError 57 | def output! 58 | propagate_errors! 59 | output 60 | end 61 | 62 | # Parses the output file and stores the values in the appropriate ivars 63 | # @return [nil] 64 | def parse! 65 | unless File.exists? output_file_path 66 | @status = 'error' 67 | @error = ContainerError.new('User code did not finish properly') 68 | @error_to_raise = @error 69 | return 70 | end 71 | 72 | begin 73 | data = File.binread output_file_path 74 | @raw_response = Marshal.load(data) 75 | rescue => e 76 | @status = 'error' 77 | @error = e 78 | @error_to_raise = ContainerError.new(e) 79 | return 80 | end 81 | 82 | unless ['success', 'error'].include? @raw_response[:status] 83 | @status = 'error' 84 | @error = InternalError.new('Output file has invalid format') 85 | @error_to_raise = @error 86 | return 87 | end 88 | 89 | @status = @raw_response[:status] 90 | @output = @raw_response[:output] 91 | @error = @raw_response[:error] 92 | @error_to_raise = UserCodeError.new(@error) if @error 93 | nil 94 | end 95 | 96 | private 97 | 98 | def output_file_path 99 | File.join(host_code_dir_path, output_file_name) 100 | end 101 | 102 | def propagate_errors! 103 | return if valid? 104 | raise InternalError.new 'Response object is invalid but no errors were recorded.' unless error 105 | raise error_to_raise 106 | end 107 | 108 | end 109 | end -------------------------------------------------------------------------------- /lib/trusted_sandbox/cli.rb: -------------------------------------------------------------------------------- 1 | require 'trusted_sandbox' 2 | require 'thor' 3 | 4 | module TrustedSandbox 5 | class Cli < Thor 6 | desc 'install', 'Creates trusted_sandbox.yml in `config`, if this directory exists, or in the current directory otherwise' 7 | def install 8 | curr_dir_file = 'trusted_sandbox.yml' 9 | config_dir_file = 'config/trusted_sandbox.yml' 10 | 11 | puts "#{curr_dir_file} already exists" or return if File.exist?(curr_dir_file) 12 | puts "#{config_dir_file} already exists" or return if File.exist?(config_dir_file) 13 | 14 | target_file = Dir.exist?('config') ? config_dir_file : curr_dir_file 15 | 16 | puts "Creating #{target_file}" 17 | FileUtils.cp File.expand_path('../config/trusted_sandbox.yml', __FILE__), target_file 18 | end 19 | 20 | desc 'test', 'Checks Trusted Sandbox can connect to Docker' 21 | def test 22 | TrustedSandbox.test_connection 23 | puts 'Trusted Sandbox seems to be configured correctly!' 24 | end 25 | 26 | desc 'ssh UID', 'Launch a container with shell and mount the code folder. Works only if keep_code_folders is true. UID is the suffix of the code folder' 27 | def ssh(uid) 28 | raise 'keep_code_folders must be set to true' unless TrustedSandbox.config.keep_code_folders 29 | local_code_dir = File.join TrustedSandbox.config.host_code_root_path, uid 30 | `docker run -it -v #{local_code_dir}:/home/sandbox/src --entrypoint="/bin/bash" #{TrustedSandbox.config.docker_image_name} -s` 31 | end 32 | 33 | desc 'generate_image IMAGE_NAME', 'Creates the Docker image files and places them into the `trusted_sandbox_images` directory. Default name is ruby-2.1.2' 34 | def generate_image(image_name = 'ruby-2.1.2') 35 | target_dir = 'trusted_sandbox_images' 36 | target_image_path = "#{target_dir}/#{image_name}" 37 | gem_image_path = File.expand_path("../server_images/#{image_name}", __FILE__) 38 | 39 | puts "Image #{image_name} does not exist" or return unless Dir.exist?(gem_image_path) 40 | puts "Directory #{target_image_path} already exists" or return if Dir.exist?(target_image_path) 41 | 42 | puts "Copying #{image_name} into #{target_image_path}" 43 | FileUtils.mkdir_p target_dir 44 | FileUtils.cp_r gem_image_path, target_image_path 45 | end 46 | 47 | desc 'generate_images', 'Copies all Docker images files into `trusted_sandbox_images` directory' 48 | def generate_images 49 | target_dir = 'trusted_sandbox_images' 50 | source_dir = File.expand_path("../server_images", __FILE__) 51 | 52 | puts "Directory #{target_dir} already exists" or return if Dir.exist?(target_dir) 53 | puts "Copying images into #{target_dir}" 54 | 55 | FileUtils.cp_r source_dir, target_dir 56 | end 57 | 58 | desc 'set_quotas QUOTA_KB', 'Sets the quota for all the UIDs in the pool. This requires additional installation. Refer to the README file.' 59 | def set_quotas(quota_kb) 60 | from = TrustedSandbox.config.pool_min_uid 61 | to = TrustedSandbox.config.pool_max_uid 62 | puts "Configuring quota for UIDs [#{from}..#{to}]" 63 | (from..to).each do |uid| 64 | `sudo setquota -u #{uid} 0 #{quota_kb} 0 0 /` 65 | end 66 | end 67 | 68 | desc 'reset_uid_pool UID', 'Release the provided UID from the UID-pool. If the UID is omitted, all UIDs that were reserved will be released, effectively resetting the pool' 69 | def reset_uid_pool(uid = nil) 70 | if uid 71 | TrustedSandbox.uid_pool.release uid 72 | else 73 | TrustedSandbox.uid_pool.release_all 74 | end 75 | end 76 | 77 | desc 'ssh UID', 'Shows how to run a container with the current configuration settings. If UID is provided, it includes mounting instructions.' 78 | def ssh(uid=nil) 79 | uid_string = uid ? "-v #{File.join(TrustedSandbox.config.host_code_root_path, uid)}:/home/sandbox/src" : nil 80 | puts %{docker run -it #{uid_string} --entrypoint="/bin/bash" #{TrustedSandbox.config.docker_image_name} -s} 81 | end 82 | end 83 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause this 4 | # file to always be loaded, without a need to explicitly require it in any files. 5 | # 6 | # Given that it is always loaded, you are encouraged to keep this file as 7 | # light-weight as possible. Requiring heavyweight dependencies from this file 8 | # will add to the boot time of your test suite on EVERY test run, even for an 9 | # individual file that may not need all of that loaded. Instead, consider making 10 | # a separate helper file that requires the additional dependencies and performs 11 | # the additional setup, and require it from the spec files that actually need it. 12 | # 13 | # The `.rspec` file also contains a few flags that are not defaults but that 14 | # users commonly want. 15 | # 16 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 17 | 18 | require 'trusted_sandbox' 19 | 20 | RSpec.configure do |config| 21 | # rspec-expectations config goes here. You can use an alternate 22 | # assertion/expectation library such as wrong or the stdlib/minitest 23 | # assertions if you prefer. 24 | config.expect_with :rspec do |expectations| 25 | # This option will default to `true` in RSpec 4. It makes the `description` 26 | # and `failure_message` of custom matchers include text for helper methods 27 | # defined using `chain`, e.g.: 28 | # be_bigger_than(2).and_smaller_than(4).description 29 | # # => "be bigger than 2 and smaller than 4" 30 | # ...rather than: 31 | # # => "be bigger than 2" 32 | expectations.syntax = [:expect, :should] 33 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 34 | end 35 | 36 | config.mock_framework = :rr 37 | 38 | # rspec-mocks config goes here. You can use an alternate test double 39 | # library (such as bogus or mocha) by changing the `mock_with` option here. 40 | # config.mock_with :rspec do |mocks| 41 | # Prevents you from mocking or stubbing a method that does not exist on 42 | # a real object. This is generally recommended, and will default to 43 | # `true` in RSpec 4. 44 | # mocks.verify_partial_doubles = true 45 | # end 46 | 47 | # The settings below are suggested to provide a good initial experience 48 | # with RSpec, but feel free to customize to your heart's content. 49 | =begin 50 | # These two settings work together to allow you to limit a spec run 51 | # to individual examples or groups you care about by tagging them with 52 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 53 | # get run. 54 | config.filter_run :focus 55 | config.run_all_when_everything_filtered = true 56 | 57 | # Limits the available syntax to the non-monkey patched syntax that is recommended. 58 | # For more details, see: 59 | # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 60 | # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 61 | # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching 62 | config.disable_monkey_patching! 63 | 64 | # This setting enables warnings. It's recommended, but in some cases may 65 | # be too noisy due to issues in dependencies. 66 | config.warnings = true 67 | 68 | # Many RSpec users commonly either run the entire suite or an individual 69 | # file, and it's useful to allow more verbose output when running an 70 | # individual spec file. 71 | if config.files_to_run.one? 72 | # Use the documentation formatter for detailed output, 73 | # unless a formatter has already been configured 74 | # (e.g. via a command-line flag). 75 | config.default_formatter = 'doc' 76 | end 77 | 78 | # Print the 10 slowest examples and example groups at the 79 | # end of the spec run, to help surface which specs are running 80 | # particularly slow. 81 | config.profile_examples = 10 82 | 83 | # Run specs in random order to surface order dependencies. If you find an 84 | # order dependency and want to debug it, you can fix the order by providing 85 | # the seed, which is printed after each run. 86 | # --seed 1234 87 | config.order = :random 88 | 89 | # Seed global randomization in this process using the `--seed` CLI option. 90 | # Setting this allows you to use `--seed` to deterministically reproduce 91 | # test failures related to randomization by passing the same `--seed` value 92 | # as the one that triggered the failure. 93 | Kernel.srand config.seed 94 | =end 95 | end 96 | -------------------------------------------------------------------------------- /lib/trusted_sandbox/uid_pool.rb: -------------------------------------------------------------------------------- 1 | module TrustedSandbox 2 | 3 | # Offers intra-server inter-process pool of Uids. In other words: 4 | # - Every server has its own pool. Since Docker containers live within a server, this is what we want. 5 | # - Processes within the same server share the pool. 6 | # 7 | # Usage: 8 | # The following will behave the same when different processes try to perform #lock and #release. 9 | # 10 | # pool = UidPool.new 100, 101 11 | # pool.lock 12 | # # => 100 13 | # 14 | # pool.lock 15 | # # => 101 16 | # 17 | # pool.lock 18 | # # => RuntimeError: No available UIDs in the pool. Please try again later. 19 | # 20 | # pool.release(100) 21 | # # => 100 22 | # 23 | # pool.lock 24 | # # => 100 25 | # 26 | # pool.release_all 27 | # 28 | class UidPool 29 | 30 | attr_reader :lock_dir, :master_lock_file, :lower, :upper, :timeout, :retries, :delay 31 | 32 | # @param lower [Integer] lower bound of the pool 33 | # @param upper [Integer] upper bound of the pool 34 | # @option timeout [Integer] number of seconds to wait for the lock 35 | # @option retries [Integer] number of attempts to retry to acquire a uid 36 | # @option delay [Float] delay between retries 37 | def initialize(lock_dir, lower, upper, options={}) 38 | @lock_dir = lock_dir 39 | FileUtils.mkdir_p(lock_dir) 40 | 41 | @master_lock_file = lock_file_path_for('master') 42 | @lower = lower 43 | @upper = upper 44 | @timeout = options[:timeout] || options['timeout'] || 3 45 | @retries = options[:retries] || options['retries'] || 5 46 | @delay = options[:delay] || options['delay'] || 0.5 47 | end 48 | 49 | def inspect 50 | "#" 51 | end 52 | 53 | # Locks one UID from the pool, in a cross-process atomic manner 54 | # @return [Integer] 55 | # @raise [PoolTimeoutError] if no ID is available after retries 56 | def lock 57 | retries.times do 58 | atomically(timeout) do 59 | uid = available_uid 60 | if uid 61 | lock_uid uid 62 | return uid.to_i 63 | end 64 | end 65 | sleep(delay) 66 | end 67 | raise PoolTimeoutError.new('No available UIDs in the pool. Please try again later.') 68 | end 69 | 70 | # Releases all UIDs 71 | # @return [UidPool] self 72 | def release_all 73 | all_uids.each do |uid| 74 | release uid 75 | end 76 | self 77 | end 78 | 79 | # Releases one UID 80 | # @param uid [Integer] 81 | # @return [Integer] UID removed 82 | def release(uid) 83 | atomically(timeout) do 84 | release_uid uid 85 | end 86 | end 87 | 88 | # @return [Integer] number of used UIDs 89 | def used 90 | used_uids.length 91 | end 92 | 93 | # @return [Integer] number of availabld UIDs 94 | def available 95 | available_uids.length 96 | end 97 | 98 | # @return [Array] all taken uids 99 | def used_uids 100 | uids = Dir.entries(lock_dir) - %w(. .. master) 101 | uids.map(&:to_i) 102 | end 103 | 104 | # @return [Array] all non taken uids 105 | def available_uids 106 | all_uids - used_uids 107 | end 108 | 109 | private 110 | 111 | # @return [Array] all uids in range 112 | def all_uids 113 | [*lower..upper] 114 | end 115 | 116 | # @param uid [Integer] 117 | # @return [String] full path for the UID lock file 118 | def lock_file_path_for(uid) 119 | File.join lock_dir, uid.to_s 120 | end 121 | 122 | # Creates a UID lock file in the lock_dir 123 | # 124 | # @param uid [Integer] 125 | # @return [Integer] the UID locked 126 | def lock_uid(uid) 127 | File.open lock_file_path_for(uid), 'w' 128 | uid 129 | end 130 | 131 | # Removes a UID lock file from the lock_dir 132 | # 133 | # @param uid [Integer] 134 | # @return [Integer] the UID removed 135 | def release_uid(uid) 136 | FileUtils.rm lock_file_path_for(uid), force: true 137 | uid 138 | end 139 | 140 | # @param timeout [Integer] 141 | # @return yield return value 142 | def atomically(timeout) 143 | Timeout.timeout(timeout) do 144 | File.open(master_lock_file, File::RDWR|File::CREAT, 0644) do |f| 145 | f.flock File::LOCK_EX 146 | yield 147 | end 148 | end 149 | end 150 | 151 | # @return [Integer, nil] one available uid or nil if none is available 152 | def available_uid 153 | available_uids.first 154 | end 155 | 156 | end 157 | end -------------------------------------------------------------------------------- /spec/integration/integration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | # This should not be run by CI as it requires server installation. 4 | 5 | describe 'integration testing' do 6 | 7 | describe 'sanity test' do 8 | it 'works for inline' do 9 | TrustedSandbox.run_code!('input[:x] ** 2', input: {x: 10}).should == 100 10 | 11 | response = TrustedSandbox.run_code('puts "hi"; input[:x] ** 2', input: {x: 10}) 12 | response.valid?.should == true 13 | response.output.should == 100 14 | response.stdout.should == ["hi\n"] 15 | end 16 | 17 | it 'works for a class' do 18 | TrustedSandbox.run!(TrustedSandbox::GeneralPurpose, 'input[:x] ** 2', input: {x: 10}).should == 100 19 | 20 | response = TrustedSandbox.run(TrustedSandbox::GeneralPurpose, 'puts "hi"; input[:x] ** 2', input: {x: 10}) 21 | response.valid?.should == true 22 | response.output.should == 100 23 | response.stdout.should == ["hi\n"] 24 | end 25 | 26 | it 'works when there is an error' do 27 | expect {TrustedSandbox.run_code!('asfsadf')}.to raise_error(TrustedSandbox::UserCodeError) 28 | 29 | response = TrustedSandbox.run_code('asfsadf') 30 | response.valid?.should == false 31 | response.output.should == nil 32 | response.status.should == 'error' 33 | response.error.is_a?(NameError).should == true 34 | response.error_to_raise.is_a?(TrustedSandbox::UserCodeError).should == true 35 | end 36 | end 37 | 38 | describe 'memory limit' do 39 | it 'raises error when limited' do 40 | response = TrustedSandbox.with_options(memory_limit: 50_000_000) do |s| 41 | s.run_code('x = "*" * 50_000_000') 42 | end 43 | response.valid?.should == false 44 | response.stderr.should == ["Killed\n"] 45 | end 46 | 47 | it 'works when not limited' do 48 | response = TrustedSandbox.with_options(memory_limit: 150_000_000) do |s| 49 | s.run_code('x = "*" * 50_000_000') 50 | end 51 | response.stderr.should be_empty 52 | response.stdout.should be_empty 53 | response.valid?.should == true 54 | end 55 | end 56 | 57 | describe 'network limit' do 58 | it 'raises error when limited' do 59 | response = TrustedSandbox.with_options(network_access: false) do |s| 60 | s.run_code('`ping www.google.com -c 1; echo $?`.split("\n").last') 61 | end 62 | response.stderr.should == ["ping: unknown host www.google.com\n"] 63 | response.output.to_i.should_not == 0 64 | end 65 | 66 | it 'works when not limited' do 67 | response = TrustedSandbox.with_options(network_access: true) do |s| 68 | s.run_code('`ping www.google.com -c 1; echo $?`.split("\n").last') 69 | end 70 | response.stderr.should be_empty 71 | response.output.to_i.should == 0 72 | end 73 | end 74 | 75 | describe 'time limit' do 76 | it 'raises error' do 77 | response = TrustedSandbox.with_options(execution_timeout: 1) do |s| 78 | s.run_code('puts "hi"; while true; end') 79 | end 80 | response.valid?.should == false 81 | response.error.is_a?(Timeout::Error).should == true 82 | response.error_to_raise.is_a?(TrustedSandbox::ExecutionTimeoutError).should == true 83 | end 84 | end 85 | 86 | describe 'shortcut used' do 87 | before do 88 | dont_allow(Docker::Container).create 89 | end 90 | context 'no issue' do 91 | it 'works' do 92 | TrustedSandbox.with_options(shortcut: true) do |s| 93 | s.run_code!('input[:x] ** 2', input: {x: 10}).should == 100 94 | end 95 | 96 | response = TrustedSandbox.with_options(shortcut: true) do |s| 97 | s.run_code('puts "hi"; input[:x] ** 2', input: {x: 10}) 98 | end 99 | response.valid?.should == true 100 | response.output.should == 100 101 | response.stdout.should == ["hi\n"] 102 | end 103 | end 104 | 105 | context 'timeout' do 106 | it 'raises error' do 107 | response = TrustedSandbox.with_options(shortcut: true, execution_timeout: 1) do |s| 108 | s.run_code('puts "hi"; while true; end') 109 | end 110 | response.valid?.should == false 111 | response.error.is_a?(Timeout::Error).should == true 112 | response.error_to_raise.is_a?(TrustedSandbox::ExecutionTimeoutError).should == true 113 | end 114 | end 115 | 116 | context 'user error' do 117 | it 'raises error' do 118 | expect {TrustedSandbox.with_options(shortcut: true) {|s| s.run_code!('asfsadf')}}.to raise_error(TrustedSandbox::UserCodeError) 119 | 120 | response = TrustedSandbox.with_options(shortcut: true) {|s| s.run_code('asfsadf') } 121 | response.valid?.should == false 122 | response.output.should == nil 123 | response.status.should == 'error' 124 | response.error.is_a?(NameError).should == true 125 | response.error_to_raise.is_a?(TrustedSandbox::UserCodeError).should == true 126 | end 127 | end 128 | end 129 | end -------------------------------------------------------------------------------- /lib/trusted_sandbox/config.rb: -------------------------------------------------------------------------------- 1 | module TrustedSandbox 2 | 3 | # Allows chaining so that specific invocations can override configurations. 4 | # Usage: 5 | # general_config = Defaults.new.override(pool_size: 10, memory_limit: 100) 6 | # specific_invocation = general_config.override(memory_limit: 200) 7 | # 8 | class Config 9 | attr_reader :fallback_config 10 | 11 | # = Class macros 12 | 13 | # Usage: 14 | # attr_reader_with_fallback :my_attribute 15 | # 16 | # Equivalent to: 17 | # def my_attribute 18 | # return @my_attribute if @my_attribute 19 | # return fallback_config.my_attribute if @my_attribute.nil? and fallback_config.respond_to?(:my_attribute) 20 | # nil 21 | # end 22 | # 23 | def self.attr_reader_with_fallback(*names) 24 | names.each do |name| 25 | define_method name do 26 | value = instance_variable_get("@#{name}") 27 | return value unless value.nil? 28 | return fallback_config.send(name) if fallback_config.respond_to?(name) 29 | nil 30 | end 31 | end 32 | end 33 | 34 | # Usage: 35 | # attr_accessor_with_fallback :my_attribute 36 | # 37 | # Equivalent to: 38 | # attr_reader_with_fallback :my_attribute 39 | # attr_writer :my_attribute 40 | # 41 | def self.attr_accessor_with_fallback(*names) 42 | names.each do |name| 43 | attr_reader_with_fallback(name) 44 | attr_writer(name) 45 | end 46 | end 47 | 48 | attr_accessor_with_fallback :pool_size, :pool_min_uid, :pool_timeout, :pool_retries, :pool_delay, :docker_options, 49 | :memory_limit, :memory_swap_limit, :cpu_shares, :docker_image_name, 50 | :execution_timeout, :network_access, :enable_swap_limit, :enable_quotas, 51 | :container_code_path, :container_input_filename, :container_output_filename, 52 | :keep_code_folders, :keep_containers, :quiet_mode, :container_manifest_filename, 53 | :shortcut 54 | 55 | attr_reader_with_fallback :host_code_root_path, :host_uid_pool_lock_path 56 | 57 | attr_reader :docker_url, :docker_cert_path, :docker_auth_email, :docker_auth_user, :docker_auth_password, 58 | :docker_auth_needed 59 | 60 | # @param params [Hash] hash of parameters used to override the existing config object's attributes 61 | # @return [Config] a new object with the fallback object set to self 62 | def override(params={}) 63 | Config.send :new, self, params 64 | end 65 | 66 | # @return [Integer] the upper boundary of the uid pool based on pool_min_uid and pool_size 67 | def pool_max_uid 68 | pool_min_uid + pool_size - 1 69 | end 70 | 71 | # @param url [String] URL for Docker daemon. Will be sent to the Docker class 72 | # @return [String] the URL 73 | def docker_url=(url) 74 | @docker_url = url 75 | Docker.url = url 76 | end 77 | 78 | # Prepare to set Docker.options appropriately given a path to the cert directory. 79 | # @param path [String] path to the certificate directory 80 | # @return [Hash] of docker options that will be set 81 | def docker_cert_path=(path) 82 | @docker_cert_path = File.expand_path(path) 83 | @docker_options_for_cert = { 84 | private_key_path: "#{@docker_cert_path}/key.pem", 85 | certificate_path: "#{@docker_cert_path}/cert.pem", 86 | ssl_verify_peer: false 87 | } 88 | end 89 | 90 | # @param path [String] shorthand version of the path. E.g.: '~/tmp' 91 | # @return [String] the full path that was set. E.g.: '/home/user/tmp' 92 | def host_code_root_path=(path) 93 | @host_code_root_path = File.expand_path(path) 94 | end 95 | 96 | # @param path [String] shorthand version of the path 97 | # @return [String] the full path that was set 98 | def host_uid_pool_lock_path=(path) 99 | @host_uid_pool_lock_path = File.expand_path(path) 100 | end 101 | 102 | # Set hash used to authenticate with Docker 103 | # All keys are mandatory 104 | # @option :user [String] 105 | # @option :password [String] 106 | # @option :email [String] 107 | def docker_login=(options={}) 108 | @docker_auth_needed = true 109 | @docker_auth_user = options[:user] || options['user'] 110 | @docker_auth_password = options[:password] || options['password'] 111 | @docker_auth_email = options[:email] || options['email'] 112 | end 113 | 114 | # Called to do any necessary setup to allow staged configuration. These involve: 115 | # - Setting Docker.options based on the cert path 116 | # - Calling Docker.authenticate! with the login parameters, if these were entered 117 | # @return [Config] self for chaining 118 | def finished_configuring 119 | Docker.options = @docker_options_for_cert.merge(docker_options) 120 | 121 | return self unless @docker_auth_needed 122 | Docker.authenticate! username: @docker_auth_user, password: @docker_auth_password, email: @docker_auth_email 123 | @docker_auth_needed = false 124 | self 125 | end 126 | 127 | private_class_method :new 128 | 129 | # @params fallback_config [Config] config object that will be deferred to if the current config object does not 130 | # contain a value for the requested configuration options 131 | # @params params [Hash] hash containing configuration options 132 | def initialize(fallback_config, params={}) 133 | @docker_options_for_cert = {} 134 | @fallback_config = fallback_config 135 | params.each do |key, value| 136 | send "#{key}=", value 137 | end 138 | end 139 | 140 | end 141 | end -------------------------------------------------------------------------------- /lib/trusted_sandbox/host_runner.rb: -------------------------------------------------------------------------------- 1 | module TrustedSandbox 2 | class HostRunner 3 | 4 | attr_reader :uid_pool, :config 5 | 6 | # @param config [Config] 7 | # @param uid_pool [UidPool] 8 | # @param config_override [Hash] allows overriding configurations for a specific invocation 9 | def initialize(config, uid_pool, config_override={}) 10 | @config = config.override(config_override) 11 | @uid_pool = uid_pool 12 | end 13 | 14 | # @param klass [Class] the class object that should be run 15 | # @param *args [Array] arguments to send to klass#initialize 16 | # @return [Response] 17 | def run(klass, *args) 18 | if config.shortcut 19 | shortcut(klass, *args) 20 | else 21 | run_in_container(klass, *args) 22 | end 23 | end 24 | 25 | # @param klass [Class] the class object that should be run 26 | # @param *args [Array] arguments to send to klass#initialize 27 | # @return [Object] return value from the #eval method 28 | # @raise [InternalError, UserCodeError, ContainerError] 29 | def run!(klass, *args) 30 | run(klass, *args).output! 31 | end 32 | 33 | # @param code [String] code to be evaluated 34 | # @param args [Hash] hash to send to GeneralPurpose 35 | # @return [Response] 36 | def run_code(code, args={}) 37 | run(GeneralPurpose, code, args) 38 | end 39 | 40 | # @param code [String] code to be evaluated 41 | # @param args [Hash] hash to send to GeneralPurpose 42 | # @return [Object] return value from the #eval method 43 | # @raise [InternalError, UserCodeError, ContainerError] 44 | def run_code!(code, args={}) 45 | run!(GeneralPurpose, code, args) 46 | end 47 | 48 | private 49 | 50 | def run_in_container(klass, *args) 51 | create_code_dir 52 | serialize_request(klass, *args) 53 | create_container 54 | start_container 55 | ensure 56 | release_uid 57 | remove_code_dir 58 | remove_container 59 | end 60 | 61 | def obtain_uid 62 | @uid ||= uid_pool.lock 63 | end 64 | 65 | def release_uid 66 | uid_pool.release(@uid) if @uid and !config.keep_code_folders 67 | end 68 | 69 | def code_dir_path 70 | @code_dir_path ||= File.join config.host_code_root_path, obtain_uid.to_s 71 | end 72 | 73 | def remove_code_dir 74 | FileUtils.rm_rf code_dir_path unless config.keep_code_folders 75 | end 76 | 77 | def create_code_dir 78 | if config.keep_code_folders and !config.quiet_mode 79 | puts "Creating #{code_dir_path}" 80 | puts nil 81 | puts 'To launch and ssh into a new docker container with this directory mounted, run:' 82 | puts '-' * 80 83 | puts %{docker run -it -v #{code_dir_path}:/home/sandbox/src --entrypoint="/bin/bash" #{config.docker_image_name} -s} 84 | puts nil 85 | end 86 | 87 | FileUtils.mkdir_p code_dir_path 88 | end 89 | 90 | def serialize_request(klass, *args) 91 | serializer = RequestSerializer.new(code_dir_path, config.container_manifest_filename, config.container_input_filename) 92 | serializer.serialize(klass, *args) 93 | end 94 | 95 | def create_container 96 | @container = Docker::Container.create create_container_request 97 | end 98 | 99 | def start_container 100 | @container.start start_container_request 101 | stdout, stderr = nil, nil 102 | Timeout.timeout(config.execution_timeout) do 103 | stdout, stderr = @container.attach(stream: true, stdin: nil, stdout: true, stderr: true, logs: true, tty: false) 104 | end 105 | response = TrustedSandbox::Response.new stdout, stderr, code_dir_path, config.container_output_filename 106 | response.parse! 107 | response 108 | rescue Timeout::Error => e 109 | logs = @container.logs(stdout: true, stderr: true) 110 | TrustedSandbox::Response.error(e, TrustedSandbox::ExecutionTimeoutError, logs) 111 | end 112 | 113 | # @return [TrustedSandbox::Response] 114 | def shortcut(klass, *args) 115 | output, stdout, stderr = Timeout.timeout(config.execution_timeout) do 116 | begin 117 | $stdout = StringIO.new 118 | $stderr = StringIO.new 119 | [klass.new(*args).run, $stdout.string, $stderr.string] 120 | ensure 121 | $stdout = STDOUT 122 | $stderr = STDERR 123 | end 124 | end 125 | TrustedSandbox::Response.shortcut output, stdout, stderr 126 | rescue Timeout::Error => e 127 | TrustedSandbox::Response.error(e, TrustedSandbox::ExecutionTimeoutError, stdout, stderr) 128 | rescue => e 129 | TrustedSandbox::Response.error(e, TrustedSandbox::UserCodeError, stdout, stderr) 130 | end 131 | 132 | def remove_container 133 | return unless @container and !config.keep_containers 134 | @container.delete force: true 135 | end 136 | 137 | def create_container_request 138 | basic_request = { 139 | # 'Hostname' => '', 140 | # 'Domainname' => '', 141 | # 'User' => '', 142 | 'CpuShares' => config.cpu_shares, 143 | 'Memory' => config.memory_limit, 144 | # 'Cpuset' => '0,1', 145 | 'AttachStdin' => false, 146 | 'AttachStdout' => true, 147 | 'AttachStderr' => true, 148 | # 'PortSpecs' => null, 149 | 'Tty' => false, 150 | 'OpenStdin' => false, 151 | 'StdinOnce' => false, 152 | 'Cmd' => [@uid.to_s], 153 | 'Image' => config.docker_image_name, 154 | 'Volumes' => { 155 | config.container_code_path => {} 156 | }, 157 | # 'WorkingDir' => '', 158 | 'NetworkDisabled' => !config.network_access, 159 | # 'ExposedPorts' => { 160 | # '22/tcp' => {} 161 | # } 162 | } 163 | basic_request.merge!('MemorySwap' => config.memory_swap_limit) if config.enable_swap_limit 164 | basic_request.merge!('Env' => ['USE_QUOTAS=1']) if config.enable_quotas 165 | basic_request 166 | end 167 | 168 | def start_container_request 169 | { 170 | 'Binds' => ["#{code_dir_path}:#{config.container_code_path}"], 171 | # 'Links' => ['redis3:redis'], 172 | # 'LxcConf' => {'lxc.utsname' => 'docker'}, 173 | # 'PortBindings' => {'22/tcp' => [{'HostPort' => '11022'}]}, 174 | # 'PublishAllPorts' => false, 175 | # 'Privileged' => false, 176 | # 'Dns' => ['8.8.8.8'], 177 | # 'VolumesFrom' => ['parent', 'other:ro'], 178 | # 'CapAdd' => ['NET_ADMIN'], 179 | # 'CapDrop' => ['MKNOD'] 180 | } 181 | end 182 | end 183 | end -------------------------------------------------------------------------------- /spec/lib/trusted_sandbox/host_runner_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe TrustedSandbox::HostRunner do 4 | 5 | before do 6 | @defaults = TrustedSandbox::Defaults.send(:new).override(quiet_mode: true) 7 | @uid_pool = Object.new 8 | end 9 | 10 | describe 'UID pool and code dir handling' do 11 | context 'keep_code_folders=false' do 12 | before do 13 | mock(@uid_pool).lock { 100 } 14 | mock(@uid_pool).release(100) {} 15 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, keep_code_folders: false 16 | stub(@subject).create_container 17 | stub(@subject).start_container 18 | end 19 | 20 | it 'locks and releases from UID pool' do 21 | @subject.run TrustedSandbox::HostRunner 22 | end 23 | 24 | it 'deletes the code folder' do 25 | @subject.run TrustedSandbox::HostRunner 26 | Dir.exists?(@subject.send(:code_dir_path)).should == false 27 | end 28 | end 29 | 30 | context 'keep_code_folders=true' do 31 | before do 32 | mock(@uid_pool).lock { 100 } 33 | dont_allow(@uid_pool).release 34 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, keep_code_folders: true 35 | stub(@subject).create_container 36 | stub(@subject).start_container 37 | end 38 | 39 | it 'locks but does not release from UID pool' do 40 | @subject.run TrustedSandbox::HostRunner 41 | end 42 | 43 | it 'does not delete the code folder' do 44 | @subject.run TrustedSandbox::HostRunner 45 | Dir.exists?(@subject.send(:code_dir_path)).should == true 46 | end 47 | end 48 | end 49 | 50 | describe 'container creation' do 51 | before do 52 | stub(@uid_pool).lock { 100 } 53 | stub(@uid_pool).release(100) {} 54 | 55 | container = Object.new 56 | @container = container 57 | 58 | create_req = {} 59 | stub(Docker::Container).create { |req| create_req.clear; create_req.merge!(req); container } 60 | @create_req = create_req 61 | 62 | start_req = {} 63 | stub(container).start { |req| start_req.clear; start_req.merge!(req) } 64 | @start_req = start_req 65 | 66 | mock(container).attach(stream: true, stdin: nil, stdout: true, stderr: true, logs: true, tty: false) { ['stdout', 'stderr'] } 67 | end 68 | 69 | context 'keep_containers=true' do 70 | before do 71 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, keep_containers: true 72 | dont_allow(@container).delete 73 | end 74 | 75 | it 'does not delete the container' do 76 | @subject.run TrustedSandbox::HostRunner 77 | end 78 | end 79 | 80 | context 'keep_containers=false' do 81 | before do 82 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, keep_containers: false 83 | mock(@container).delete(force: true) {} 84 | end 85 | 86 | it 'does not delete the container' do 87 | @subject.run TrustedSandbox::HostRunner 88 | end 89 | end 90 | 91 | context 'basic request parameters' do 92 | before do 93 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, cpu_shares: 5, memory_limit: 100, 94 | docker_image_name: 'image', container_code_path: '/code', 95 | network_access: false, keep_containers: true 96 | @subject.run TrustedSandbox::HostRunner 97 | end 98 | 99 | it 'sends the right requests' do 100 | @create_req.should == {"CpuShares"=>5, "Memory"=>100, "AttachStdin"=>false, "AttachStdout"=>true, "AttachStderr"=>true, "Tty"=>false, "OpenStdin"=>false, "StdinOnce"=>false, "Cmd"=>["100"], "Image"=>"image", "Volumes"=>{"/code"=>{}}, "NetworkDisabled"=>true} 101 | @start_req.should == {"Binds"=>["#{File.expand_path('tmp/code_dirs/100')}:/code"]} 102 | end 103 | end 104 | 105 | context 'enable_quotas=true' do 106 | before do 107 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, enable_quotas: true, keep_containers: true 108 | @subject.run TrustedSandbox::HostRunner 109 | end 110 | 111 | it 'sends the right request' do 112 | @create_req['Env'].should == ['USE_QUOTAS=1'] 113 | end 114 | end 115 | 116 | context 'enable_quotas=false' do 117 | before do 118 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, enable_quotas: false, keep_containers: true 119 | @subject.run TrustedSandbox::HostRunner 120 | end 121 | 122 | it 'sends the right request' do 123 | @create_req['Env'].should be_nil 124 | end 125 | end 126 | 127 | context 'enable_swap_limit=true' do 128 | before do 129 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, enable_swap_limit: true, memory_swap_limit: 200, keep_containers: true 130 | @subject.run TrustedSandbox::HostRunner 131 | end 132 | 133 | it 'sends the right request' do 134 | @create_req['MemorySwap'].should == 200 135 | end 136 | end 137 | 138 | context 'enable_swap_limit=false' do 139 | before do 140 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, enable_swap_limit: false, memory_swap_limit: 200, keep_containers: true 141 | @subject.run TrustedSandbox::HostRunner 142 | end 143 | 144 | it 'sends the right request' do 145 | @create_req['MemorySwap'].should be_nil 146 | end 147 | end 148 | 149 | context 'network_access=true' do 150 | before do 151 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, network_access: true, keep_containers: true 152 | @subject.run TrustedSandbox::HostRunner 153 | end 154 | 155 | it 'sends the right request' do 156 | @create_req['NetworkDisabled'].should == false 157 | end 158 | end 159 | 160 | context 'network_access=false' do 161 | before do 162 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, network_access: false, keep_containers: true 163 | @subject.run TrustedSandbox::HostRunner 164 | end 165 | 166 | it 'sends the right request' do 167 | @create_req['NetworkDisabled'].should == true 168 | end 169 | end 170 | end 171 | 172 | # See more in integration testing 173 | describe 'shortcut used' do 174 | before do 175 | dont_allow(TrustedSandbox::RequestSerializer).new 176 | dont_allow(@uid_pool).lock 177 | dont_allow(Docker::Container).create 178 | @subject = TrustedSandbox::HostRunner.new @defaults, @uid_pool, shortcut: true 179 | end 180 | 181 | it 'works' do 182 | @subject.run_code!('true').should == true 183 | end 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Trusted Sandbox 2 | 3 | Run untrusted code in a contained sandbox, using Docker. This gem was inspired by [Harry Marr's work][1]. 4 | 5 | ## Instant gratification 6 | 7 | Trusted Sandbox makes it simple to execute classes that `eval` untrusted code in a resource-controlled docker 8 | container. 9 | 10 | The simplest way to get started is run "inline" code within a container: 11 | 12 | ```ruby 13 | require 'trusted_sandbox' 14 | 15 | untrusted_code = "input[:number] ** 2" 16 | 17 | # The following will run inside a Docker container 18 | output = TrustedSandbox.run_code! untrusted_code, input: {number: 10} 19 | # => 100 20 | ``` 21 | 22 | `run_code!` receives user code and an arguments hash. Any key in the arguments hash is available when the user code 23 | executes. 24 | 25 | In addition, you can send any class to execute within a Docker container. All you need is to have the class respond to 26 | `initialize` and `run`. Trusted Sandbox loads the container, copies the class file to the container, serializes the 27 | arguments sent to `initialize`, instantiates an object, calls `run`, and serializes its return value back to the host. 28 | 29 | ```ruby 30 | # lib/my_function.rb 31 | 32 | class MyFunction 33 | attr_reader :input 34 | 35 | def initialize(user_code, input) 36 | @user_code = user_code 37 | @input = input 38 | end 39 | 40 | def run 41 | eval @user_code 42 | end 43 | end 44 | ``` 45 | ```ruby 46 | # somewhere_else.rb 47 | require 'trusted_sandbox' 48 | require 'lib/my_function' 49 | 50 | untrusted_code = "input[:number] ** 2" 51 | 52 | # The following will run inside a Docker container 53 | output = TrustedSandbox.run! MyFunction, untrusted_code, {number: 10} 54 | # => 100 55 | ``` 56 | 57 | ## Installing 58 | 59 | ### Step 1 60 | Add this line to your application's Gemfile: 61 | ``` 62 | gem 'trusted-sandbox' 63 | ``` 64 | 65 | And then execute: 66 | ``` 67 | $ bundle 68 | ``` 69 | Or install it yourself as: 70 | ``` 71 | $ gem install trusted-sandbox 72 | ``` 73 | 74 | ### Step 2 75 | Install Docker, Server version >= 1.2.0. Note that at the time of writing some distro package management systems have 76 | an earlier version. Refer to the Docker documentation to see how to install the latest Docker on your environment. 77 | 78 | Note that on a Linux server the docker daemon runs as root, and the root user owns the socket used to connect to the 79 | daemon. In order to avoid the need to run your application with sudo privileges, add the application user to the 80 | `docker` group: 81 | ``` 82 | # keep `${USER}` for the connected user or change to suit your needs 83 | $ sudo gpasswd -a ${USER} docker 84 | $ sudo service docker.io restart 85 | ``` 86 | then reconnect to shell session and try the following (without sudo): 87 | ``` 88 | $ docker images 89 | ``` 90 | If it works, then you are all set. 91 | 92 | You can read more about this issue [here][5]. 93 | 94 | ### Step 3 95 | Run the following command which will copy the `trusted_sandbox.yml` file into your current directory, or 96 | `config` directory if it exists: 97 | ``` 98 | $ trusted_sandbox install 99 | ``` 100 | 101 | Then follow the configuration instructions in this guide. Once you're done configuring, test your installation by 102 | running: 103 | ``` 104 | $ trusted_sandbox test 105 | ``` 106 | 107 | ### Step 4 108 | Install the image. This step is optional, as Docker automatically installs images when you first run them. However, 109 | since it takes a few minutes we suggest you do this in advance. 110 | ``` 111 | $ docker run --rm vaharoni/trusted_sandbox:ruby-2.1.2.v2 112 | ``` 113 | If you see the message "you must provide a uid", then you are set. 114 | 115 | Consider restarting the docker service if you receive an error that looks like this: 116 | `Error response from daemon: Cannot start container 9f3bd8d72f0704980cedacc068261c38e280e7314916245550a6d48431ea8f11: 117 | fork/exec /var/lib/docker/init/dockerinit-1.0.1: cannot allocate memory` 118 | 119 | ``` 120 | $ sudo service docker.io restart 121 | ``` 122 | and then try again. 123 | 124 | ### Step 5 125 | 126 | If you'd like to limit swap memory or set user quotas you'll have to install additional programs on your server. 127 | Follow the instructions in the relevant sections of the configuration guide. 128 | 129 | ## Configuring Trusted Sandbox 130 | 131 | Let's go over the sections of the YAML configuration file you created in step 3 above. 132 | The top key of the YAML file is an environment string that can be set by `TRUSTED_SANDBOX_ENV` or `RAILS_ENV` 133 | environment variables. 134 | 135 | ### Docker connection 136 | 137 | Trusted Sandbox uses the `docker-api` gem to communicate with docker. `docker-api`'s defaults work quite well for a 138 | Linux host, and you should be good by omitting `docker_url` and `docker_cert_path` all together. 139 | 140 | The following configurations work for a Mac OS host: 141 | 142 | ```ruby 143 | # If omitted ENV['DOCKER_HOST'] is used. If it is not set, docker-api defaults are used. 144 | docker_url: https://192.168.59.103:2376 145 | 146 | # If omitted ENV['DOCKER_CERT_PATH'] is used. If it is not set, docker-api defaults are used. 147 | docker_cert_path: ~/.boot2docker/certs/boot2docker-vm 148 | ``` 149 | If you need finer control of `docker-api` configuration, you can add a `docker_options` hash entry to the 150 | YAML file which will override any configuration and passed through to `Docker.options`. 151 | 152 | In addition, these docker-related configuration parameters can be used: 153 | ```ruby 154 | docker_image_name: vaharoni/trusted_sandbox:ruby-2.1.2.v2 155 | 156 | # Optional authentication 157 | docker_login: 158 | user: my_user 159 | password: my_password 160 | email: email@email.com 161 | ``` 162 | 163 | 164 | ### Limiting resources 165 | CPU: 166 | ```ruby 167 | cpu_shares: 1 # In relative units 168 | ``` 169 | Memory: 170 | ```ruby 171 | memory_limit: 52_428_800 # In bytes 172 | enable_swap_limit: false 173 | memory_swap_limit: 52_428_800 # In bytes. Relevant only if enable_swap_limit is true. 174 | ``` 175 | Execution 176 | ```ruby 177 | execution_timeout: 15 # In seconds 178 | network_access: false 179 | ``` 180 | Quotas 181 | ```ruby 182 | enable_quotas: false 183 | ``` 184 | Settings for UID-pool used for assigning user quotas. Always used, even if quota functionality is disabled. 185 | It's very unlikely you'll need to touch these: 186 | ```ruby 187 | pool_size: 5000 188 | pool_min_uid: 20000 189 | pool_timeout: 3 190 | pool_retries: 5 191 | pool_delay: 0.5 192 | ``` 193 | Note that controlling memory swap limits and user quotas requires additional steps as outlined below. 194 | 195 | ### Execution parameters 196 | 197 | ```ruby 198 | # A temporary folder under which sub folders are created and mounted to containers. 199 | # The code and args exchange between the host and containers is done via these sub folders. 200 | host_code_root_path: tmp/code_dirs 201 | 202 | # When set to true, the temporary sub folders will not be erased. This allows you to login 203 | # to the container to troubleshoot issues as explained in the "Troubleshooting" section. 204 | keep_code_folders: false 205 | 206 | # When set to true, containers will not be erased after they finish running. This allows you 207 | # to troubleshoot issues by viewing container parameters and logs as explained in the 208 | # "Troubleshooting" section. 209 | keep_containers: false 210 | 211 | # A folder used by the UID-pool to handle locks. 212 | host_uid_pool_lock_path: tmp/uid_pool_lock 213 | 214 | # When set to true the code is executed within the current process, without launching a 215 | # Docker container. This is useful for testing and on dev machines that do not have Docker 216 | # installed. 217 | shortcut: false 218 | ``` 219 | 220 | ### Limiting swap memory 221 | 222 | In order to limit swap memory, you'll need to set up your host server to allow that. 223 | The following should work for Debian / Ubuntu. 224 | 225 | First, run: 226 | ``` 227 | $ sudoedit /etc/default/grub 228 | ``` 229 | and edit the following line: 230 | ``` 231 | GRUB_CMDLINE_LINUX="cgroup_enable=memory swapaccount=1" 232 | ``` 233 | Then run: 234 | ``` 235 | $ sudo update-grub 236 | ``` 237 | Reboot the server, and you should be set. Read more about it [here][2]. 238 | Remember to set `enable_swap_limit: true` in the YAML file. 239 | 240 | ### Limiting user quotas 241 | 242 | Note: due to permission setting scheme, limiting user quota does not work on OS or Windows. 243 | 244 | In order to control quotas we follow the technique suggested by [Harry Marr][3]. It makes use of the fact that 245 | UIDs (user IDs) and GIDs (Group IDs) are shared between the host and its containers. When a container starts, we 246 | run the untrusted code under an unprivileged user whose UID has a quota enforced by the host. 247 | 248 | In order to enable quotas do the following on the server: 249 | ``` 250 | $ sudo apt-get install quota 251 | ``` 252 | And follow [these instructions][4] as well as [this resource][6], which we bring here for completeness. Note that these 253 | may vary for your distro. 254 | 255 | ``` 256 | $ sudo vim /etc/fstab 257 | ``` 258 | Add `,usrquota` in the end of column no. 4 so it looks something like: 259 | ``` 260 | LABEL=cloudimg-rootfs / ext4 defaults,discard,usrquota 0 0 261 | ``` 262 | Then do: 263 | ``` 264 | $ sudo touch /aquota.user 265 | $ sudo chmod 600 /aquota.* 266 | $ sudo mount -o remount / 267 | ``` 268 | and **reboot the server**. Then do: 269 | ``` 270 | $ sudo quotacheck -avum 271 | $ sudo quotaon -avu 272 | ``` 273 | You should see something like this: 274 | ``` 275 | /dev/disk/by-uuid/d36a9e2f-dae9-477f-8aea-29f1bdd1c04e [/]: user quotas turned on 276 | ``` 277 | 278 | To actually set the quotas, run the following (quota is in KB): 279 | ``` 280 | $ trusted_sandbox set_quotas 10000 281 | ``` 282 | This sets ~10MB quota on all UIDs that are in the range defined by `pool_size` and `pool_min_uid` parameters. If you 283 | change these configuration parameters you must rerun the `set_quotas` command. 284 | 285 | Remember to set `enable_quotas: true` in the YAML file. 286 | 287 | To get a quota report, do: 288 | ``` 289 | $ sudo repquota -a 290 | ``` 291 | 292 | ### Limiting network 293 | 294 | The only option available is to turn on and off network access using `enable_network`. Finer control of network 295 | access is currently not supported. If you need this feature please open an issue and share your use case. 296 | 297 | ## Using Trusted Sandbox 298 | 299 | ### Class and argument serialization 300 | 301 | The class you send to a container can be as elaborate as you want, providing a context of execution for the user code. 302 | When you call `run` or `run!` with a class constant, the file where that class is defined is copied to the 303 | `/home/sandbox/src` folder inside the container. Any arguments needed to instantiate an object from that class are 304 | serialized. When the container starts, it deserializes these arguments, invokes the `new` method with them, and runs 305 | `run` on the instantiated object. The output of that method is then serialized back to the host. 306 | 307 | A less trivial example: 308 | ```ruby 309 | # my_function.rb 310 | 311 | # Example for requiring a gem, assuming it is in the Gemfile of both the container and the 312 | # host. If you want to access a gem that is only available to the container, put the require 313 | # directive inside `initialize` or `run` methods. 314 | require 'hashie/mash' 315 | 316 | class MyFunction 317 | 318 | attr_reader :a, :b 319 | def initialize(first_user_func, second_user_func, a, b) 320 | @first_user_func = first_user_func 321 | @second_user_func = second_user_func 322 | @a = a 323 | @b = b 324 | end 325 | 326 | def run 327 | # Will have access to #a and #b through attr_reader 328 | result1 = eval(@first_user_func) 329 | 330 | result2 = Context.new(result1).run(@second_user_func) 331 | [result1, result2] 332 | end 333 | 334 | class Context 335 | attr_reader :x 336 | def initialize(x) 337 | @x = x 338 | end 339 | 340 | def run(code) 341 | eval code 342 | end 343 | end 344 | end 345 | ``` 346 | ```ruby 347 | # Somewhere else 348 | require 'trusted_sandbox' 349 | require 'my_function' 350 | a, b = TrustedSandbox.run! MyFunction, "a + b", "x ** 2", 2, 5 351 | # => 49 352 | ``` 353 | Because serialization occurs through Marshalling, you should use primitive Ruby classes for your inputs as much as 354 | possible. You can prepare a docker image with additional gems and custom Ruby classes, as explained in the 355 | "Using custom docker images" section. 356 | 357 | ### Running containers 358 | 359 | There are two main methods to run a container. 360 | 361 | Use `run!` to retrieve output from the container. If the user code raised 362 | an exception, it will be raised by `run!`. 363 | 364 | ```ruby 365 | output = TrustedSandbox.run! MyFunction, "input ** 2", 10 366 | # => 100 367 | ``` 368 | Use `run` to retrieve a response object. The response object provides additional useful information about the 369 | container execution. 370 | 371 | Here is a success scenario: 372 | ```ruby 373 | response = TrustedSandbox.run MyFunction, "input ** 2", 10 374 | 375 | response.status 376 | # => "success" 377 | 378 | response.valid? 379 | # => true 380 | 381 | response.output 382 | # => 100 383 | 384 | response.output! 385 | # => 100 386 | 387 | response.error 388 | # => nil 389 | ``` 390 | Here is an error scenario: 391 | ```ruby 392 | response = TrustedSandbox.run MyFunction, "raise 'error!'", 10 393 | 394 | response.status 395 | # => "error" 396 | 397 | response.valid? 398 | # => false 399 | 400 | response.output 401 | # => nil 402 | 403 | response.output! 404 | # => TrustedSandbox::UserCodeError: error! 405 | 406 | response.error 407 | # => # 408 | 409 | response.error.backtrace 410 | # => /home/sandbox/src/my_function.rb:14:in `eval' 411 | # => /home/sandbox/src/my_function.rb:14:in `eval' 412 | # => /home/sandbox/src/my_function.rb:14:in `run' 413 | 414 | # Can be useful if MyFunction prints to stdout 415 | puts response.stdout 416 | 417 | # Can be useful for environment related errors 418 | puts response.stderr 419 | ``` 420 | 421 | The helper methods `run_code` and `run_code!` behave similarly to `run` and `run!`. They invoke TrustedSandbox 422 | on a `GeneralPurpose` class that performs a simple `eval`, with an ability to provide a context for the code to run in. 423 | The following: 424 | ```ruby 425 | TrustedSandbox.run_code! "input[:a] + input[:b]", input: {a: 1, b: 2} 426 | # => 3 427 | ``` 428 | Is equivalent to running: 429 | ```ruby 430 | TrustedSandbox.run! TrustedSandbox::GeneralPurpose, "input[:a] + input[:b]", input: {a: 1, b: 2} 431 | # => 3 432 | ``` 433 | 434 | ### Overriding specific invocations 435 | 436 | To override a configuration parameter for a specific invocation, use `with_options`: 437 | ```ruby 438 | TrustedSandbox.with_options(cpu_shares: 2) do |s| 439 | s.run! MyFunction, untrusted_code, input 440 | end 441 | ``` 442 | You should not override user quota related parameters, as they must be prepared on the host in advance of execution. 443 | 444 | ## Using custom docker images 445 | 446 | Trusted Sandbox comes with one ready-to-use image that includes Ruby 2.1.2. It is hosted on Docker Hub under 447 | `vaharoni/trusted_sandbox:ruby-2.1.2.v2`. 448 | 449 | To use a different image from your Docker Hub account simply change the configuration parameters in the YAML file. 450 | 451 | To customize the provided images, run the following. It will copy the image definition to your current directory under 452 | `trusted_sandbox_images/ruby-2.1.2`. 453 | ``` 454 | $ trusted_sandbox generate_image 455 | ``` 456 | 457 | After modifying the files to your satisfaction, you can either push it to your Docker Hub account, or build directly 458 | on the server. Assuming you kept the image under trusted_sandbox_images/ruby-2.1.2: 459 | ``` 460 | $ docker build -t "your_user/your_image_name:your_image_version" trusted_sandbox_images/ruby-2.1.2 461 | ``` 462 | 463 | ## Troubleshooting 464 | 465 | If you encounter issues, try troubleshooting them by accessing your container's bash. Make the following change in the 466 | YAML file: 467 | 468 | ```ruby 469 | keep_code_folders: true 470 | ``` 471 | This will keep your code folders from getting deleted when containers stop running. This allows you to do the 472 | following from your command line (adjust to your environment): 473 | ``` 474 | $ docker run -it -v /home/MyUser/my_app/tmp/code_dirs/20000:/home/sandbox/src --entrypoint="/bin/bash" my_user/my_image:my_tag -s 475 | ``` 476 | Note that this will also take out that specific UID from the UID-pool so that future runs don't remount the same folder. 477 | To release that UID back to the pool, either reset that specific UID: 478 | ``` 479 | $ trusted_sandbox reset_uid_pool 20000 480 | ``` 481 | or reset all UIDs (make sure no other containers are running): 482 | ``` 483 | $ trusted_sandbox reset_uid_pool 484 | ``` 485 | 486 | To avoid containers from being deleted after they finish running, set: 487 | ```ruby 488 | keep_containers: true 489 | ``` 490 | This will allow you to view containers by running `docker ps -a` and then check out container logs 491 | `docker logs CONTAINER_ID` or container parameters `docker inspect CONTAINER_ID`. 492 | 493 | You will need to delete containers yourself by running `docker rm CONTAINER_ID`. To delete all of your containers do: 494 | ``` 495 | $ docker ps -aq | xargs docker rm 496 | ``` 497 | 498 | ## Contributing 499 | 500 | 1. Fork it 501 | 2. Create your feature branch (`git checkout -b my-new-feature`) 502 | 3. Commit your changes (`git commit -am 'Add some feature'`) 503 | 4. Push to the branch (`git push origin my-new-feature`) 504 | 5. Create new Pull Request 505 | 506 | ## License 507 | Licensed under the [MIT license](http://opensource.org/licenses/MIT). 508 | 509 | [1]: http://hmarr.com/2013/oct/16/codecube-runnable-gists/ 510 | [2]: https://www.digitalocean.com/community/tutorials/how-to-enable-user-and-group-quotas 511 | [3]: http://hmarr.com/2013/oct/16/codecube-runnable-gists/ 512 | [4]: https://www.digitalocean.com/community/tutorials/how-to-enable-user-quotas 513 | [5]: http://askubuntu.com/questions/477551/how-can-i-use-docker-without-sudo 514 | [6]: http://www.howtoforge.com/how-to-set-up-journaled-quota-on-debian-lenny --------------------------------------------------------------------------------