diff --git a/.circleci/config.yml b/.circleci/config.yml index c1a3bd9..3555376 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -41,6 +41,7 @@ jobs: POSTGRES_USER: "circleci" POSTGRES_DB: "safer_rails_console_test" POSTGRES_HOST_AUTH_METHOD: "trust" + - image: cimg/redis:6.2.6 working_directory: ~/safer_rails_console steps: - checkout diff --git a/README.md b/README.md index 4c058a8..b2647f4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Build Status](https://circleci.com/gh/salsify/safer_rails_console.svg?style=svg)](https://circleci.com/gh/salsify/safer_rails_console) [![Gem Version](https://badge.fury.io/rb/safer_rails_console.svg)](https://badge.fury.io/rb/safer_rails_console) -This gem makes Rails console sessions less dangerous in specified environments by warning, color-coding, and auto-sandboxing PostgreSQL connections. In the future we'd like to extend this to make other external connections read-only too (e.g. disable job queueing, non-GET HTTP requests, etc.) +This gem makes Rails console sessions less dangerous in specified environments by warning, color-coding, and auto-sandboxing PostgreSQL and Redis connections. In the future we'd like to extend this to make other external connections read-only too (e.g. disable job queueing, non-GET HTTP requests, etc.) ## Installation diff --git a/lib/safer_rails_console/patches/sandbox/redis_readonly.rb b/lib/safer_rails_console/patches/sandbox/redis_readonly.rb new file mode 100644 index 0000000..62bd51f --- /dev/null +++ b/lib/safer_rails_console/patches/sandbox/redis_readonly.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module SaferRailsConsole + module Patches + module Sandbox + module RedisReadonly + READ_COMMANDS = [ + 'hvals', 'bitcount', 'zscan', 'hget', 'smembers', 'hrandfield', 'zrevrange', 'bitpos', 'hlen', 'xlen', 'post', + 'zscore', 'dbsize', 'get', 'hstrlen', 'zrangebylex', 'scan', 'georadiusbymember_ro', 'zmscore', 'smismember', + 'zcount', 'lrange', 'stralgo', 'zrank', 'pttl', 'lpos', 'geopos', 'ttl', 'zrangebyscore', 'sdiff', 'llen', + 'sismember', 'zrevrangebyscore', 'zdiff', 'zrandmember', 'geodist', 'hexists', 'zrange', 'hmget', 'lindex', + 'zrevrangebylex', 'sunion', 'randomkey', 'zrevrank', 'xrange', 'xpending', 'hgetall', 'getrange', 'exists', + 'keys', 'georadius_ro', 'lolwut', 'hscan', 'object', 'zlexcount', 'type', 'geohash', 'touch', 'hkeys', + 'strlen', 'scard', 'substr', 'zinter', 'srandmember', 'mget', 'xinfo', 'geosearch', 'zunion', 'xread', + 'pfcount', 'xrevrange', 'sscan', 'memory', 'bitfield_ro', 'dump', 'host:', 'sinter', 'getbit', 'zcard' + ].freeze + + def self.raise_exception_on_write_command(command, service = ::Redis) + unless READ_COMMANDS.include?(command.downcase.to_s) + raise service.to_s.constantize::CommandError.new( + "Write commands are not allowed in readonly mode: #{command}" + ) + end + end + + def self.handle_and_reraise_exception(error) + if error.message.include?('Write commands are not allowed') + puts SaferRailsConsole::Colors.color_text( # rubocop:disable Rails/Output + 'An operation could not be completed due to read-only mode.', + SaferRailsConsole::Colors::RED + ) + end + + raise error + end + + module RedisPatch + def process(commands) + begin + command = commands.flatten.first + SaferRailsConsole::Patches::Sandbox::RedisReadonly.raise_exception_on_write_command(command) + rescue Redis::CommandError => e + SaferRailsConsole::Patches::Sandbox::RedisReadonly.handle_and_reraise_exception(e) + end + super + end + end + + module RedisClientPatch + def call(commands, redis_config) + command = commands.first + begin + SaferRailsConsole::Patches::Sandbox::RedisReadonly.raise_exception_on_write_command(command, + ::RedisClient) + rescue RedisClient::CommandError => e + SaferRailsConsole::Patches::Sandbox::RedisReadonly.handle_and_reraise_exception(e) + end + super + end + end + + ::Redis::Client.prepend(RedisPatch) if defined?(::Redis::Client) + ::RedisClient.register(RedisClientPatch) if defined?(::RedisClient) + end + end + end +end diff --git a/safer_rails_console.gemspec b/safer_rails_console.gemspec index 5f80a48..2155e24 100644 --- a/safer_rails_console.gemspec +++ b/safer_rails_console.gemspec @@ -42,6 +42,8 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'overcommit', '~> 0.39.0' spec.add_development_dependency 'pg', '~> 1.1' spec.add_development_dependency 'rake', '~> 12.0' + spec.add_development_dependency 'redis', '~> 4.7' + spec.add_development_dependency 'redis-client', '~> 0.11' spec.add_development_dependency 'rspec', '~> 3.6' spec.add_development_dependency 'rspec_junit_formatter' spec.add_development_dependency 'salsify_rubocop', '~> 1.27.0' diff --git a/spec/integration/patches/sandbox_spec.rb b/spec/integration/patches/sandbox_spec.rb index e35438e..2764037 100644 --- a/spec/integration/patches/sandbox_spec.rb +++ b/spec/integration/patches/sandbox_spec.rb @@ -27,6 +27,41 @@ end end + context "redis readonly" do + it "enforces readonly commands" do + # Run a console session that makes some redis changes + run_console_commands('Redis.new.set("test", "value")') + + # Run a new console session to ensure the redis changes were not saved + result = run_console_commands('puts "Redis.get(\"test\").nil? = #{Redis.new.get(\'test\').nil?}"') # rubocop:disable Lint/InterpolationCheck Layout/LineLength + expect(result.stdout).to include('Redis.get("test").nil? = true') + end + + it "lets the user know that an operation could not be completed" do + result = run_console_commands('Redis.new.set("test", "value")') + expect(result.stdout).to include('An operation could not be completed due to read-only mode.') + end + end + + context "redis-client readonly" do + it "enforces readonly commands" do + # Run a console session that makes some redis changes + run_console_commands('RedisClient.new.call("SET", "test", "value")') + + # Run a new console session to ensure the redis changes were not saved + result = run_console_commands( + 'puts "RedisClient.new.call(\"GET\", \"test\").nil? = ' \ + '#{RedisClient.new.call(\'GET\', \'test\').nil?}"' # rubocop:disable Lint/InterpolationCheck + ) + expect(result.stdout).to include('RedisClient.new.call("GET", "test").nil? = true') + end + + it "lets the user know that an operation could not be completed" do + result = run_console_commands('RedisClient.new.call("SET", "test", "value")') + expect(result.stdout).to include('An operation could not be completed due to read-only mode.') + end + end + def run_console_commands(*commands) commands += ['exit'] run_console('--sandbox', input: commands.join("\n")) diff --git a/spec/internal/rails_6_0/Gemfile b/spec/internal/rails_6_0/Gemfile index 87e967a..78a426d 100644 --- a/spec/internal/rails_6_0/Gemfile +++ b/spec/internal/rails_6_0/Gemfile @@ -4,5 +4,7 @@ source 'https://rubygems.org' gem 'pg' gem 'rails', '~> 6.0.0' +gem 'redis', '~> 4.0.0' +gem 'redis-client', '~> 0.11' gem 'safer_rails_console', path: '../../../' diff --git a/spec/internal/rails_6_1/Gemfile b/spec/internal/rails_6_1/Gemfile index 1474a50..3144e06 100644 --- a/spec/internal/rails_6_1/Gemfile +++ b/spec/internal/rails_6_1/Gemfile @@ -4,5 +4,7 @@ source 'https://rubygems.org' gem 'pg' gem 'rails', '~> 6.1.0' +gem 'redis', '~> 4.0.0' +gem 'redis-client', '~> 0.11' gem 'safer_rails_console', path: '../../../' diff --git a/spec/internal/rails_7_0/Gemfile b/spec/internal/rails_7_0/Gemfile index f9ffce2..a34e402 100644 --- a/spec/internal/rails_7_0/Gemfile +++ b/spec/internal/rails_7_0/Gemfile @@ -8,5 +8,7 @@ source 'https://rubygems.org' gem 'pg' gem 'rails', '~> 7.0.0' +gem 'redis', '~> 4.0.0' +gem 'redis-client', '~> 0.11' gem 'safer_rails_console', path: '../../../'